nautilus_cli/opt.rs
1// -------------------------------------------------------------------------------------------------
2// Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3// https://nautechsystems.io
4//
5// Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6// You may not use this file except in compliance with the License.
7// You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use clap::Parser;
17
18/// Command-line interface for NautilusTrader.
19#[derive(Debug, Parser)]
20#[clap(version, about, author)]
21pub struct NautilusCli {
22 #[clap(subcommand)]
23 pub(crate) command: Commands,
24}
25
26/// Available top-level commands for the NautilusTrader CLI.
27#[derive(Parser, Debug)]
28pub enum Commands {
29 Database(DatabaseOpt),
30 #[cfg(feature = "defi")]
31 Blockchain(BlockchainOpt),
32}
33
34/// Database management options and subcommands.
35#[derive(Parser, Debug)]
36#[command(about = "Postgres database operations", long_about = None)]
37pub struct DatabaseOpt {
38 #[clap(subcommand)]
39 pub(crate) command: DatabaseCommand,
40}
41
42/// Configuration parameters for database connection and operations.
43#[derive(Parser, Debug, Clone)]
44pub struct DatabaseConfig {
45 /// Hostname or IP address of the database server.
46 #[arg(long)]
47 pub(crate) host: Option<String>,
48 /// Port number of the database server.
49 #[arg(long)]
50 pub(crate) port: Option<u16>,
51 /// Username for connecting to the database.
52 #[arg(long)]
53 pub(crate) username: Option<String>,
54 /// Name of the database.
55 #[arg(long)]
56 pub(crate) database: Option<String>,
57 /// Password for connecting to the database.
58 #[arg(long)]
59 pub(crate) password: Option<String>,
60 /// Directory path to the schema files.
61 #[arg(long)]
62 pub(crate) schema: Option<String>,
63}
64
65/// Available database management commands.
66#[derive(Parser, Debug, Clone)]
67#[command(about = "Postgres database operations", long_about = None)]
68pub enum DatabaseCommand {
69 /// Initializes a new Postgres database with the latest schema.
70 Init(DatabaseConfig),
71 /// Drops roles, privileges and deletes all data from the database.
72 Drop(DatabaseConfig),
73}
74
75#[cfg(feature = "defi")]
76/// Blockchain management options and subcommands.
77#[derive(Parser, Debug)]
78#[command(about = "Blockchain operations", long_about = None)]
79pub struct BlockchainOpt {
80 #[clap(subcommand)]
81 pub(crate) command: BlockchainCommand,
82}
83
84#[cfg(feature = "defi")]
85/// Available blockchain management commands.
86#[derive(Parser, Debug, Clone)]
87#[command(about = "Blockchain operations", long_about = None)]
88pub enum BlockchainCommand {
89 /// Syncs blockchain blocks.
90 SyncBlocks {
91 /// The blockchain chain name (case-insensitive). Examples: ethereum, arbitrum, base, polygon, bsc
92 #[arg(long)]
93 chain: String,
94 /// Starting block number to sync from (optional)
95 #[arg(long)]
96 from_block: Option<u64>,
97 /// Ending block number to sync to (optional, defaults to current chain head)
98 #[arg(long)]
99 to_block: Option<u64>,
100 /// Database configuration options
101 #[clap(flatten)]
102 database: DatabaseConfig,
103 },
104 /// Sync DEX pools.
105 SyncDex {
106 /// The blockchain chain name (case-insensitive). Supported chains are listed below.
107 #[arg(long)]
108 chain: String,
109 /// The DEX name (case-insensitive). Supported DEX names are listed below.
110 #[arg(long)]
111 dex: String,
112 /// RPC HTTP URL for blockchain calls (optional, falls back to `RPC_HTTP_URL` env var)
113 #[arg(long)]
114 rpc_url: Option<String>,
115 /// Reset sync progress and start from the beginning, ignoring last synced block
116 #[arg(long)]
117 reset: bool,
118 /// Maximum number of Multicall calls per RPC request (optional, defaults to 200)
119 #[arg(long)]
120 multicall_calls_per_rpc_request: Option<u32>,
121 /// Database configuration options
122 #[clap(flatten)]
123 database: DatabaseConfig,
124 },
125 /// Analyze a specific DEX pool.
126 AnalyzePool {
127 /// The blockchain chain name (case-insensitive). Supported chains are listed below.
128 #[arg(long)]
129 chain: String,
130 /// The DEX name (case-insensitive). Supported DEX names are listed below.
131 #[arg(long)]
132 dex: String,
133 /// The pool contract address
134 #[arg(long)]
135 address: String,
136 /// Starting block number to sync from (optional)
137 #[arg(long)]
138 from_block: Option<u64>,
139 /// Ending block number to sync to (optional, defaults to current chain head)
140 #[arg(long)]
141 to_block: Option<u64>,
142 /// RPC HTTP URL for blockchain calls (optional, falls back to RPC_HTTP_URL env var)
143 #[expect(
144 clippy::doc_markdown,
145 reason = "clap renders doc comments as plain help text"
146 )]
147 #[arg(long)]
148 rpc_url: Option<String>,
149 /// Reset sync progress and start from the beginning, ignoring last synced block
150 #[arg(long)]
151 reset: bool,
152 /// Return needs_bootstrap for pools without a valid snapshot before the target block
153 #[expect(
154 clippy::doc_markdown,
155 reason = "clap renders doc comments as plain help text"
156 )]
157 #[arg(long)]
158 require_existing_snapshot: bool,
159 /// Checkpoint block numbers to snapshot in one pass (comma-separated, each at or below to-block)
160 #[arg(long, value_delimiter = ',')]
161 checkpoint_blocks: Vec<u64>,
162 /// Skip on-chain validation and persist replay-derived snapshots without the multicall compare
163 #[arg(long)]
164 skip_validation: bool,
165 /// Build the snapshot from mint/burn history plus an RPC read, without full swap storage
166 #[arg(long)]
167 snapshot_from_rpc: bool,
168 /// Maximum number of Multicall calls per RPC request (optional, defaults to 200)
169 #[arg(long)]
170 multicall_calls_per_rpc_request: Option<u32>,
171 /// Database configuration options
172 #[clap(flatten)]
173 database: DatabaseConfig,
174 },
175 /// Analyze several DEX pools in one runtime.
176 AnalyzePools {
177 /// The blockchain chain name (case-insensitive). Supported chains are listed below.
178 #[arg(long)]
179 chain: String,
180 /// The DEX name (case-insensitive). Supported DEX names are listed below.
181 #[arg(long)]
182 dex: String,
183 /// Pool contract address. Can be repeated.
184 #[arg(long = "address")]
185 addresses: Vec<String>,
186 /// File containing one pool contract address per line. Empty lines and comment lines are ignored.
187 #[arg(long)]
188 addresses_file: Option<String>,
189 /// Starting block number to sync from (optional)
190 #[arg(long)]
191 from_block: Option<u64>,
192 /// Ending block number to sync to (optional, defaults to current chain head)
193 #[arg(long)]
194 to_block: Option<u64>,
195 /// RPC HTTP URL for blockchain calls (optional, falls back to RPC_HTTP_URL env var)
196 #[expect(
197 clippy::doc_markdown,
198 reason = "clap renders doc comments as plain help text"
199 )]
200 #[arg(long)]
201 rpc_url: Option<String>,
202 /// Reset sync progress and start from the beginning, ignoring last synced block
203 #[arg(long)]
204 reset: bool,
205 /// Return needs_bootstrap for pools without a valid snapshot before the target block
206 #[expect(
207 clippy::doc_markdown,
208 reason = "clap renders doc comments as plain help text"
209 )]
210 #[arg(long)]
211 require_existing_snapshot: bool,
212 /// Checkpoint block numbers to snapshot in one pass (comma-separated, each at or below to-block)
213 #[arg(long, value_delimiter = ',')]
214 checkpoint_blocks: Vec<u64>,
215 /// Skip on-chain validation and persist replay-derived snapshots without the multicall compare
216 #[arg(long)]
217 skip_validation: bool,
218 /// Build snapshots from mint/burn history plus RPC reads, without full swap storage
219 #[arg(long)]
220 snapshot_from_rpc: bool,
221 /// Maximum number of pools to analyze concurrently (optional, defaults to 4)
222 #[arg(long)]
223 concurrency: Option<usize>,
224 /// Maximum number of Multicall calls per RPC request (optional, defaults to 200)
225 #[arg(long)]
226 multicall_calls_per_rpc_request: Option<u32>,
227 /// Database configuration options
228 #[clap(flatten)]
229 database: DatabaseConfig,
230 },
231}
232
233#[cfg(all(test, feature = "defi"))]
234mod tests {
235 use clap::Parser;
236 use rstest::rstest;
237
238 use super::*;
239
240 #[rstest]
241 fn analyze_pools_cli_parses_repeated_addresses_file_and_shared_options() {
242 let cli = NautilusCli::try_parse_from([
243 "nautilus",
244 "blockchain",
245 "analyze-pools",
246 "--chain",
247 "ethereum",
248 "--dex",
249 "UniswapV3",
250 "--address",
251 "0x1111111111111111111111111111111111111111",
252 "--address",
253 "0x2222222222222222222222222222222222222222",
254 "--addresses-file",
255 "/tmp/pools.txt",
256 "--from-block",
257 "100",
258 "--to-block",
259 "200",
260 "--rpc-url",
261 "http://localhost:8545",
262 "--reset",
263 "--require-existing-snapshot",
264 "--multicall-calls-per-rpc-request",
265 "25",
266 "--host",
267 "localhost",
268 "--port",
269 "5433",
270 "--username",
271 "postgres",
272 "--database",
273 "nautilus",
274 "--password",
275 "secret",
276 ])
277 .unwrap();
278
279 match cli.command {
280 Commands::Blockchain(BlockchainOpt {
281 command:
282 BlockchainCommand::AnalyzePools {
283 chain,
284 dex,
285 addresses,
286 addresses_file,
287 from_block,
288 to_block,
289 rpc_url,
290 reset,
291 require_existing_snapshot,
292 checkpoint_blocks,
293 skip_validation,
294 snapshot_from_rpc,
295 concurrency,
296 multicall_calls_per_rpc_request,
297 database,
298 },
299 }) => {
300 assert_eq!(chain, "ethereum");
301 assert_eq!(dex, "UniswapV3");
302 assert_eq!(
303 addresses,
304 vec![
305 "0x1111111111111111111111111111111111111111".to_string(),
306 "0x2222222222222222222222222222222222222222".to_string(),
307 ]
308 );
309 assert_eq!(addresses_file.as_deref(), Some("/tmp/pools.txt"));
310 assert_eq!(from_block, Some(100));
311 assert_eq!(to_block, Some(200));
312 assert_eq!(rpc_url.as_deref(), Some("http://localhost:8545"));
313 assert!(reset);
314 assert!(require_existing_snapshot);
315 assert!(checkpoint_blocks.is_empty());
316 assert!(!skip_validation);
317 assert!(!snapshot_from_rpc);
318 assert_eq!(concurrency, None);
319 assert_eq!(multicall_calls_per_rpc_request, Some(25));
320 assert_eq!(database.host.as_deref(), Some("localhost"));
321 assert_eq!(database.port, Some(5433));
322 assert_eq!(database.username.as_deref(), Some("postgres"));
323 assert_eq!(database.database.as_deref(), Some("nautilus"));
324 assert_eq!(database.password.as_deref(), Some("secret"));
325 assert_eq!(database.schema, None);
326 }
327 _ => panic!("Expected analyze-pools blockchain command"),
328 }
329 }
330
331 #[rstest]
332 #[case("analyze-pool")]
333 #[case("analyze-pools")]
334 fn blockchain_analysis_help_lists_capabilities_as_plain_text(#[case] subcommand: &str) {
335 let mut command = crate::cli_command();
336 let help = command
337 .find_subcommand_mut("blockchain")
338 .and_then(|command| command.find_subcommand_mut(subcommand))
339 .map(|command| command.render_long_help().to_string())
340 .unwrap();
341
342 // Snapshot-capable DEXes are listed; the registered-but-unsupported SushiSwapV2 is not.
343 assert!(help.contains("UniswapV3"));
344 assert!(help.contains("PancakeSwapV3"));
345 assert!(help.contains("AerodromeSlipstream"));
346 assert!(!help.contains("SushiSwapV2"));
347 assert!(help.contains("RPC_HTTP_URL"));
348 assert!(help.contains("needs_bootstrap"));
349 // Help is rendered as plain text, so doc-markdown backticks must not survive.
350 assert!(!help.contains("`UniswapV3`"));
351 assert!(!help.contains("`PancakeSwapV3`"));
352 assert!(!help.contains("`RPC_HTTP_URL`"));
353 assert!(!help.contains("`needs_bootstrap`"));
354 }
355
356 #[rstest]
357 fn blockchain_sync_dex_help_lists_discoverable_dexes() {
358 let mut command = crate::cli_command();
359 let help = command
360 .find_subcommand_mut("blockchain")
361 .and_then(|command| command.find_subcommand_mut("sync-dex"))
362 .map(|command| command.render_long_help().to_string())
363 .unwrap();
364
365 // sync-dex receives the discovery block, not the snapshot block.
366 assert!(help.contains("Discoverable DEXes"));
367 assert!(!help.contains("Snapshot-capable"));
368 // UniswapV2 is discovery-only, so it appears here but never in the snapshot listing.
369 assert!(help.contains("UniswapV2"));
370 }
371}