1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum StrategyKind {
5 Ma,
6 Ema,
7 Atr,
8 Vlc,
9 Chb,
10 Orb,
11 Rsa,
12 Dct,
13 Mrv,
14 Bbr,
15 Sto,
16 Reg,
17 Ens,
18 Mac,
19 Roc,
20 Arn,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct StrategyKindSpec {
25 pub kind: StrategyKind,
26 pub label: &'static str,
27 pub category: &'static str,
28 pub default_fast: usize,
29 pub default_slow: usize,
30 pub default_cooldown: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct StrategyTypeOption {
35 pub display_label: String,
36 pub strategy_label: Option<String>,
37}
38
39const STRATEGY_KIND_SPECS: [StrategyKindSpec; 16] = [
40 StrategyKindSpec {
41 kind: StrategyKind::Ma,
42 label: "MA",
43 category: "Trend",
44 default_fast: 5,
45 default_slow: 20,
46 default_cooldown: 1,
47 },
48 StrategyKindSpec {
49 kind: StrategyKind::Ema,
50 label: "EMA",
51 category: "Trend",
52 default_fast: 9,
53 default_slow: 21,
54 default_cooldown: 1,
55 },
56 StrategyKindSpec {
57 kind: StrategyKind::Atr,
58 label: "ATR",
59 category: "Volatility",
60 default_fast: 14,
61 default_slow: 180,
62 default_cooldown: 1,
63 },
64 StrategyKindSpec {
65 kind: StrategyKind::Vlc,
66 label: "VLC",
67 category: "Volatility",
68 default_fast: 20,
69 default_slow: 120,
70 default_cooldown: 1,
71 },
72 StrategyKindSpec {
73 kind: StrategyKind::Chb,
74 label: "CHB",
75 category: "Breakout",
76 default_fast: 20,
77 default_slow: 10,
78 default_cooldown: 1,
79 },
80 StrategyKindSpec {
81 kind: StrategyKind::Orb,
82 label: "ORB",
83 category: "Breakout",
84 default_fast: 12,
85 default_slow: 8,
86 default_cooldown: 1,
87 },
88 StrategyKindSpec {
89 kind: StrategyKind::Rsa,
90 label: "RSA",
91 category: "MeanReversion",
92 default_fast: 14,
93 default_slow: 70,
94 default_cooldown: 1,
95 },
96 StrategyKindSpec {
97 kind: StrategyKind::Dct,
98 label: "DCT",
99 category: "Trend",
100 default_fast: 20,
101 default_slow: 10,
102 default_cooldown: 1,
103 },
104 StrategyKindSpec {
105 kind: StrategyKind::Mrv,
106 label: "MRV",
107 category: "MeanReversion",
108 default_fast: 20,
109 default_slow: 200,
110 default_cooldown: 1,
111 },
112 StrategyKindSpec {
113 kind: StrategyKind::Bbr,
114 label: "BBR",
115 category: "MeanReversion",
116 default_fast: 20,
117 default_slow: 200,
118 default_cooldown: 1,
119 },
120 StrategyKindSpec {
121 kind: StrategyKind::Sto,
122 label: "STO",
123 category: "MeanReversion",
124 default_fast: 14,
125 default_slow: 80,
126 default_cooldown: 1,
127 },
128 StrategyKindSpec {
129 kind: StrategyKind::Reg,
130 label: "REG",
131 category: "Hybrid",
132 default_fast: 10,
133 default_slow: 30,
134 default_cooldown: 1,
135 },
136 StrategyKindSpec {
137 kind: StrategyKind::Ens,
138 label: "ENS",
139 category: "Hybrid",
140 default_fast: 10,
141 default_slow: 30,
142 default_cooldown: 1,
143 },
144 StrategyKindSpec {
145 kind: StrategyKind::Mac,
146 label: "MAC",
147 category: "Trend",
148 default_fast: 12,
149 default_slow: 26,
150 default_cooldown: 1,
151 },
152 StrategyKindSpec {
153 kind: StrategyKind::Roc,
154 label: "ROC",
155 category: "Trend",
156 default_fast: 10,
157 default_slow: 20,
158 default_cooldown: 1,
159 },
160 StrategyKindSpec {
161 kind: StrategyKind::Arn,
162 label: "ARN",
163 category: "Trend",
164 default_fast: 14,
165 default_slow: 70,
166 default_cooldown: 1,
167 },
168];
169const STRATEGY_CATEGORY_ORDER: [&str; 5] =
170 ["Trend", "MeanReversion", "Volatility", "Breakout", "Hybrid"];
171
172impl StrategyKind {
173 pub fn specs() -> &'static [StrategyKindSpec] {
174 &STRATEGY_KIND_SPECS
175 }
176
177 pub fn as_label(self) -> &'static str {
178 Self::specs()
179 .iter()
180 .find(|spec| spec.kind == self)
181 .map(|spec| spec.label)
182 .unwrap_or("MA")
183 }
184
185 pub fn from_label(label: &str) -> Option<Self> {
186 Self::specs()
187 .iter()
188 .find(|spec| spec.label.eq_ignore_ascii_case(label))
189 .map(|spec| spec.kind)
190 }
191
192 pub fn defaults(self) -> (usize, usize, u64) {
193 let spec = Self::specs()
194 .iter()
195 .find(|spec| spec.kind == self)
196 .copied()
197 .unwrap_or(STRATEGY_KIND_SPECS[0]);
198 (spec.default_fast, spec.default_slow, spec.default_cooldown)
199 }
200}
201
202pub fn strategy_kind_labels() -> Vec<String> {
203 StrategyKind::specs()
204 .iter()
205 .map(|spec| spec.label.to_string())
206 .collect()
207}
208
209pub fn strategy_kind_categories() -> Vec<String> {
210 STRATEGY_CATEGORY_ORDER
211 .iter()
212 .map(|item| item.to_string())
213 .collect()
214}
215
216pub fn strategy_kind_labels_by_category(category: &str) -> Vec<String> {
217 StrategyKind::specs()
218 .iter()
219 .filter(|spec| spec.category.eq_ignore_ascii_case(category))
220 .map(|spec| spec.label.to_string())
221 .collect()
222}
223
224pub fn strategy_kind_category_for_label(label: &str) -> Option<String> {
225 StrategyKind::specs()
226 .iter()
227 .find(|spec| spec.label.eq_ignore_ascii_case(label))
228 .map(|spec| spec.category.to_string())
229}
230
231pub fn strategy_type_options_by_category(category: &str) -> Vec<StrategyTypeOption> {
232 let mut options: Vec<StrategyTypeOption> = StrategyKind::specs()
233 .iter()
234 .filter(|spec| spec.category.eq_ignore_ascii_case(category))
235 .map(|spec| StrategyTypeOption {
236 display_label: spec.label.to_string(),
237 strategy_label: Some(spec.label.to_string()),
238 })
239 .collect();
240 let coming_soon: &[&str] = &[];
241 options.extend(coming_soon.iter().map(|name| StrategyTypeOption {
242 display_label: format!("{} (Coming soon)", name),
243 strategy_label: None,
244 }));
245 options
246}
247
248#[derive(Debug, Clone)]
249pub struct StrategyLifecycleRow {
250 pub created_at_ms: i64,
251 pub total_running_ms: u64,
252}
253
254fn now_ms() -> i64 {
255 chrono::Utc::now().timestamp_millis()
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct StrategyProfile {
260 pub label: String,
261 pub source_tag: String,
262 #[serde(default)]
263 pub strategy_type: String,
264 #[serde(default)]
265 pub symbol: String,
266 #[serde(default = "now_ms")]
267 pub created_at_ms: i64,
268 #[serde(default)]
269 pub cumulative_running_ms: u64,
270 #[serde(default)]
271 pub last_started_at_ms: Option<i64>,
272 pub fast_period: usize,
273 pub slow_period: usize,
274 pub min_ticks_between_signals: u64,
275}
276
277impl StrategyProfile {
278 pub fn strategy_type_id(&self) -> String {
279 let ty = self.strategy_type.trim().to_ascii_lowercase();
280 if ty.is_empty() {
281 self.strategy_kind().as_label().to_ascii_lowercase()
282 } else {
283 ty
284 }
285 }
286
287 pub fn strategy_kind(&self) -> StrategyKind {
288 match self.strategy_type.trim().to_ascii_lowercase().as_str() {
289 "arn" => return StrategyKind::Arn,
290 "roc" => return StrategyKind::Roc,
291 "mac" => return StrategyKind::Mac,
292 "ens" => return StrategyKind::Ens,
293 "reg" => return StrategyKind::Reg,
294 "orb" => return StrategyKind::Orb,
295 "vlc" => return StrategyKind::Vlc,
296 "sto" => return StrategyKind::Sto,
297 "bbr" => return StrategyKind::Bbr,
298 "mrv" => return StrategyKind::Mrv,
299 "dct" => return StrategyKind::Dct,
300 "rsa" => return StrategyKind::Rsa,
301 "chb" => return StrategyKind::Chb,
302 "atr" => return StrategyKind::Atr,
303 "ema" => return StrategyKind::Ema,
304 "ma" => return StrategyKind::Ma,
305 _ => {}
306 }
307 if self.source_tag.eq_ignore_ascii_case("arn")
308 || self.label.to_ascii_uppercase().starts_with("ARN(")
309 {
310 StrategyKind::Arn
311 } else if self.source_tag.eq_ignore_ascii_case("roc")
312 || self.label.to_ascii_uppercase().starts_with("ROC(")
313 {
314 StrategyKind::Roc
315 } else if self.source_tag.eq_ignore_ascii_case("mac")
316 || self.label.to_ascii_uppercase().starts_with("MAC(")
317 {
318 StrategyKind::Mac
319 } else if self.source_tag.eq_ignore_ascii_case("ens")
320 || self.label.to_ascii_uppercase().starts_with("ENS(")
321 {
322 StrategyKind::Ens
323 } else if self.source_tag.eq_ignore_ascii_case("reg")
324 || self.label.to_ascii_uppercase().starts_with("REG(")
325 {
326 StrategyKind::Reg
327 } else if self.source_tag.eq_ignore_ascii_case("rsa")
328 || self.label.to_ascii_uppercase().starts_with("RSA(")
329 {
330 StrategyKind::Rsa
331 } else if self.source_tag.eq_ignore_ascii_case("vlc")
332 || self.label.to_ascii_uppercase().starts_with("VLC(")
333 {
334 StrategyKind::Vlc
335 } else if self.source_tag.eq_ignore_ascii_case("orb")
336 || self.label.to_ascii_uppercase().starts_with("ORB(")
337 {
338 StrategyKind::Orb
339 } else if self.source_tag.eq_ignore_ascii_case("bbr")
340 || self.label.to_ascii_uppercase().starts_with("BBR(")
341 {
342 StrategyKind::Bbr
343 } else if self.source_tag.eq_ignore_ascii_case("sto")
344 || self.label.to_ascii_uppercase().starts_with("STO(")
345 {
346 StrategyKind::Sto
347 } else if self.source_tag.eq_ignore_ascii_case("dct")
348 || self.label.to_ascii_uppercase().starts_with("DCT(")
349 {
350 StrategyKind::Dct
351 } else if self.source_tag.eq_ignore_ascii_case("mrv")
352 || self.label.to_ascii_uppercase().starts_with("MRV(")
353 {
354 StrategyKind::Mrv
355 } else if self.source_tag.eq_ignore_ascii_case("atr")
356 || self.label.to_ascii_uppercase().starts_with("ATR(")
357 || self.label.to_ascii_uppercase().starts_with("ATRX(")
358 {
359 StrategyKind::Atr
360 } else if self.source_tag.eq_ignore_ascii_case("chb")
361 || self.label.to_ascii_uppercase().starts_with("CHB(")
362 {
363 StrategyKind::Chb
364 } else if self.source_tag.eq_ignore_ascii_case("ema")
365 || self.label.to_ascii_uppercase().starts_with("EMA(")
366 {
367 StrategyKind::Ema
368 } else {
369 StrategyKind::Ma
370 }
371 }
372
373 pub fn periods_tuple(&self) -> (usize, usize, u64) {
374 (
375 self.fast_period,
376 self.slow_period,
377 self.min_ticks_between_signals,
378 )
379 }
380}
381
382#[derive(Debug, Clone)]
383pub struct StrategyCatalog {
384 profiles: Vec<StrategyProfile>,
385 next_custom_id: u32,
386}
387
388impl StrategyCatalog {
389 fn builtin_profiles(
390 default_symbol: &str,
391 config_fast: usize,
392 config_slow: usize,
393 min_ticks_between_signals: u64,
394 ) -> Vec<StrategyProfile> {
395 let symbol = default_symbol.trim().to_ascii_uppercase();
396 vec![
397 StrategyProfile {
398 label: "MA(Config)".to_string(),
399 source_tag: "cfg".to_string(),
400 strategy_type: "ma".to_string(),
401 symbol: symbol.clone(),
402 created_at_ms: now_ms(),
403 cumulative_running_ms: 0,
404 last_started_at_ms: None,
405 fast_period: config_fast,
406 slow_period: config_slow,
407 min_ticks_between_signals,
408 },
409 StrategyProfile {
410 label: "MA(Fast 5/20)".to_string(),
411 source_tag: "fst".to_string(),
412 strategy_type: "ma".to_string(),
413 symbol: symbol.clone(),
414 created_at_ms: now_ms(),
415 cumulative_running_ms: 0,
416 last_started_at_ms: None,
417 fast_period: 5,
418 slow_period: 20,
419 min_ticks_between_signals,
420 },
421 StrategyProfile {
422 label: "MA(Slow 20/60)".to_string(),
423 source_tag: "slw".to_string(),
424 strategy_type: "ma".to_string(),
425 symbol: symbol.clone(),
426 created_at_ms: now_ms(),
427 cumulative_running_ms: 0,
428 last_started_at_ms: None,
429 fast_period: 20,
430 slow_period: 60,
431 min_ticks_between_signals,
432 },
433 StrategyProfile {
434 label: "RSA(RSI 14 30/70)".to_string(),
435 source_tag: "rsa".to_string(),
436 strategy_type: "rsa".to_string(),
437 symbol,
438 created_at_ms: now_ms(),
439 cumulative_running_ms: 0,
440 last_started_at_ms: None,
441 fast_period: 14,
442 slow_period: 70,
443 min_ticks_between_signals,
444 },
445 StrategyProfile {
446 label: "DCT(Donchian 20/10)".to_string(),
447 source_tag: "dct".to_string(),
448 strategy_type: "dct".to_string(),
449 symbol: default_symbol.trim().to_ascii_uppercase(),
450 created_at_ms: now_ms(),
451 cumulative_running_ms: 0,
452 last_started_at_ms: None,
453 fast_period: 20,
454 slow_period: 10,
455 min_ticks_between_signals,
456 },
457 StrategyProfile {
458 label: "MRV(SMA 20 -2.00%)".to_string(),
459 source_tag: "mrv".to_string(),
460 strategy_type: "mrv".to_string(),
461 symbol: default_symbol.trim().to_ascii_uppercase(),
462 created_at_ms: now_ms(),
463 cumulative_running_ms: 0,
464 last_started_at_ms: None,
465 fast_period: 20,
466 slow_period: 200,
467 min_ticks_between_signals,
468 },
469 StrategyProfile {
470 label: "BBR(BB 20 2.00x)".to_string(),
471 source_tag: "bbr".to_string(),
472 strategy_type: "bbr".to_string(),
473 symbol: default_symbol.trim().to_ascii_uppercase(),
474 created_at_ms: now_ms(),
475 cumulative_running_ms: 0,
476 last_started_at_ms: None,
477 fast_period: 20,
478 slow_period: 200,
479 min_ticks_between_signals,
480 },
481 StrategyProfile {
482 label: "STO(Stoch 14 20/80)".to_string(),
483 source_tag: "sto".to_string(),
484 strategy_type: "sto".to_string(),
485 symbol: default_symbol.trim().to_ascii_uppercase(),
486 created_at_ms: now_ms(),
487 cumulative_running_ms: 0,
488 last_started_at_ms: None,
489 fast_period: 14,
490 slow_period: 80,
491 min_ticks_between_signals,
492 },
493 StrategyProfile {
494 label: "VLC(Compression 20 1.20%)".to_string(),
495 source_tag: "vlc".to_string(),
496 strategy_type: "vlc".to_string(),
497 symbol: default_symbol.trim().to_ascii_uppercase(),
498 created_at_ms: now_ms(),
499 cumulative_running_ms: 0,
500 last_started_at_ms: None,
501 fast_period: 20,
502 slow_period: 120,
503 min_ticks_between_signals,
504 },
505 StrategyProfile {
506 label: "ORB(Opening 12/8)".to_string(),
507 source_tag: "orb".to_string(),
508 strategy_type: "orb".to_string(),
509 symbol: default_symbol.trim().to_ascii_uppercase(),
510 created_at_ms: now_ms(),
511 cumulative_running_ms: 0,
512 last_started_at_ms: None,
513 fast_period: 12,
514 slow_period: 8,
515 min_ticks_between_signals,
516 },
517 StrategyProfile {
518 label: "REG(Regime 10/30)".to_string(),
519 source_tag: "reg".to_string(),
520 strategy_type: "reg".to_string(),
521 symbol: default_symbol.trim().to_ascii_uppercase(),
522 created_at_ms: now_ms(),
523 cumulative_running_ms: 0,
524 last_started_at_ms: None,
525 fast_period: 10,
526 slow_period: 30,
527 min_ticks_between_signals,
528 },
529 StrategyProfile {
530 label: "ENS(Vote 10/30)".to_string(),
531 source_tag: "ens".to_string(),
532 strategy_type: "ens".to_string(),
533 symbol: default_symbol.trim().to_ascii_uppercase(),
534 created_at_ms: now_ms(),
535 cumulative_running_ms: 0,
536 last_started_at_ms: None,
537 fast_period: 10,
538 slow_period: 30,
539 min_ticks_between_signals,
540 },
541 StrategyProfile {
542 label: "MAC(MACD 12/26)".to_string(),
543 source_tag: "mac".to_string(),
544 strategy_type: "mac".to_string(),
545 symbol: default_symbol.trim().to_ascii_uppercase(),
546 created_at_ms: now_ms(),
547 cumulative_running_ms: 0,
548 last_started_at_ms: None,
549 fast_period: 12,
550 slow_period: 26,
551 min_ticks_between_signals,
552 },
553 StrategyProfile {
554 label: "ROC(ROC 10 0.20%)".to_string(),
555 source_tag: "roc".to_string(),
556 strategy_type: "roc".to_string(),
557 symbol: default_symbol.trim().to_ascii_uppercase(),
558 created_at_ms: now_ms(),
559 cumulative_running_ms: 0,
560 last_started_at_ms: None,
561 fast_period: 10,
562 slow_period: 20,
563 min_ticks_between_signals,
564 },
565 StrategyProfile {
566 label: "ARN(Aroon 14 70)".to_string(),
567 source_tag: "arn".to_string(),
568 strategy_type: "arn".to_string(),
569 symbol: default_symbol.trim().to_ascii_uppercase(),
570 created_at_ms: now_ms(),
571 cumulative_running_ms: 0,
572 last_started_at_ms: None,
573 fast_period: 14,
574 slow_period: 70,
575 min_ticks_between_signals,
576 },
577 ]
578 }
579
580 pub fn is_custom_source_tag(source_tag: &str) -> bool {
581 let Some(id) = source_tag.strip_prefix('c') else {
582 return false;
583 };
584 !id.is_empty() && id.chars().all(|ch| ch.is_ascii_digit())
585 }
586
587 pub fn new(
588 default_symbol: &str,
589 config_fast: usize,
590 config_slow: usize,
591 min_ticks_between_signals: u64,
592 ) -> Self {
593 Self {
594 profiles: Self::builtin_profiles(
595 default_symbol,
596 config_fast,
597 config_slow,
598 min_ticks_between_signals,
599 ),
600 next_custom_id: 1,
601 }
602 }
603
604 pub fn labels(&self) -> Vec<String> {
605 self.profiles.iter().map(|p| p.label.clone()).collect()
606 }
607
608 pub fn symbols(&self) -> Vec<String> {
609 self.profiles.iter().map(|p| p.symbol.clone()).collect()
610 }
611
612 pub fn lifecycle_rows(&self, now_ms: i64) -> Vec<StrategyLifecycleRow> {
613 self.profiles
614 .iter()
615 .map(|profile| {
616 let running_delta = profile
617 .last_started_at_ms
618 .map(|started| now_ms.saturating_sub(started).max(0) as u64)
619 .unwrap_or(0);
620 StrategyLifecycleRow {
621 created_at_ms: profile.created_at_ms,
622 total_running_ms: profile.cumulative_running_ms.saturating_add(running_delta),
623 }
624 })
625 .collect()
626 }
627
628 pub fn len(&self) -> usize {
629 self.profiles.len()
630 }
631
632 pub fn get(&self, index: usize) -> Option<&StrategyProfile> {
633 self.profiles.get(index)
634 }
635
636 pub fn get_by_source_tag(&self, source_tag: &str) -> Option<&StrategyProfile> {
637 self.profiles.iter().find(|p| p.source_tag == source_tag)
638 }
639
640 pub fn mark_running(&mut self, source_tag: &str, now_ms: i64) -> bool {
641 let Some(profile) = self
642 .profiles
643 .iter_mut()
644 .find(|p| p.source_tag == source_tag)
645 else {
646 return false;
647 };
648 if profile.last_started_at_ms.is_none() {
649 profile.last_started_at_ms = Some(now_ms);
650 }
651 true
652 }
653
654 pub fn mark_stopped(&mut self, source_tag: &str, now_ms: i64) -> bool {
655 let Some(profile) = self
656 .profiles
657 .iter_mut()
658 .find(|p| p.source_tag == source_tag)
659 else {
660 return false;
661 };
662 if let Some(started) = profile.last_started_at_ms.take() {
663 let delta = now_ms.saturating_sub(started).max(0) as u64;
664 profile.cumulative_running_ms = profile.cumulative_running_ms.saturating_add(delta);
665 }
666 true
667 }
668
669 pub fn stop_all_running(&mut self, now_ms: i64) {
670 for profile in &mut self.profiles {
671 if let Some(started) = profile.last_started_at_ms.take() {
672 let delta = now_ms.saturating_sub(started).max(0) as u64;
673 profile.cumulative_running_ms = profile.cumulative_running_ms.saturating_add(delta);
674 }
675 }
676 }
677
678 pub fn profiles(&self) -> &[StrategyProfile] {
679 &self.profiles
680 }
681
682 pub fn index_of_label(&self, label: &str) -> Option<usize> {
683 self.profiles.iter().position(|p| p.label == label)
684 }
685
686 pub fn from_profiles(
687 profiles: Vec<StrategyProfile>,
688 default_symbol: &str,
689 config_fast: usize,
690 config_slow: usize,
691 min_ticks_between_signals: u64,
692 ) -> Self {
693 if profiles.is_empty() {
694 return Self::new(
695 default_symbol,
696 config_fast,
697 config_slow,
698 min_ticks_between_signals,
699 );
700 }
701 let mut restored = profiles;
702 let mut merged = Vec::new();
703 for builtin in Self::builtin_profiles(
704 default_symbol,
705 config_fast,
706 config_slow,
707 min_ticks_between_signals,
708 ) {
709 if let Some(idx) = restored
710 .iter()
711 .position(|p| p.source_tag.eq_ignore_ascii_case(&builtin.source_tag))
712 {
713 merged.push(restored.remove(idx));
714 } else {
715 merged.push(builtin);
716 }
717 }
718 merged.extend(restored);
719
720 for profile in &mut merged {
721 if profile.source_tag.trim().is_empty() {
722 profile.source_tag = "cfg".to_string();
723 } else {
724 profile.source_tag = profile.source_tag.trim().to_ascii_lowercase();
725 }
726 if profile.strategy_type.trim().is_empty() {
727 profile.strategy_type = profile.strategy_kind().as_label().to_ascii_lowercase();
728 } else {
729 profile.strategy_type = profile.strategy_type.trim().to_ascii_lowercase();
730 }
731 if profile.symbol.trim().is_empty() {
732 profile.symbol = default_symbol.trim().to_ascii_uppercase();
733 }
734 if profile.created_at_ms <= 0 {
735 profile.created_at_ms = now_ms();
736 }
737 }
738 let next_custom_id = merged
739 .iter()
740 .filter_map(|profile| {
741 let tag = profile.source_tag.strip_prefix('c')?;
742 tag.parse::<u32>().ok()
743 })
744 .max()
745 .map(|id| id + 1)
746 .unwrap_or(1);
747 Self {
748 profiles: merged,
749 next_custom_id,
750 }
751 }
752
753 pub fn add_custom_from_index(&mut self, base_index: usize) -> StrategyProfile {
754 let base = self
755 .profiles
756 .get(base_index)
757 .cloned()
758 .unwrap_or_else(|| self.profiles[0].clone());
759 self.new_custom_profile(
760 base.strategy_kind(),
761 &base.symbol,
762 base.fast_period,
763 base.slow_period,
764 base.min_ticks_between_signals,
765 )
766 }
767
768 pub fn fork_profile(
769 &mut self,
770 index: usize,
771 kind: StrategyKind,
772 symbol: &str,
773 fast_period: usize,
774 slow_period: usize,
775 min_ticks_between_signals: u64,
776 ) -> Option<StrategyProfile> {
777 self.profiles.get(index)?;
778 Some(self.new_custom_profile(
779 kind,
780 symbol,
781 fast_period,
782 slow_period,
783 min_ticks_between_signals,
784 ))
785 }
786
787 pub fn remove_custom_profile(&mut self, index: usize) -> Option<StrategyProfile> {
788 let profile = self.profiles.get(index)?;
789 if !Self::is_custom_source_tag(&profile.source_tag) {
790 return None;
791 }
792 Some(self.profiles.remove(index))
793 }
794
795 fn new_custom_profile(
796 &mut self,
797 kind: StrategyKind,
798 symbol: &str,
799 fast_period: usize,
800 slow_period: usize,
801 min_ticks_between_signals: u64,
802 ) -> StrategyProfile {
803 let tag = format!("c{:02}", self.next_custom_id);
804 self.next_custom_id += 1;
805 let fast = fast_period.max(2);
806 let (label, slow) = match kind {
807 StrategyKind::Ma => {
808 let slow = slow_period.max(fast + 1);
809 (format!("MA(Custom {}/{}) [{}]", fast, slow, tag), slow)
810 }
811 StrategyKind::Ema => {
812 let slow = slow_period.max(fast + 1);
813 (format!("EMA(Custom {}/{}) [{}]", fast, slow, tag), slow)
814 }
815 StrategyKind::Atr => {
816 let threshold_x100 = slow_period.clamp(110, 500);
817 (
818 format!(
819 "ATRX(Custom {} {:.2}x) [{}]",
820 fast,
821 threshold_x100 as f64 / 100.0,
822 tag
823 ),
824 threshold_x100,
825 )
826 }
827 StrategyKind::Vlc => {
828 let threshold_bps = slow_period.clamp(10, 5000);
829 (
830 format!(
831 "VLC(Custom {} {:.2}%) [{}]",
832 fast,
833 threshold_bps as f64 / 100.0,
834 tag
835 ),
836 threshold_bps,
837 )
838 }
839 StrategyKind::Chb => {
840 let exit_window = slow_period.max(2);
841 (
842 format!("CHB(Custom {}/{}) [{}]", fast, exit_window, tag),
843 exit_window,
844 )
845 }
846 StrategyKind::Orb => {
847 let exit_window = slow_period.max(2);
848 (
849 format!("ORB(Custom {}/{}) [{}]", fast, exit_window, tag),
850 exit_window,
851 )
852 }
853 StrategyKind::Rsa => {
854 let upper = slow_period.clamp(51, 95);
855 let lower = 100 - upper;
856 (
857 format!("RSA(Custom {} {}/{}) [{}]", fast, lower, upper, tag),
858 upper,
859 )
860 }
861 StrategyKind::Dct => {
862 let exit_window = slow_period.max(2);
863 (
864 format!("DCT(Custom {}/{}) [{}]", fast, exit_window, tag),
865 exit_window,
866 )
867 }
868 StrategyKind::Mrv => {
869 let threshold_bps = slow_period.clamp(10, 3000);
870 (
871 format!(
872 "MRV(Custom {} -{:.2}%) [{}]",
873 fast,
874 threshold_bps as f64 / 100.0,
875 tag
876 ),
877 threshold_bps,
878 )
879 }
880 StrategyKind::Bbr => {
881 let band_mult_x100 = slow_period.clamp(50, 400);
882 (
883 format!(
884 "BBR(Custom {} {:.2}x) [{}]",
885 fast,
886 band_mult_x100 as f64 / 100.0,
887 tag
888 ),
889 band_mult_x100,
890 )
891 }
892 StrategyKind::Sto => {
893 let upper = slow_period.clamp(51, 95);
894 let lower = 100 - upper;
895 (
896 format!("STO(Custom {} {}/{}) [{}]", fast, lower, upper, tag),
897 upper,
898 )
899 }
900 StrategyKind::Reg => {
901 let slow = slow_period.max(fast + 1);
902 (format!("REG(Custom {}/{}) [{}]", fast, slow, tag), slow)
903 }
904 StrategyKind::Ens => {
905 let slow = slow_period.max(fast + 1);
906 (format!("ENS(Custom {}/{}) [{}]", fast, slow, tag), slow)
907 }
908 StrategyKind::Mac => {
909 let slow = slow_period.max(fast + 1);
910 (format!("MAC(Custom {}/{}) [{}]", fast, slow, tag), slow)
911 }
912 StrategyKind::Roc => {
913 let threshold_bps = slow_period.clamp(5, 1_000);
914 (
915 format!(
916 "ROC(Custom {} {:.2}%) [{}]",
917 fast,
918 threshold_bps as f64 / 100.0,
919 tag
920 ),
921 threshold_bps,
922 )
923 }
924 StrategyKind::Arn => {
925 let threshold = slow_period.clamp(50, 90);
926 (
927 format!("ARN(Custom {} {}) [{}]", fast, threshold, tag),
928 threshold,
929 )
930 }
931 };
932 let profile = StrategyProfile {
933 label,
934 source_tag: tag,
935 strategy_type: kind.as_label().to_ascii_lowercase(),
936 symbol: symbol.trim().to_ascii_uppercase(),
937 created_at_ms: now_ms(),
938 cumulative_running_ms: 0,
939 last_started_at_ms: None,
940 fast_period: fast,
941 slow_period: slow,
942 min_ticks_between_signals: min_ticks_between_signals.max(1),
943 };
944 self.profiles.push(profile.clone());
945 profile
946 }
947}