nautilus_interactive_brokers/gateway/
dockerized.rs1#[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#[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 NoContainer = 1,
65 ContainerCreated = 2,
67 ContainerStarting = 3,
69 ContainerStopped = 4,
71 NotLoggedIn = 5,
73 Ready = 6,
75 Unknown = 7,
77}
78
79#[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 config: DockerizedIBGatewayConfig,
101 pub(crate) docker: Docker,
103 username: String,
105 password: String,
107 host: String,
109 port: u16,
111 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 pub const CONTAINER_NAME: &'static str = "nautilus-ib-gateway";
131
132 pub const HOST_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4002), ("Live", 4001)];
134
135 pub const CONTAINER_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4004), ("Live", 4003)];
137
138 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 pub fn new(config: DockerizedIBGatewayConfig) -> anyhow::Result<Self> {
174 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 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 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 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 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 pub fn container_name(&self) -> &str {
216 &self.container_name
217 }
218
219 pub fn host(&self) -> &str {
221 &self.host
222 }
223
224 pub fn port(&self) -> u16 {
226 self.port
227 }
228
229 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[cfg(not(feature = "gateway"))]
545#[derive(Debug)]
546pub struct DockerizedIBGateway;
547
548#[cfg(not(feature = "gateway"))]
549impl DockerizedIBGateway {
550 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}