Skip to main content

sandbox_quant/
strategy_catalog.rs

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}