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