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}