Skip to main content

sandbox_quant/command/
backtest.rs

1use chrono::NaiveDate;
2
3use crate::app::bootstrap::BinanceMode;
4use crate::app::cli::normalize_instrument_symbol;
5use crate::strategy::model::StrategyTemplate;
6use crate::terminal::completion::ShellCompletion;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum BacktestCommand {
10    Run {
11        template: StrategyTemplate,
12        instrument: String,
13        from: NaiveDate,
14        to: NaiveDate,
15    },
16    List,
17    ReportLatest,
18    ReportShow {
19        run_id: i64,
20    },
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum BacktestShellInput {
25    Empty,
26    Help,
27    Exit,
28    Mode(BinanceMode),
29    Command(BacktestCommand),
30}
31
32pub fn backtest_help_text() -> &'static str {
33    "/run <template> <instrument> --from <YYYY-MM-DD> --to <YYYY-MM-DD>\n/list\n/report latest\n/report show <run_id>\n/mode <real|demo>\n/help\n/exit"
34}
35
36pub fn parse_backtest_shell_input(line: &str) -> Result<BacktestShellInput, String> {
37    let trimmed = line.trim();
38    if trimmed.is_empty() {
39        return Ok(BacktestShellInput::Empty);
40    }
41
42    let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
43    match without_prefix {
44        "help" => return Ok(BacktestShellInput::Help),
45        "exit" | "quit" => return Ok(BacktestShellInput::Exit),
46        _ => {}
47    }
48
49    let args: Vec<String> = without_prefix
50        .split_whitespace()
51        .map(str::to_string)
52        .collect();
53    if args.first().map(String::as_str) == Some("mode") {
54        let raw_mode = args.get(1).ok_or("usage: /mode <real|demo>")?;
55        let mode = match raw_mode.as_str() {
56            "real" => BinanceMode::Real,
57            "demo" => BinanceMode::Demo,
58            _ => return Err(format!("unsupported mode: {raw_mode}")),
59        };
60        return Ok(BacktestShellInput::Mode(mode));
61    }
62
63    parse_backtest_command(&args).map(BacktestShellInput::Command)
64}
65
66pub fn parse_backtest_command(args: &[String]) -> Result<BacktestCommand, String> {
67    match args.first().map(String::as_str) {
68        Some("run") => {
69            let template = match args.get(1).map(String::as_str) {
70                Some("liquidation-breakdown-short") => StrategyTemplate::LiquidationBreakdownShort,
71                Some(other) => return Err(format!("unsupported template: {other}")),
72                None => {
73                    return Err(
74                        "usage: run <template> <instrument> --from <YYYY-MM-DD> --to <YYYY-MM-DD>"
75                            .to_string(),
76                    )
77                }
78            };
79            let instrument = normalize_instrument_symbol(args.get(2).ok_or(
80                "usage: run <template> <instrument> --from <YYYY-MM-DD> --to <YYYY-MM-DD>",
81            )?);
82            let (from, to) = parse_dates(&args[3..])?;
83            Ok(BacktestCommand::Run {
84                template,
85                instrument,
86                from,
87                to,
88            })
89        }
90        Some("list") => {
91            if args.len() == 1 {
92                Ok(BacktestCommand::List)
93            } else {
94                Err("usage: list".to_string())
95            }
96        }
97        Some("report") => match args.get(1).map(String::as_str) {
98            Some("latest") if args.len() == 2 => Ok(BacktestCommand::ReportLatest),
99            Some("show") if args.len() == 3 => {
100                let run_id = args[2]
101                    .parse::<i64>()
102                    .map_err(|_| format!("invalid run id: {}", args[2]))?;
103                Ok(BacktestCommand::ReportShow { run_id })
104            }
105            _ => Err("usage: report latest | report show <run_id>".to_string()),
106        },
107        Some(other) => Err(format!("unsupported command: {other}")),
108        None => Err("missing backtest command".to_string()),
109    }
110}
111
112pub fn complete_backtest_input(line: &str) -> Vec<ShellCompletion> {
113    let trimmed = line.trim_start();
114    let without_prefix = trimmed.strip_prefix('/').unwrap_or(trimmed);
115    let trailing_space = without_prefix.ends_with(' ');
116    let parts: Vec<&str> = without_prefix.split_whitespace().collect();
117
118    if parts.is_empty() {
119        return vec![
120            completion("/run", "run a backtest over a date range"),
121            completion("/list", "list stored backtest runs"),
122            completion("/report", "show stored backtest reports"),
123            completion("/mode", "switch dataset mode"),
124            completion("/help", "show help"),
125            completion("/exit", "exit"),
126        ];
127    }
128    if parts.len() == 1 && !trailing_space {
129        return ["/run", "/list", "/report", "/mode", "/help", "/exit"]
130            .into_iter()
131            .filter(|item| item.trim_start_matches('/').starts_with(parts[0]))
132            .map(|item| completion(item, ""))
133            .collect();
134    }
135
136    match parts.first().copied() {
137        Some("mode") => ["real", "demo"]
138            .into_iter()
139            .filter(|item| item.starts_with(parts.last().copied().unwrap_or_default()))
140            .map(|item| completion(&format!("/mode {item}"), "switch backtest mode"))
141            .collect(),
142        Some("run") if parts.len() <= 2 => StrategyTemplate::all()
143            .into_iter()
144            .map(|template| {
145                completion(
146                    &format!("/run {}", template.slug()),
147                    "choose a backtest template",
148                )
149            })
150            .collect(),
151        Some("report") if parts.len() <= 2 => vec![
152            completion("/report latest", "show latest stored run"),
153            completion("/report show ", "show a stored run by id"),
154        ],
155        _ => Vec::new(),
156    }
157}
158
159fn parse_dates(args: &[String]) -> Result<(NaiveDate, NaiveDate), String> {
160    let mut from = None;
161    let mut to = None;
162    let mut index = 0usize;
163    while index < args.len() {
164        match args[index].as_str() {
165            "--from" => {
166                let value = args.get(index + 1).ok_or("missing value for --from")?;
167                from = Some(
168                    NaiveDate::parse_from_str(value, "%Y-%m-%d")
169                        .map_err(|_| format!("invalid date: {value}"))?,
170                );
171                index += 2;
172            }
173            "--to" => {
174                let value = args.get(index + 1).ok_or("missing value for --to")?;
175                to = Some(
176                    NaiveDate::parse_from_str(value, "%Y-%m-%d")
177                        .map_err(|_| format!("invalid date: {value}"))?,
178                );
179                index += 2;
180            }
181            other => return Err(format!("unsupported arg: {other}")),
182        }
183    }
184    let from = from.ok_or("missing --from")?;
185    let to = to.ok_or("missing --to")?;
186    if from > to {
187        return Err(format!(
188            "invalid date range: from ({from}) must be on or before to ({to})"
189        ));
190    }
191    Ok((from, to))
192}
193
194fn completion(value: &str, description: &str) -> ShellCompletion {
195    ShellCompletion {
196        value: value.to_string(),
197        description: description.to_string(),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn parse_backtest_command_rejects_reversed_date_range() {
207        let args = vec![
208            "run".to_string(),
209            "liquidation-breakdown-short".to_string(),
210            "btcusdt".to_string(),
211            "--from".to_string(),
212            "2026-03-14".to_string(),
213            "--to".to_string(),
214            "2026-03-13".to_string(),
215        ];
216
217        let error = parse_backtest_command(&args).expect_err("expected invalid date range");
218
219        assert_eq!(
220            error,
221            "invalid date range: from (2026-03-14) must be on or before to (2026-03-13)"
222        );
223    }
224
225    #[test]
226    fn parse_backtest_command_normalizes_instrument_and_accepts_valid_dates() {
227        let args = vec![
228            "run".to_string(),
229            "liquidation-breakdown-short".to_string(),
230            "btcusdt".to_string(),
231            "--from".to_string(),
232            "2026-03-13".to_string(),
233            "--to".to_string(),
234            "2026-03-14".to_string(),
235        ];
236
237        let command = parse_backtest_command(&args).expect("valid run command");
238
239        assert_eq!(
240            command,
241            BacktestCommand::Run {
242                template: StrategyTemplate::LiquidationBreakdownShort,
243                instrument: "BTCUSDT".to_string(),
244                from: NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
245                to: NaiveDate::from_ymd_opt(2026, 3, 14).expect("date"),
246            }
247        );
248    }
249}