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    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}