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",
171 "MeanReversion",
172 "Volatility",
173 "Breakout",
174 "Hybrid",
175];
176
177impl StrategyKind {
178 pub fn specs() -> &'static [StrategyKindSpec] {
179 &STRATEGY_KIND_SPECS
180 }
181
182 pub fn as_label(self) -> &'static str {
183 Self::specs()
184 .iter()
185 .find(|spec| spec.kind == self)
186 .map(|spec| spec.label)
187 .unwrap_or("MA")
188 }
189
190 pub fn from_label(label: &str) -> Option<Self> {
191 Self::specs()
192 .iter()
193 .find(|spec| spec.label.eq_ignore_ascii_case(label))
194 .map(|spec| spec.kind)
195 }
196
197 pub fn defaults(self) -> (usize, usize, u64) {
198 let spec = Self::specs()
199 .iter()
200 .find(|spec| spec.kind == self)
201 .copied()
202 .unwrap_or(STRATEGY_KIND_SPECS[0]);
203 (spec.default_fast, spec.default_slow, spec.default_cooldown)
204 }
205}
206
207pub fn strategy_kind_labels() -> Vec<String> {
208 StrategyKind::specs()
209 .iter()
210 .map(|spec| spec.label.to_string())
211 .collect()
212}
213
214pub fn strategy_kind_categories() -> Vec<String> {
215 STRATEGY_CATEGORY_ORDER
216 .iter()
217 .map(|item| item.to_string())
218 .collect()
219}
220
221pub fn strategy_kind_labels_by_category(category: &str) -> Vec<String> {
222 StrategyKind::specs()
223 .iter()
224 .filter(|spec| spec.category.eq_ignore_ascii_case(category))
225 .map(|spec| spec.label.to_string())
226 .collect()
227}
228
229pub fn strategy_kind_category_for_label(label: &str) -> Option<String> {
230 StrategyKind::specs()
231 .iter()
232 .find(|spec| spec.label.eq_ignore_ascii_case(label))
233 .map(|spec| spec.category.to_string())
234}
235
236pub fn strategy_type_options_by_category(category: &str) -> Vec<StrategyTypeOption> {
237 let mut options: Vec<StrategyTypeOption> = StrategyKind::specs()
238 .iter()
239 .filter(|spec| spec.category.eq_ignore_ascii_case(category))
240 .map(|spec| StrategyTypeOption {
241 display_label: spec.label.to_string(),
242 strategy_label: Some(spec.label.to_string()),
243 })
244 .collect();
245 let coming_soon: &[&str] = &[];
246 options.extend(coming_soon.iter().map(|name| StrategyTypeOption {
247 display_label: format!("{} (Coming soon)", name),
248 strategy_label: None,
249 }));
250 options
251}
252
253#[derive(Debug, Clone)]
254pub struct StrategyLifecycleRow {
255 pub created_at_ms: i64,
256 pub total_running_ms: u64,
257}
258
259fn now_ms() -> i64 {
260 chrono::Utc::now().timestamp_millis()
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct StrategyProfile {
265 pub label: String,
266 pub source_tag: String,
267 #[serde(default)]
268 pub strategy_type: String,
269 #[serde(default)]
270 pub symbol: String,
271 #[serde(default = "now_ms")]
272 pub created_at_ms: i64,
273 #[serde(default)]
274 pub cumulative_running_ms: u64,
275 #[serde(default)]
276 pub last_started_at_ms: Option<i64>,
277 pub fast_period: usize,
278 pub slow_period: usize,
279 pub min_ticks_between_signals: u64,
280}
281
282impl StrategyProfile {
283 pub fn strategy_type_id(&self) -> String {
284 let ty = self.strategy_type.trim().to_ascii_lowercase();
285 if ty.is_empty() {
286 self.strategy_kind().as_label().to_ascii_lowercase()
287 } else {
288 ty
289 }
290 }
291
292 pub fn strategy_kind(&self) -> StrategyKind {
293 match self.strategy_type.trim().to_ascii_lowercase().as_str() {
294 "arn" => return StrategyKind::Arn,
295 "roc" => return StrategyKind::Roc,
296 "mac" => return StrategyKind::Mac,
297 "ens" => return StrategyKind::Ens,
298 "reg" => return StrategyKind::Reg,
299 "orb" => return StrategyKind::Orb,
300 "vlc" => return StrategyKind::Vlc,
301 "sto" => return StrategyKind::Sto,
302 "bbr" => return StrategyKind::Bbr,
303 "mrv" => return StrategyKind::Mrv,
304 "dct" => return StrategyKind::Dct,
305 "rsa" => return StrategyKind::Rsa,
306 "chb" => return StrategyKind::Chb,
307 "atr" => return StrategyKind::Atr,
308 "ema" => return StrategyKind::Ema,
309 "ma" => return StrategyKind::Ma,
310 _ => {}
311 }
312 if self.source_tag.eq_ignore_ascii_case("arn")
313 || self.label.to_ascii_uppercase().starts_with("ARN(")
314 {
315 StrategyKind::Arn
316 } else if self.source_tag.eq_ignore_ascii_case("roc")
317 || self.label.to_ascii_uppercase().starts_with("ROC(")
318 {
319 StrategyKind::Roc
320 } else if self.source_tag.eq_ignore_ascii_case("mac")
321 || self.label.to_ascii_uppercase().starts_with("MAC(")
322 {
323 StrategyKind::Mac
324 } else if self.source_tag.eq_ignore_ascii_case("ens")
325 || self.label.to_ascii_uppercase().starts_with("ENS(")
326 {
327 StrategyKind::Ens
328 } else if self.source_tag.eq_ignore_ascii_case("reg")
329 || self.label.to_ascii_uppercase().starts_with("REG(")
330 {
331 StrategyKind::Reg
332 } else if self.source_tag.eq_ignore_ascii_case("rsa")
333 || self.label.to_ascii_uppercase().starts_with("RSA(")
334 {
335 StrategyKind::Rsa
336 } else if self.source_tag.eq_ignore_ascii_case("vlc")
337 || self.label.to_ascii_uppercase().starts_with("VLC(")
338 {
339 StrategyKind::Vlc
340 } else if self.source_tag.eq_ignore_ascii_case("orb")
341 || self.label.to_ascii_uppercase().starts_with("ORB(")
342 {
343 StrategyKind::Orb
344 } else if self.source_tag.eq_ignore_ascii_case("bbr")
345 || self.label.to_ascii_uppercase().starts_with("BBR(")
346 {
347 StrategyKind::Bbr
348 } else if self.source_tag.eq_ignore_ascii_case("sto")
349 || self.label.to_ascii_uppercase().starts_with("STO(")
350 {
351 StrategyKind::Sto
352 } else if self.source_tag.eq_ignore_ascii_case("dct")
353 || self.label.to_ascii_uppercase().starts_with("DCT(")
354 {
355 StrategyKind::Dct
356 } else if self.source_tag.eq_ignore_ascii_case("mrv")
357 || self.label.to_ascii_uppercase().starts_with("MRV(")
358 {
359 StrategyKind::Mrv
360 } else if self.source_tag.eq_ignore_ascii_case("atr")
361 || self.label.to_ascii_uppercase().starts_with("ATR(")
362 || self.label.to_ascii_uppercase().starts_with("ATRX(")
363 {
364 StrategyKind::Atr
365 } else if self.source_tag.eq_ignore_ascii_case("chb")
366 || self.label.to_ascii_uppercase().starts_with("CHB(")
367 {
368 StrategyKind::Chb
369 } else if self.source_tag.eq_ignore_ascii_case("ema")
370 || self.label.to_ascii_uppercase().starts_with("EMA(")
371 {
372 StrategyKind::Ema
373 } else {
374 StrategyKind::Ma
375 }
376 }
377
378 pub fn periods_tuple(&self) -> (usize, usize, u64) {
379 (
380 self.fast_period,
381 self.slow_period,
382 self.min_ticks_between_signals,
383 )
384 }
385}
386
387#[derive(Debug, Clone)]
388pub struct StrategyCatalog {
389 profiles: Vec<StrategyProfile>,
390 next_custom_id: u32,
391}
392
393impl StrategyCatalog {
394 fn builtin_profiles(
395 default_symbol: &str,
396 config_fast: usize,
397 config_slow: usize,
398 min_ticks_between_signals: u64,
399 ) -> Vec<StrategyProfile> {
400 let symbol = default_symbol.trim().to_ascii_uppercase();
401 vec![
402 StrategyProfile {
403 label: "MA(Config)".to_string(),
404 source_tag: "cfg".to_string(),
405 strategy_type: "ma".to_string(),
406 symbol: symbol.clone(),
407 created_at_ms: now_ms(),
408 cumulative_running_ms: 0,
409 last_started_at_ms: None,
410 fast_period: config_fast,
411 slow_period: config_slow,
412 min_ticks_between_signals,
413 },
414 StrategyProfile {
415 label: "MA(Fast 5/20)".to_string(),
416 source_tag: "fst".to_string(),
417 strategy_type: "ma".to_string(),
418 symbol: symbol.clone(),
419 created_at_ms: now_ms(),
420 cumulative_running_ms: 0,
421 last_started_at_ms: None,
422 fast_period: 5,
423 slow_period: 20,
424 min_ticks_between_signals,
425 },
426 StrategyProfile {
427 label: "MA(Slow 20/60)".to_string(),
428 source_tag: "slw".to_string(),
429 strategy_type: "ma".to_string(),
430 symbol: symbol.clone(),
431 created_at_ms: now_ms(),
432 cumulative_running_ms: 0,
433 last_started_at_ms: None,
434 fast_period: 20,
435 slow_period: 60,
436 min_ticks_between_signals,
437 },
438 StrategyProfile {
439 label: "RSA(RSI 14 30/70)".to_string(),
440 source_tag: "rsa".to_string(),
441 strategy_type: "rsa".to_string(),
442 symbol,
443 created_at_ms: now_ms(),
444 cumulative_running_ms: 0,
445 last_started_at_ms: None,
446 fast_period: 14,
447 slow_period: 70,
448 min_ticks_between_signals,
449 },
450 StrategyProfile {
451 label: "DCT(Donchian 20/10)".to_string(),
452 source_tag: "dct".to_string(),
453 strategy_type: "dct".to_string(),
454 symbol: default_symbol.trim().to_ascii_uppercase(),
455 created_at_ms: now_ms(),
456 cumulative_running_ms: 0,
457 last_started_at_ms: None,
458 fast_period: 20,
459 slow_period: 10,
460 min_ticks_between_signals,
461 },
462 StrategyProfile {
463 label: "MRV(SMA 20 -2.00%)".to_string(),
464 source_tag: "mrv".to_string(),
465 strategy_type: "mrv".to_string(),
466 symbol: default_symbol.trim().to_ascii_uppercase(),
467 created_at_ms: now_ms(),
468 cumulative_running_ms: 0,
469 last_started_at_ms: None,
470 fast_period: 20,
471 slow_period: 200,
472 min_ticks_between_signals,
473 },
474 StrategyProfile {
475 label: "BBR(BB 20 2.00x)".to_string(),
476 source_tag: "bbr".to_string(),
477 strategy_type: "bbr".to_string(),
478 symbol: default_symbol.trim().to_ascii_uppercase(),
479 created_at_ms: now_ms(),
480 cumulative_running_ms: 0,
481 last_started_at_ms: None,
482 fast_period: 20,
483 slow_period: 200,
484 min_ticks_between_signals,
485 },
486 StrategyProfile {
487 label: "STO(Stoch 14 20/80)".to_string(),
488 source_tag: "sto".to_string(),
489 strategy_type: "sto".to_string(),
490 symbol: default_symbol.trim().to_ascii_uppercase(),
491 created_at_ms: now_ms(),
492 cumulative_running_ms: 0,
493 last_started_at_ms: None,
494 fast_period: 14,
495 slow_period: 80,
496 min_ticks_between_signals,
497 },
498 StrategyProfile {
499 label: "VLC(Compression 20 1.20%)".to_string(),
500 source_tag: "vlc".to_string(),
501 strategy_type: "vlc".to_string(),
502 symbol: default_symbol.trim().to_ascii_uppercase(),
503 created_at_ms: now_ms(),
504 cumulative_running_ms: 0,
505 last_started_at_ms: None,
506 fast_period: 20,
507 slow_period: 120,
508 min_ticks_between_signals,
509 },
510 StrategyProfile {
511 label: "ORB(Opening 12/8)".to_string(),
512 source_tag: "orb".to_string(),
513 strategy_type: "orb".to_string(),
514 symbol: default_symbol.trim().to_ascii_uppercase(),
515 created_at_ms: now_ms(),
516 cumulative_running_ms: 0,
517 last_started_at_ms: None,
518 fast_period: 12,
519 slow_period: 8,
520 min_ticks_between_signals,
521 },
522 StrategyProfile {
523 label: "REG(Regime 10/30)".to_string(),
524 source_tag: "reg".to_string(),
525 strategy_type: "reg".to_string(),
526 symbol: default_symbol.trim().to_ascii_uppercase(),
527 created_at_ms: now_ms(),
528 cumulative_running_ms: 0,
529 last_started_at_ms: None,
530 fast_period: 10,
531 slow_period: 30,
532 min_ticks_between_signals,
533 },
534 StrategyProfile {
535 label: "ENS(Vote 10/30)".to_string(),
536 source_tag: "ens".to_string(),
537 strategy_type: "ens".to_string(),
538 symbol: default_symbol.trim().to_ascii_uppercase(),
539 created_at_ms: now_ms(),
540 cumulative_running_ms: 0,
541 last_started_at_ms: None,
542 fast_period: 10,
543 slow_period: 30,
544 min_ticks_between_signals,
545 },
546 StrategyProfile {
547 label: "MAC(MACD 12/26)".to_string(),
548 source_tag: "mac".to_string(),
549 strategy_type: "mac".to_string(),
550 symbol: default_symbol.trim().to_ascii_uppercase(),
551 created_at_ms: now_ms(),
552 cumulative_running_ms: 0,
553 last_started_at_ms: None,
554 fast_period: 12,
555 slow_period: 26,
556 min_ticks_between_signals,
557 },
558 StrategyProfile {
559 label: "ROC(ROC 10 0.20%)".to_string(),
560 source_tag: "roc".to_string(),
561 strategy_type: "roc".to_string(),
562 symbol: default_symbol.trim().to_ascii_uppercase(),
563 created_at_ms: now_ms(),
564 cumulative_running_ms: 0,
565 last_started_at_ms: None,
566 fast_period: 10,
567 slow_period: 20,
568 min_ticks_between_signals,
569 },
570 StrategyProfile {
571 label: "ARN(Aroon 14 70)".to_string(),
572 source_tag: "arn".to_string(),
573 strategy_type: "arn".to_string(),
574 symbol: default_symbol.trim().to_ascii_uppercase(),
575 created_at_ms: now_ms(),
576 cumulative_running_ms: 0,
577 last_started_at_ms: None,
578 fast_period: 14,
579 slow_period: 70,
580 min_ticks_between_signals,
581 },
582 ]
583 }
584
585 pub fn is_custom_source_tag(source_tag: &str) -> bool {
586 let Some(id) = source_tag.strip_prefix('c') else {
587 return false;
588 };
589 !id.is_empty() && id.chars().all(|ch| ch.is_ascii_digit())
590 }
591
592 pub fn new(
593 default_symbol: &str,
594 config_fast: usize,
595 config_slow: usize,
596 min_ticks_between_signals: u64,
597 ) -> Self {
598 Self {
599 profiles: Self::builtin_profiles(
600 default_symbol,
601 config_fast,
602 config_slow,
603 min_ticks_between_signals,
604 ),
605 next_custom_id: 1,
606 }
607 }
608
609 pub fn labels(&self) -> Vec<String> {
610 self.profiles.iter().map(|p| p.label.clone()).collect()
611 }
612
613 pub fn symbols(&self) -> Vec<String> {
614 self.profiles.iter().map(|p| p.symbol.clone()).collect()
615 }
616
617 pub fn lifecycle_rows(&self, now_ms: i64) -> Vec<StrategyLifecycleRow> {
618 self.profiles
619 .iter()
620 .map(|profile| {
621 let running_delta = profile
622 .last_started_at_ms
623 .map(|started| now_ms.saturating_sub(started).max(0) as u64)
624 .unwrap_or(0);
625 StrategyLifecycleRow {
626 created_at_ms: profile.created_at_ms,
627 total_running_ms: profile.cumulative_running_ms.saturating_add(running_delta),
628 }
629 })
630 .collect()
631 }
632
633 pub fn len(&self) -> usize {
634 self.profiles.len()
635 }
636
637 pub fn get(&self, index: usize) -> Option<&StrategyProfile> {
638 self.profiles.get(index)
639 }
640
641 pub fn get_by_source_tag(&self, source_tag: &str) -> Option<&StrategyProfile> {
642 self.profiles.iter().find(|p| p.source_tag == source_tag)
643 }
644
645 pub fn mark_running(&mut self, source_tag: &str, now_ms: i64) -> bool {
646 let Some(profile) = self
647 .profiles
648 .iter_mut()
649 .find(|p| p.source_tag == source_tag)
650 else {
651 return false;
652 };
653 if profile.last_started_at_ms.is_none() {
654 profile.last_started_at_ms = Some(now_ms);
655 }
656 true
657 }
658
659 pub fn mark_stopped(&mut self, source_tag: &str, now_ms: i64) -> bool {
660 let Some(profile) = self
661 .profiles
662 .iter_mut()
663 .find(|p| p.source_tag == source_tag)
664 else {
665 return false;
666 };
667 if let Some(started) = profile.last_started_at_ms.take() {
668 let delta = now_ms.saturating_sub(started).max(0) as u64;
669 profile.cumulative_running_ms = profile.cumulative_running_ms.saturating_add(delta);
670 }
671 true
672 }
673
674 pub fn stop_all_running(&mut self, now_ms: i64) {
675 for profile in &mut self.profiles {
676 if let Some(started) = profile.last_started_at_ms.take() {
677 let delta = now_ms.saturating_sub(started).max(0) as u64;
678 profile.cumulative_running_ms = profile.cumulative_running_ms.saturating_add(delta);
679 }
680 }
681 }
682
683 pub fn profiles(&self) -> &[StrategyProfile] {
684 &self.profiles
685 }
686
687 pub fn index_of_label(&self, label: &str) -> Option<usize> {
688 self.profiles.iter().position(|p| p.label == label)
689 }
690
691 pub fn from_profiles(
692 profiles: Vec<StrategyProfile>,
693 default_symbol: &str,
694 config_fast: usize,
695 config_slow: usize,
696 min_ticks_between_signals: u64,
697 ) -> Self {
698 if profiles.is_empty() {
699 return Self::new(
700 default_symbol,
701 config_fast,
702 config_slow,
703 min_ticks_between_signals,
704 );
705 }
706 let mut restored = profiles;
707 let mut merged = Vec::new();
708 for builtin in Self::builtin_profiles(
709 default_symbol,
710 config_fast,
711 config_slow,
712 min_ticks_between_signals,
713 ) {
714 if let Some(idx) = restored
715 .iter()
716 .position(|p| p.source_tag.eq_ignore_ascii_case(&builtin.source_tag))
717 {
718 merged.push(restored.remove(idx));
719 } else {
720 merged.push(builtin);
721 }
722 }
723 merged.extend(restored);
724
725 for profile in &mut merged {
726 if profile.source_tag.trim().is_empty() {
727 profile.source_tag = "cfg".to_string();
728 } else {
729 profile.source_tag = profile.source_tag.trim().to_ascii_lowercase();
730 }
731 if profile.strategy_type.trim().is_empty() {
732 profile.strategy_type = profile.strategy_kind().as_label().to_ascii_lowercase();
733 } else {
734 profile.strategy_type = profile.strategy_type.trim().to_ascii_lowercase();
735 }
736 if profile.symbol.trim().is_empty() {
737 profile.symbol = default_symbol.trim().to_ascii_uppercase();
738 }
739 if profile.created_at_ms <= 0 {
740 profile.created_at_ms = now_ms();
741 }
742 }
743 let next_custom_id = merged
744 .iter()
745 .filter_map(|profile| {
746 let tag = profile.source_tag.strip_prefix('c')?;
747 tag.parse::<u32>().ok()
748 })
749 .max()
750 .map(|id| id + 1)
751 .unwrap_or(1);
752 Self {
753 profiles: merged,
754 next_custom_id,
755 }
756 }
757
758 pub fn add_custom_from_index(&mut self, base_index: usize) -> StrategyProfile {
759 let base = self
760 .profiles
761 .get(base_index)
762 .cloned()
763 .unwrap_or_else(|| self.profiles[0].clone());
764 self.new_custom_profile(
765 base.strategy_kind(),
766 &base.symbol,
767 base.fast_period,
768 base.slow_period,
769 base.min_ticks_between_signals,
770 )
771 }
772
773 pub fn fork_profile(
774 &mut self,
775 index: usize,
776 kind: StrategyKind,
777 symbol: &str,
778 fast_period: usize,
779 slow_period: usize,
780 min_ticks_between_signals: u64,
781 ) -> Option<StrategyProfile> {
782 self.profiles.get(index)?;
783 Some(self.new_custom_profile(
784 kind,
785 symbol,
786 fast_period,
787 slow_period,
788 min_ticks_between_signals,
789 ))
790 }
791
792 pub fn remove_custom_profile(&mut self, index: usize) -> Option<StrategyProfile> {
793 let profile = self.profiles.get(index)?;
794 if !Self::is_custom_source_tag(&profile.source_tag) {
795 return None;
796 }
797 Some(self.profiles.remove(index))
798 }
799
800 fn new_custom_profile(
801 &mut self,
802 kind: StrategyKind,
803 symbol: &str,
804 fast_period: usize,
805 slow_period: usize,
806 min_ticks_between_signals: u64,
807 ) -> StrategyProfile {
808 let tag = format!("c{:02}", self.next_custom_id);
809 self.next_custom_id += 1;
810 let fast = fast_period.max(2);
811 let (label, slow) = match kind {
812 StrategyKind::Ma => {
813 let slow = slow_period.max(fast + 1);
814 (format!("MA(Custom {}/{}) [{}]", fast, slow, tag), slow)
815 }
816 StrategyKind::Ema => {
817 let slow = slow_period.max(fast + 1);
818 (format!("EMA(Custom {}/{}) [{}]", fast, slow, tag), slow)
819 }
820 StrategyKind::Atr => {
821 let threshold_x100 = slow_period.clamp(110, 500);
822 (
823 format!("ATRX(Custom {} {:.2}x) [{}]", fast, threshold_x100 as f64 / 100.0, tag),
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 (format!("ARN(Custom {} {}) [{}]", fast, threshold, tag), threshold)
927 }
928 };
929 let profile = StrategyProfile {
930 label,
931 source_tag: tag,
932 strategy_type: kind.as_label().to_ascii_lowercase(),
933 symbol: symbol.trim().to_ascii_uppercase(),
934 created_at_ms: now_ms(),
935 cumulative_running_ms: 0,
936 last_started_at_ms: None,
937 fast_period: fast,
938 slow_period: slow,
939 min_ticks_between_signals: min_ticks_between_signals.max(1),
940 };
941 self.profiles.push(profile.clone());
942 profile
943 }
944}