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