1#[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#[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 NoContainer = 1,
69 ContainerCreated = 2,
71 ContainerStarting = 3,
73 ContainerStopped = 4,
75 NotLoggedIn = 5,
77 Ready = 6,
79 Unknown = 7,
81}
82
83#[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 config: DockerizedIBGatewayConfig,
105 pub(crate) docker: Docker,
107 username: SecretString,
109 password: SecretString,
111 host: String,
113 port: u16,
115 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 pub const CONTAINER_NAME: &'static str = "nautilus-ib-gateway";
135
136 pub const HOST_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4002), ("Live", 4001)];
138
139 pub const CONTAINER_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4004), ("Live", 4003)];
141
142 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 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 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 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 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 pub fn container_name(&self) -> &str {
230 &self.container_name
231 }
232
233 pub fn host(&self) -> &str {
235 &self.host
236 }
237
238 pub fn port(&self) -> u16 {
240 self.port
241 }
242
243 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[cfg(not(feature = "gateway"))]
564#[derive(Debug)]
565pub struct DockerizedIBGateway;
566
567#[cfg(not(feature = "gateway"))]
568impl DockerizedIBGateway {
569 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}