Skip to main content

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}