Skip to main content

nautilus_interactive_brokers/gateway/
dockerized.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
16//! Dockerized IB Gateway management.
17
18#[cfg(feature = "gateway")]
19use std::{collections::HashMap, fmt::Debug, time::Duration};
20
21#[cfg(feature = "gateway")]
22use anyhow::Context;
23#[cfg(feature = "gateway")]
24use bollard::Docker;
25#[cfg(feature = "gateway")]
26use bollard::container::LogOutput;
27#[cfg(feature = "gateway")]
28use bollard::models::{
29    ContainerCreateBody, ContainerCreateResponse, HostConfig, PortBinding, RestartPolicy,
30    RestartPolicyNameEnum,
31};
32#[cfg(feature = "gateway")]
33use bollard::query_parameters::{
34    CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions,
35    StartContainerOptions, StopContainerOptions,
36};
37#[cfg(feature = "gateway")]
38use futures_util::StreamExt;
39#[cfg(feature = "gateway")]
40use jiff::Timestamp;
41#[cfg(feature = "gateway")]
42use nautilus_core::string::secret::SecretString;
43use serde::{Deserialize, Serialize};
44
45#[cfg(not(feature = "gateway"))]
46use crate::config::DockerizedIBGatewayConfig;
47#[cfg(feature = "gateway")]
48use crate::config::{DockerizedIBGatewayConfig, TradingMode};
49
50/// Container status enumeration.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(
53    feature = "python",
54    pyo3::pyclass(
55        module = "nautilus_trader.adapters.interactive_brokers",
56        from_py_object,
57        rename_all = "SCREAMING_SNAKE_CASE"
58    )
59)]
60#[cfg_attr(
61    feature = "python",
62    pyo3_stub_gen::derive::gen_stub_pyclass_enum(
63        module = "nautilus_trader.adapters.interactive_brokers"
64    )
65)]
66pub enum ContainerStatus {
67    /// No container exists.
68    NoContainer = 1,
69    /// Container has been created but not started.
70    ContainerCreated = 2,
71    /// Container is starting.
72    ContainerStarting = 3,
73    /// Container has stopped.
74    ContainerStopped = 4,
75    /// Container is running but not logged in.
76    NotLoggedIn = 5,
77    /// Container is ready (running and logged in).
78    Ready = 6,
79    /// Unknown container status.
80    Unknown = 7,
81}
82
83/// Dockerized IB Gateway manager.
84///
85/// This struct manages the lifecycle of Interactive Brokers Gateway Docker containers,
86/// including creation, starting, stopping, and status checking.
87#[derive(Clone)]
88#[cfg_attr(
89    feature = "python",
90    pyo3::pyclass(
91        module = "nautilus_trader.adapters.interactive_brokers",
92        from_py_object
93    )
94)]
95#[cfg_attr(
96    feature = "python",
97    pyo3_stub_gen::derive::gen_stub_pyclass(
98        module = "nautilus_trader.adapters.interactive_brokers"
99    )
100)]
101#[cfg(feature = "gateway")]
102pub struct DockerizedIBGateway {
103    /// Configuration for the gateway.
104    config: DockerizedIBGatewayConfig,
105    /// Docker client.
106    pub(crate) docker: Docker,
107    /// Username for IB account.
108    username: SecretString,
109    /// Password for IB account.
110    password: SecretString,
111    /// Host address (always 127.0.0.1).
112    host: String,
113    /// Port for the gateway.
114    port: u16,
115    /// Container name.
116    container_name: String,
117}
118
119#[cfg(feature = "gateway")]
120impl Debug for DockerizedIBGateway {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct(stringify!(DockerizedIBGateway))
123            .field("host", &self.host)
124            .field("port", &self.port)
125            .field("container_name", &self.container_name)
126            .field("trading_mode", &self.config.trading_mode)
127            .finish_non_exhaustive()
128    }
129}
130
131#[cfg(feature = "gateway")]
132impl DockerizedIBGateway {
133    /// Base container name.
134    pub const CONTAINER_NAME: &'static str = "nautilus-ib-gateway";
135
136    /// Host API ports by trading mode.
137    pub const HOST_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4002), ("Live", 4001)];
138
139    /// Container API ports exposed by the IB Gateway image.
140    pub const CONTAINER_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4004), ("Live", 4003)];
141
142    /// Internal VNC port.
143    pub const VNC_PORT_INTERNAL: u16 = 5900;
144
145    fn host_port_for_mode(trading_mode: TradingMode) -> u16 {
146        match trading_mode {
147            TradingMode::Paper => 4002,
148            TradingMode::Live => 4001,
149        }
150    }
151
152    fn container_port_for_mode(trading_mode: TradingMode) -> u16 {
153        match trading_mode {
154            TradingMode::Paper => 4004,
155            TradingMode::Live => 4003,
156        }
157    }
158
159    fn logs_indicate_ready(logs: &str) -> bool {
160        logs.contains("Login has completed")
161            || logs.contains("Configuration tasks completed")
162            || logs.contains("Logged in to")
163            || logs.contains("Login successful")
164    }
165
166    fn session_logs_indicate_ready(logs: &str, started_at: Timestamp) -> bool {
167        logs.lines().any(|line| {
168            let Some((timestamp, message)) = line.split_once(' ') else {
169                return false;
170            };
171
172            timestamp.parse::<Timestamp>().is_ok_and(|timestamp| {
173                timestamp >= started_at && Self::logs_indicate_ready(message)
174            })
175        })
176    }
177
178    /// Create a new DockerizedIBGateway from configuration.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if:
183    /// - Configuration validation fails.
184    /// - Username or password is not provided and not available in environment variables.
185    /// - Docker client creation fails.
186    pub fn new(mut config: DockerizedIBGatewayConfig) -> anyhow::Result<Self> {
187        config.validate()?;
188
189        let username = config
190            .username
191            .take()
192            .or_else(|| std::env::var("TWS_USERNAME").ok().map(SecretString::from))
193            .ok_or_else(|| anyhow::anyhow!("username not set nor available in env TWS_USERNAME"))?;
194
195        let password = config
196            .password
197            .take()
198            .or_else(|| std::env::var("TWS_PASSWORD").ok().map(SecretString::from))
199            .ok_or_else(|| anyhow::anyhow!("password not set nor available in env TWS_PASSWORD"))?;
200
201        // Connect to Docker
202        let docker = Docker::connect_with_local_defaults().context(
203            "Failed to connect to the local Docker daemon. Ensure Docker is running and the local Docker socket is available",
204        )?;
205
206        // Determine port based on trading mode
207        let mode_str = match config.trading_mode {
208            TradingMode::Paper => "Paper",
209            TradingMode::Live => "Live",
210        };
211
212        let port = Self::host_port_for_mode(config.trading_mode);
213
214        // Generate container name
215        let container_name = format!("{}-{}", Self::CONTAINER_NAME, mode_str).to_lowercase();
216
217        Ok(Self {
218            config,
219            docker,
220            username,
221            password,
222            host: "127.0.0.1".to_string(),
223            port,
224            container_name,
225        })
226    }
227
228    /// Get the container name.
229    pub fn container_name(&self) -> &str {
230        &self.container_name
231    }
232
233    /// Get the host address.
234    pub fn host(&self) -> &str {
235        &self.host
236    }
237
238    /// Get the port.
239    pub fn port(&self) -> u16 {
240        self.port
241    }
242
243    /// Check if the container is logged in by examining logs.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if log retrieval fails.
248    pub async fn is_logged_in(&self, container_id: &str) -> anyhow::Result<bool> {
249        let started_at = self
250            .docker
251            .inspect_container(container_id, None)
252            .await
253            .context("Failed to inspect gateway container")?
254            .state
255            .and_then(|state| state.started_at)
256            .context("Gateway container start time is missing")?
257            .parse::<Timestamp>()
258            .context("Gateway container start time is invalid")?;
259        let since = i32::try_from(started_at.as_second())
260            .context("Gateway container start time exceeds the Docker log API range")?;
261
262        let logs_options = LogsOptions {
263            stdout: true,
264            stderr: true,
265            since,
266            timestamps: true,
267            ..Default::default()
268        };
269
270        let mut logs_stream = self.docker.logs(container_id, Some(logs_options));
271
272        let mut logged_in = false;
273
274        while let Some(log_result) = logs_stream.next().await {
275            let log_output = log_result.context("Failed to read log chunk")?;
276
277            // Handle LogOutput enum variants
278            let log_bytes = match log_output {
279                LogOutput::StdOut { message } | LogOutput::StdErr { message } => message,
280                LogOutput::StdIn { message } | LogOutput::Console { message } => message,
281            };
282
283            let log_string = String::from_utf8_lossy(&log_bytes);
284            if Self::session_logs_indicate_ready(&log_string, started_at) {
285                logged_in = true;
286                break;
287            }
288        }
289
290        Ok(logged_in)
291    }
292
293    /// Get the current container status.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if container inspection fails.
298    pub async fn container_status(&self) -> anyhow::Result<ContainerStatus> {
299        let list_options = ListContainersOptions {
300            all: true,
301            ..Default::default()
302        };
303
304        let containers = self
305            .docker
306            .list_containers(Some(list_options))
307            .await
308            .context("Failed to list containers")?;
309
310        let container = containers.iter().find(|c| {
311            c.names
312                .as_ref()
313                .and_then(|names| names.first())
314                .is_some_and(|name| name.trim_start_matches('/') == self.container_name)
315        });
316
317        let Some(container) = container else {
318            return Ok(ContainerStatus::NoContainer);
319        };
320
321        let state = container
322            .state
323            .as_ref()
324            .map_or("unknown", |state| state.as_ref());
325
326        match state {
327            "running" => {
328                let container_id = container
329                    .id
330                    .as_ref()
331                    .ok_or_else(|| anyhow::anyhow!("Container ID missing"))?;
332
333                if self.is_logged_in(container_id).await.unwrap_or(false) {
334                    Ok(ContainerStatus::Ready)
335                } else {
336                    Ok(ContainerStatus::ContainerStarting)
337                }
338            }
339            "stopped" | "exited" => Ok(ContainerStatus::ContainerStopped),
340            "created" => Ok(ContainerStatus::ContainerCreated),
341            _ => Ok(ContainerStatus::Unknown),
342        }
343    }
344
345    /// Start the gateway container.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if container creation or startup fails.
350    pub async fn start(&mut self, wait: Option<u64>) -> anyhow::Result<()> {
351        tracing::debug!("Ensuring gateway is running");
352
353        let status = self.container_status().await?;
354
355        let broken_statuses = [
356            ContainerStatus::NotLoggedIn,
357            ContainerStatus::ContainerStopped,
358            ContainerStatus::ContainerCreated,
359            ContainerStatus::Unknown,
360        ];
361
362        match status {
363            ContainerStatus::NoContainer => {
364                tracing::debug!("No container, starting");
365            }
366            status if broken_statuses.contains(&status) => {
367                tracing::debug!("Status {:?}, removing existing container", status);
368                self.stop().await?;
369            }
370            ContainerStatus::Ready | ContainerStatus::ContainerStarting => {
371                tracing::debug!("Status {:?}, using existing container", status);
372                return Ok(());
373            }
374            _ => {}
375        }
376
377        tracing::debug!("Starting new container");
378
379        // Determine port mappings
380        let host_port = Self::host_port_for_mode(self.config.trading_mode);
381        let container_port = Self::container_port_for_mode(self.config.trading_mode);
382
383        let mut port_bindings = HashMap::new();
384        port_bindings.insert(
385            format!("{container_port}/tcp"),
386            Some(vec![PortBinding {
387                host_ip: Some(self.host.clone()),
388                host_port: Some(host_port.to_string()),
389            }]),
390        );
391
392        if let Some(vnc_port) = self.config.vnc_port {
393            port_bindings.insert(
394                format!("{}/tcp", Self::VNC_PORT_INTERNAL),
395                Some(vec![PortBinding {
396                    host_ip: Some(self.host.clone()),
397                    host_port: Some(vnc_port.to_string()),
398                }]),
399            );
400        }
401
402        // Prepare environment variables
403        let mode_str = match self.config.trading_mode {
404            TradingMode::Paper => "paper",
405            TradingMode::Live => "live",
406        };
407
408        let env = vec![
409            format!("TWS_USERID={}", self.username.expose_secret()),
410            format!("TWS_PASSWORD={}", self.password.expose_secret()),
411            format!("TRADING_MODE={}", mode_str),
412            format!(
413                "READ_ONLY_API={}",
414                if self.config.read_only_api {
415                    "yes"
416                } else {
417                    "no"
418                }
419            ),
420            "EXISTING_SESSION_DETECTED_ACTION=primary".to_string(),
421        ];
422
423        // Create container configuration
424        let container_config = ContainerCreateBody {
425            image: Some(self.config.container_image.clone()),
426            hostname: Some(self.container_name.clone()),
427            host_config: Some(HostConfig {
428                port_bindings: Some(port_bindings),
429                restart_policy: Some(RestartPolicy {
430                    name: Some(RestartPolicyNameEnum::ALWAYS),
431                    maximum_retry_count: None,
432                }),
433                ..Default::default()
434            }),
435            env: Some(env),
436            ..Default::default()
437        };
438
439        // Create container
440        let create_options = CreateContainerOptions {
441            name: Some(self.container_name.clone()),
442            ..Default::default()
443        };
444
445        let create_response: ContainerCreateResponse = self
446            .docker
447            .create_container(Some(create_options), container_config)
448            .await
449            .context("Failed to create container")?;
450
451        let container_id = create_response.id;
452
453        // Start container
454        self.docker
455            .start_container(&container_id, None::<StartContainerOptions>)
456            .await
457            .context("Failed to start container")?;
458
459        tracing::debug!(
460            "Container `{}` starting, waiting for ready",
461            self.container_name
462        );
463
464        // Wait for container to be ready
465        let wait_time = wait.unwrap_or(self.config.timeout);
466        let mut waited = 0u64;
467
468        while waited < wait_time {
469            if self.is_logged_in(&container_id).await.unwrap_or(false) {
470                tracing::debug!(
471                    "Gateway `{}` ready. VNC port is {:?}",
472                    self.container_name,
473                    self.config.vnc_port
474                );
475                return Ok(());
476            }
477
478            tracing::debug!("Waiting for IB Gateway to start");
479            tokio::time::sleep(Duration::from_secs(1)).await;
480            waited += 1;
481        }
482
483        anyhow::bail!(
484            "Gateway `{}` not ready after {} seconds",
485            self.container_name,
486            wait_time
487        )
488    }
489
490    /// Safely start the gateway, handling container already exists errors.
491    ///
492    /// # Errors
493    ///
494    /// Returns an error if startup fails (other than container exists).
495    pub async fn safe_start(&mut self, wait: Option<u64>) -> anyhow::Result<()> {
496        match self.start(wait).await {
497            Ok(()) => Ok(()),
498            Err(e) if e.to_string().contains("already exists") => {
499                tracing::warn!("Container already exists, continuing");
500                Ok(())
501            }
502            Err(e) => Err(e),
503        }
504    }
505
506    /// Stop and remove the gateway container.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if container stop or removal fails.
511    pub async fn stop(&self) -> anyhow::Result<()> {
512        let list_options = ListContainersOptions {
513            all: true,
514            ..Default::default()
515        };
516
517        let containers = self
518            .docker
519            .list_containers(Some(list_options))
520            .await
521            .context("Failed to list containers")?;
522
523        let container = containers.iter().find(|c| {
524            c.names
525                .as_ref()
526                .and_then(|names| names.first())
527                .is_some_and(|name| name.trim_start_matches('/') == self.container_name)
528        });
529
530        if let Some(container) = container
531            && let Some(container_id) = &container.id
532        {
533            // Stop container if running
534            if matches!(
535                container.state.as_ref().map(|state| state.as_ref()),
536                Some("running")
537            ) {
538                self.docker
539                    .stop_container(container_id, None::<StopContainerOptions>)
540                    .await
541                    .context("Failed to stop container")?;
542            }
543
544            // Remove container
545            let remove_options = RemoveContainerOptions {
546                force: true,
547                ..Default::default()
548            };
549
550            self.docker
551                .remove_container(container_id, Some(remove_options))
552                .await
553                .context("Failed to remove container")?;
554
555            tracing::debug!("Stopped and removed container `{}`", self.container_name);
556        }
557
558        Ok(())
559    }
560}
561
562/// Stub implementation when gateway feature is disabled.
563#[cfg(not(feature = "gateway"))]
564#[derive(Debug)]
565pub struct DockerizedIBGateway;
566
567#[cfg(not(feature = "gateway"))]
568impl DockerizedIBGateway {
569    /// # Errors
570    ///
571    /// Returns an error if the Dockerized IB Gateway cannot be created or started.
572    pub fn new(_config: DockerizedIBGatewayConfig) -> anyhow::Result<Self> {
573        anyhow::bail!("Gateway feature is not enabled. Build with --features gateway")
574    }
575}
576
577#[cfg(all(test, feature = "gateway"))]
578mod tests {
579    use jiff::Timestamp;
580    use nautilus_core::string::secret::SecretString;
581    use rstest::rstest;
582
583    use super::DockerizedIBGateway;
584    use crate::config::{DockerizedIBGatewayConfig, TradingMode};
585
586    #[rstest]
587    #[case::timeout_zero(0, None, "Timeout must be greater than 0")]
588    #[case::timeout_high(3601, None, "Timeout must be less than 3600 seconds")]
589    #[case::vnc_port(300, Some(5899), "VNC port must be between 5900 and 5999")]
590    fn test_constructor_rejects_invalid_config(
591        #[case] timeout: u64,
592        #[case] vnc_port: Option<u16>,
593        #[case] expected: &str,
594    ) {
595        let config = DockerizedIBGatewayConfig {
596            username: None,
597            password: None,
598            timeout,
599            vnc_port,
600            ..Default::default()
601        };
602
603        let error = DockerizedIBGateway::new(config).unwrap_err();
604
605        assert_eq!(error.to_string(), expected);
606    }
607
608    #[rstest]
609    #[case(TradingMode::Paper, 4002)]
610    #[case(TradingMode::Live, 4001)]
611    fn host_port_matches_trading_mode(#[case] trading_mode: TradingMode, #[case] expected: u16) {
612        assert_eq!(
613            DockerizedIBGateway::host_port_for_mode(trading_mode),
614            expected
615        );
616    }
617
618    #[rstest]
619    #[case(TradingMode::Paper, 4004)]
620    #[case(TradingMode::Live, 4003)]
621    fn container_port_matches_trading_mode(
622        #[case] trading_mode: TradingMode,
623        #[case] expected: u16,
624    ) {
625        assert_eq!(
626            DockerizedIBGateway::container_port_for_mode(trading_mode),
627            expected
628        );
629    }
630
631    #[rstest]
632    #[case(TradingMode::Paper, 4002)]
633    #[case(TradingMode::Live, 4001)]
634    fn new_reports_the_host_api_port(#[case] trading_mode: TradingMode, #[case] expected: u16) {
635        let gateway = DockerizedIBGateway::new(
636            DockerizedIBGatewayConfig::builder()
637                .username(SecretString::from("test-user"))
638                .password(SecretString::from("test-password"))
639                .trading_mode(trading_mode)
640                .build(),
641        )
642        .unwrap();
643
644        assert_eq!(gateway.port(), expected);
645        assert_eq!(gateway.username.expose_secret(), "test-user");
646        assert_eq!(gateway.password.expose_secret(), "test-password");
647        assert!(gateway.config.username.is_none());
648        assert!(gateway.config.password.is_none());
649    }
650
651    #[rstest]
652    #[case("Forking ::: Starting IBC Gateway", false)]
653    #[case("Started IB Gateway", false)]
654    #[case("Login has completed", true)]
655    #[case("Configuration tasks completed", true)]
656    #[case("Logged in to backend", true)]
657    #[case("Login successful", true)]
658    fn ready_log_markers_are_strict(#[case] logs: &str, #[case] expected: bool) {
659        assert_eq!(DockerizedIBGateway::logs_indicate_ready(logs), expected);
660    }
661
662    #[rstest]
663    #[case(
664        "2026-08-16T09:59:59Z Login has completed\n2026-08-16T10:00:01Z Started IB Gateway",
665        false
666    )]
667    #[case(
668        "2026-08-16T09:59:59Z Login has completed\n2026-08-16T10:00:01Z Login successful",
669        true
670    )]
671    #[case("2026-08-16T10:00:00Z Login has completed", true)]
672    fn readiness_uses_only_current_session_logs(#[case] logs: &str, #[case] expected: bool) {
673        let started_at = "2026-08-16T10:00:00Z".parse::<Timestamp>().unwrap();
674
675        assert_eq!(
676            DockerizedIBGateway::session_logs_indicate_ready(logs, started_at),
677            expected,
678        );
679    }
680}