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