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    /// Assigns an account's events that have no trader to a trader.
76    AssignAccount(AssignAccountConfig),
77}
78
79/// Configuration for assigning an account's events that have no trader.
80///
81/// Account events written before trader-scoped persistence carry no trader, and no trader-scoped
82/// cache loads them until they are assigned.
83#[derive(Parser, Debug, Clone)]
84pub struct AssignAccountConfig {
85    /// Account ID whose events to assign.
86    #[arg(long)]
87    pub(crate) account_id: String,
88    /// Trader ID to assign the events to.
89    #[arg(long)]
90    pub(crate) trader_id: String,
91    /// Database configuration options.
92    #[clap(flatten)]
93    pub(crate) database: DatabaseConfig,
94}
95
96#[cfg(feature = "defi")]
97/// Blockchain management options and subcommands.
98#[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/// Available blockchain management commands.
107#[derive(Parser, Debug, Clone)]
108#[command(about = "Blockchain operations", long_about = None)]
109pub enum BlockchainCommand {
110    /// Syncs blockchain blocks.
111    SyncBlocks {
112        /// The blockchain chain name (case-insensitive). Examples: ethereum, arbitrum, base, polygon, bsc
113        #[arg(long)]
114        chain: String,
115        /// Starting block number to sync from (optional)
116        #[arg(long)]
117        from_block: Option<u64>,
118        /// Ending block number to sync to (optional, defaults to current chain head)
119        #[arg(long)]
120        to_block: Option<u64>,
121        /// Database configuration options
122        #[clap(flatten)]
123        database: DatabaseConfig,
124    },
125    /// Sync DEX pools.
126    SyncDex {
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        /// RPC HTTP URL for blockchain calls (optional, falls back to `RPC_HTTP_URL` env var)
134        #[arg(long)]
135        rpc_url: Option<String>,
136        /// Reset sync progress and start from the beginning, ignoring last synced block
137        #[arg(long)]
138        reset: bool,
139        /// Maximum number of Multicall calls per RPC request (optional, defaults to 200)
140        #[arg(long)]
141        multicall_calls_per_rpc_request: Option<u32>,
142        /// Database configuration options
143        #[clap(flatten)]
144        database: DatabaseConfig,
145    },
146    /// Analyze a specific DEX pool.
147    AnalyzePool {
148        /// The blockchain chain name (case-insensitive). Supported chains are listed below.
149        #[arg(long)]
150        chain: String,
151        /// The DEX name (case-insensitive). Supported DEX names are listed below.
152        #[arg(long)]
153        dex: String,
154        /// The pool contract address
155        #[arg(long)]
156        address: String,
157        /// Starting block number to sync from (optional)
158        #[arg(long)]
159        from_block: Option<u64>,
160        /// Ending block number to sync to (optional, defaults to current chain head)
161        #[arg(long)]
162        to_block: Option<u64>,
163        /// RPC HTTP URL for blockchain calls (optional, falls back to RPC_HTTP_URL env var)
164        #[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        /// Reset sync progress and start from the beginning, ignoring last synced block
171        #[arg(long)]
172        reset: bool,
173        /// Return needs_bootstrap for pools without a valid snapshot before the target block
174        #[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        /// Checkpoint block numbers to snapshot in one pass (comma-separated, each at or below to-block)
181        #[arg(long, value_delimiter = ',')]
182        checkpoint_blocks: Vec<u64>,
183        /// Skip on-chain validation and persist replay-derived snapshots without the multicall compare
184        #[arg(long)]
185        skip_validation: bool,
186        /// Build the snapshot from mint/burn history plus an RPC read, without full swap storage
187        #[arg(long)]
188        snapshot_from_rpc: bool,
189        /// Maximum number of Multicall calls per RPC request (optional, defaults to 200)
190        #[arg(long)]
191        multicall_calls_per_rpc_request: Option<u32>,
192        /// Database configuration options
193        #[clap(flatten)]
194        database: DatabaseConfig,
195    },
196    /// Analyze several DEX pools in one runtime.
197    AnalyzePools {
198        /// The blockchain chain name (case-insensitive). Supported chains are listed below.
199        #[arg(long)]
200        chain: String,
201        /// The DEX name (case-insensitive). Supported DEX names are listed below.
202        #[arg(long)]
203        dex: String,
204        /// Pool contract address. Can be repeated.
205        #[arg(long = "address")]
206        addresses: Vec<String>,
207        /// File containing one pool contract address per line. Empty lines and comment lines are ignored.
208        #[arg(long)]
209        addresses_file: Option<String>,
210        /// Starting block number to sync from (optional)
211        #[arg(long)]
212        from_block: Option<u64>,
213        /// Ending block number to sync to (optional, defaults to current chain head)
214        #[arg(long)]
215        to_block: Option<u64>,
216        /// RPC HTTP URL for blockchain calls (optional, falls back to RPC_HTTP_URL env var)
217        #[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        /// Reset sync progress and start from the beginning, ignoring last synced block
224        #[arg(long)]
225        reset: bool,
226        /// Return needs_bootstrap for pools without a valid snapshot before the target block
227        #[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        /// Checkpoint block numbers to snapshot in one pass (comma-separated, each at or below to-block)
234        #[arg(long, value_delimiter = ',')]
235        checkpoint_blocks: Vec<u64>,
236        /// Skip on-chain validation and persist replay-derived snapshots without the multicall compare
237        #[arg(long)]
238        skip_validation: bool,
239        /// Build snapshots from mint/burn history plus RPC reads, without full swap storage
240        #[arg(long)]
241        snapshot_from_rpc: bool,
242        /// Maximum number of pools to analyze concurrently (optional, defaults to 4)
243        #[arg(long)]
244        concurrency: Option<usize>,
245        /// Maximum number of Multicall calls per RPC request (optional, defaults to 200)
246        #[arg(long)]
247        multicall_calls_per_rpc_request: Option<u32>,
248        /// Database configuration options
249        #[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        // Snapshot-capable DEXes are listed; the registered-but-unsupported SushiSwapV2 is not.
364        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        // Help is rendered as plain text, so doc-markdown backticks must not survive.
371        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        // sync-dex receives the discovery block, not the snapshot block.
387        assert!(help.contains("Discoverable DEXes"));
388        assert!(!help.contains("Snapshot-capable"));
389        // UniswapV2 is discovery-only, so it appears here but never in the snapshot listing.
390        assert!(help.contains("UniswapV2"));
391    }
392}
393
394/// Catalog management commands.
395#[derive(Debug, Parser)]
396pub struct CatalogOpt {
397    #[clap(subcommand)]
398    pub(crate) command: CatalogCommand,
399}
400
401/// Operations on persisted catalogs.
402#[derive(Debug, Parser)]
403pub enum CatalogCommand {
404    MigrateParquet(CatalogMigrationOpt),
405}
406
407/// Convert a Parquet catalog to the current Arrow storage format.
408#[derive(Debug, Parser)]
409pub struct CatalogMigrationOpt {
410    /// Source catalog path or object-store URI.
411    pub(crate) source: String,
412    /// Empty destination catalog path or object-store URI.
413    pub(crate) destination: String,
414    /// Validate source schemas without creating the destination.
415    #[arg(long)]
416    pub(crate) dry_run: bool,
417    /// Source object-store option in key=value form. Can be repeated.
418    #[arg(long = "source-option", value_parser = parse_storage_option)]
419    pub(crate) source_options: Vec<(String, String)>,
420    /// Destination object-store option in key=value form. Can be repeated.
421    #[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}