1use clap::Parser;
17use nautilus_persistence::backend::migration::parse_storage_option;
18
19#[derive(Debug, Parser)]
21#[clap(version, about, author)]
22pub struct NautilusCli {
23 #[clap(subcommand)]
24 pub(crate) command: Commands,
25}
26
27#[derive(Parser, Debug)]
29pub enum Commands {
30 Database(DatabaseOpt),
31 Catalog(CatalogOpt),
32 #[cfg(feature = "defi")]
33 Blockchain(BlockchainOpt),
34}
35
36#[derive(Parser, Debug)]
38#[command(about = "Postgres database operations", long_about = None)]
39pub struct DatabaseOpt {
40 #[clap(subcommand)]
41 pub(crate) command: DatabaseCommand,
42}
43
44#[derive(Parser, Debug, Clone)]
46pub struct DatabaseConfig {
47 #[arg(long)]
49 pub(crate) host: Option<String>,
50 #[arg(long)]
52 pub(crate) port: Option<u16>,
53 #[arg(long)]
55 pub(crate) username: Option<String>,
56 #[arg(long)]
58 pub(crate) database: Option<String>,
59 #[arg(long)]
61 pub(crate) password: Option<String>,
62 #[arg(long)]
64 pub(crate) schema: Option<String>,
65}
66
67#[derive(Parser, Debug, Clone)]
69#[command(about = "Postgres database operations", long_about = None)]
70pub enum DatabaseCommand {
71 Init(DatabaseConfig),
73 Drop(DatabaseConfig),
75 AssignAccount(AssignAccountConfig),
77}
78
79#[derive(Parser, Debug, Clone)]
84pub struct AssignAccountConfig {
85 #[arg(long)]
87 pub(crate) account_id: String,
88 #[arg(long)]
90 pub(crate) trader_id: String,
91 #[clap(flatten)]
93 pub(crate) database: DatabaseConfig,
94}
95
96#[cfg(feature = "defi")]
97#[derive(Parser, Debug)]
99#[command(about = "Blockchain operations", long_about = None)]
100pub struct BlockchainOpt {
101 #[clap(subcommand)]
102 pub(crate) command: BlockchainCommand,
103}
104
105#[cfg(feature = "defi")]
106#[derive(Parser, Debug, Clone)]
108#[command(about = "Blockchain operations", long_about = None)]
109pub enum BlockchainCommand {
110 SyncBlocks {
112 #[arg(long)]
114 chain: String,
115 #[arg(long)]
117 from_block: Option<u64>,
118 #[arg(long)]
120 to_block: Option<u64>,
121 #[clap(flatten)]
123 database: DatabaseConfig,
124 },
125 SyncDex {
127 #[arg(long)]
129 chain: String,
130 #[arg(long)]
132 dex: String,
133 #[arg(long)]
135 rpc_url: Option<String>,
136 #[arg(long)]
138 reset: bool,
139 #[arg(long)]
141 multicall_calls_per_rpc_request: Option<u32>,
142 #[clap(flatten)]
144 database: DatabaseConfig,
145 },
146 AnalyzePool {
148 #[arg(long)]
150 chain: String,
151 #[arg(long)]
153 dex: String,
154 #[arg(long)]
156 address: String,
157 #[arg(long)]
159 from_block: Option<u64>,
160 #[arg(long)]
162 to_block: Option<u64>,
163 #[expect(
165 clippy::doc_markdown,
166 reason = "clap renders doc comments as plain help text"
167 )]
168 #[arg(long)]
169 rpc_url: Option<String>,
170 #[arg(long)]
172 reset: bool,
173 #[expect(
175 clippy::doc_markdown,
176 reason = "clap renders doc comments as plain help text"
177 )]
178 #[arg(long)]
179 require_existing_snapshot: bool,
180 #[arg(long, value_delimiter = ',')]
182 checkpoint_blocks: Vec<u64>,
183 #[arg(long)]
185 skip_validation: bool,
186 #[arg(long)]
188 snapshot_from_rpc: bool,
189 #[arg(long)]
191 multicall_calls_per_rpc_request: Option<u32>,
192 #[clap(flatten)]
194 database: DatabaseConfig,
195 },
196 AnalyzePools {
198 #[arg(long)]
200 chain: String,
201 #[arg(long)]
203 dex: String,
204 #[arg(long = "address")]
206 addresses: Vec<String>,
207 #[arg(long)]
209 addresses_file: Option<String>,
210 #[arg(long)]
212 from_block: Option<u64>,
213 #[arg(long)]
215 to_block: Option<u64>,
216 #[expect(
218 clippy::doc_markdown,
219 reason = "clap renders doc comments as plain help text"
220 )]
221 #[arg(long)]
222 rpc_url: Option<String>,
223 #[arg(long)]
225 reset: bool,
226 #[expect(
228 clippy::doc_markdown,
229 reason = "clap renders doc comments as plain help text"
230 )]
231 #[arg(long)]
232 require_existing_snapshot: bool,
233 #[arg(long, value_delimiter = ',')]
235 checkpoint_blocks: Vec<u64>,
236 #[arg(long)]
238 skip_validation: bool,
239 #[arg(long)]
241 snapshot_from_rpc: bool,
242 #[arg(long)]
244 concurrency: Option<usize>,
245 #[arg(long)]
247 multicall_calls_per_rpc_request: Option<u32>,
248 #[clap(flatten)]
250 database: DatabaseConfig,
251 },
252}
253
254#[cfg(all(test, feature = "defi"))]
255mod tests {
256 use clap::Parser;
257 use rstest::rstest;
258
259 use super::*;
260
261 #[rstest]
262 fn analyze_pools_cli_parses_repeated_addresses_file_and_shared_options() {
263 let cli = NautilusCli::try_parse_from([
264 "nautilus",
265 "blockchain",
266 "analyze-pools",
267 "--chain",
268 "ethereum",
269 "--dex",
270 "UniswapV3",
271 "--address",
272 "0x1111111111111111111111111111111111111111",
273 "--address",
274 "0x2222222222222222222222222222222222222222",
275 "--addresses-file",
276 "/tmp/pools.txt",
277 "--from-block",
278 "100",
279 "--to-block",
280 "200",
281 "--rpc-url",
282 "http://localhost:8545",
283 "--reset",
284 "--require-existing-snapshot",
285 "--multicall-calls-per-rpc-request",
286 "25",
287 "--host",
288 "localhost",
289 "--port",
290 "5433",
291 "--username",
292 "postgres",
293 "--database",
294 "nautilus",
295 "--password",
296 "secret",
297 ])
298 .unwrap();
299
300 match cli.command {
301 Commands::Blockchain(BlockchainOpt {
302 command:
303 BlockchainCommand::AnalyzePools {
304 chain,
305 dex,
306 addresses,
307 addresses_file,
308 from_block,
309 to_block,
310 rpc_url,
311 reset,
312 require_existing_snapshot,
313 checkpoint_blocks,
314 skip_validation,
315 snapshot_from_rpc,
316 concurrency,
317 multicall_calls_per_rpc_request,
318 database,
319 },
320 }) => {
321 assert_eq!(chain, "ethereum");
322 assert_eq!(dex, "UniswapV3");
323 assert_eq!(
324 addresses,
325 vec![
326 "0x1111111111111111111111111111111111111111".to_string(),
327 "0x2222222222222222222222222222222222222222".to_string(),
328 ]
329 );
330 assert_eq!(addresses_file.as_deref(), Some("/tmp/pools.txt"));
331 assert_eq!(from_block, Some(100));
332 assert_eq!(to_block, Some(200));
333 assert_eq!(rpc_url.as_deref(), Some("http://localhost:8545"));
334 assert!(reset);
335 assert!(require_existing_snapshot);
336 assert!(checkpoint_blocks.is_empty());
337 assert!(!skip_validation);
338 assert!(!snapshot_from_rpc);
339 assert_eq!(concurrency, None);
340 assert_eq!(multicall_calls_per_rpc_request, Some(25));
341 assert_eq!(database.host.as_deref(), Some("localhost"));
342 assert_eq!(database.port, Some(5433));
343 assert_eq!(database.username.as_deref(), Some("postgres"));
344 assert_eq!(database.database.as_deref(), Some("nautilus"));
345 assert_eq!(database.password.as_deref(), Some("secret"));
346 assert_eq!(database.schema, None);
347 }
348 _ => panic!("Expected analyze-pools blockchain command"),
349 }
350 }
351
352 #[rstest]
353 #[case("analyze-pool")]
354 #[case("analyze-pools")]
355 fn blockchain_analysis_help_lists_capabilities_as_plain_text(#[case] subcommand: &str) {
356 let mut command = crate::cli_command();
357 let help = command
358 .find_subcommand_mut("blockchain")
359 .and_then(|command| command.find_subcommand_mut(subcommand))
360 .map(|command| command.render_long_help().to_string())
361 .unwrap();
362
363 assert!(help.contains("UniswapV3"));
365 assert!(help.contains("PancakeSwapV3"));
366 assert!(help.contains("AerodromeSlipstream"));
367 assert!(!help.contains("SushiSwapV2"));
368 assert!(help.contains("RPC_HTTP_URL"));
369 assert!(help.contains("needs_bootstrap"));
370 assert!(!help.contains("`UniswapV3`"));
372 assert!(!help.contains("`PancakeSwapV3`"));
373 assert!(!help.contains("`RPC_HTTP_URL`"));
374 assert!(!help.contains("`needs_bootstrap`"));
375 }
376
377 #[rstest]
378 fn blockchain_sync_dex_help_lists_discoverable_dexes() {
379 let mut command = crate::cli_command();
380 let help = command
381 .find_subcommand_mut("blockchain")
382 .and_then(|command| command.find_subcommand_mut("sync-dex"))
383 .map(|command| command.render_long_help().to_string())
384 .unwrap();
385
386 assert!(help.contains("Discoverable DEXes"));
388 assert!(!help.contains("Snapshot-capable"));
389 assert!(help.contains("UniswapV2"));
391 }
392}
393
394#[derive(Debug, Parser)]
396pub struct CatalogOpt {
397 #[clap(subcommand)]
398 pub(crate) command: CatalogCommand,
399}
400
401#[derive(Debug, Parser)]
403pub enum CatalogCommand {
404 MigrateParquet(CatalogMigrationOpt),
405}
406
407#[derive(Debug, Parser)]
409pub struct CatalogMigrationOpt {
410 pub(crate) source: String,
412 pub(crate) destination: String,
414 #[arg(long)]
416 pub(crate) dry_run: bool,
417 #[arg(long = "source-option", value_parser = parse_storage_option)]
419 pub(crate) source_options: Vec<(String, String)>,
420 #[arg(long = "target-option", value_parser = parse_storage_option)]
422 pub(crate) target_options: Vec<(String, String)>,
423}
424
425#[cfg(test)]
426mod database_tests {
427 use clap::Parser;
428 use rstest::rstest;
429
430 use super::*;
431
432 #[rstest]
433 fn assign_account_cli_parses_ids_and_connection_options() {
434 let cli = NautilusCli::try_parse_from([
435 "nautilus",
436 "database",
437 "assign-account",
438 "--account-id",
439 "HYPERLIQUID-001",
440 "--trader-id",
441 "TRADER-001",
442 "--host",
443 "localhost",
444 "--port",
445 "5433",
446 ])
447 .unwrap();
448
449 let Commands::Database(DatabaseOpt {
450 command: DatabaseCommand::AssignAccount(config),
451 }) = cli.command
452 else {
453 panic!("Expected assign-account database command");
454 };
455
456 assert_eq!(config.account_id, "HYPERLIQUID-001");
457 assert_eq!(config.trader_id, "TRADER-001");
458 assert_eq!(config.database.host.as_deref(), Some("localhost"));
459 assert_eq!(config.database.port, Some(5433));
460 }
461
462 #[rstest]
463 fn assign_account_cli_requires_account_and_trader() {
464 let result = NautilusCli::try_parse_from([
465 "nautilus",
466 "database",
467 "assign-account",
468 "--trader-id",
469 "T-1",
470 ]);
471
472 assert!(result.is_err());
473 }
474}