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