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.core.nautilus_pyo3.interactive_brokers",
52 from_py_object
53 )
54)]
55pub enum ContainerStatus {
56 NoContainer = 1,
58 ContainerCreated = 2,
60 ContainerStarting = 3,
62 ContainerStopped = 4,
64 NotLoggedIn = 5,
66 Ready = 6,
68 Unknown = 7,
70}
71
72#[derive(Clone)]
77#[cfg_attr(
78 feature = "python",
79 pyo3::pyclass(
80 module = "nautilus_trader.core.nautilus_pyo3.interactive_brokers",
81 from_py_object
82 )
83)]
84#[cfg(feature = "gateway")]
85pub struct DockerizedIBGateway {
86 config: DockerizedIBGatewayConfig,
88 pub(crate) docker: Docker,
90 username: String,
92 password: String,
94 host: String,
96 port: u16,
98 container_name: String,
100}
101
102#[cfg(feature = "gateway")]
103impl Debug for DockerizedIBGateway {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct(stringify!(DockerizedIBGateway))
106 .field("host", &self.host)
107 .field("port", &self.port)
108 .field("container_name", &self.container_name)
109 .field("trading_mode", &self.config.trading_mode)
110 .finish_non_exhaustive()
111 }
112}
113
114#[cfg(feature = "gateway")]
115impl DockerizedIBGateway {
116 pub const CONTAINER_NAME: &'static str = "nautilus-ib-gateway";
118
119 pub const HOST_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4002), ("Live", 4001)];
121
122 pub const CONTAINER_PORTS: &'static [(&'static str, u16)] = &[("Paper", 4004), ("Live", 4003)];
124
125 pub const VNC_PORT_INTERNAL: u16 = 5900;
127
128 fn host_port_for_mode(trading_mode: crate::config::TradingMode) -> u16 {
129 match trading_mode {
130 crate::config::TradingMode::Paper => 4002,
131 crate::config::TradingMode::Live => 4001,
132 }
133 }
134
135 fn container_port_for_mode(trading_mode: crate::config::TradingMode) -> u16 {
136 match trading_mode {
137 crate::config::TradingMode::Paper => 4004,
138 crate::config::TradingMode::Live => 4003,
139 }
140 }
141
142 fn logs_indicate_ready(logs: &str) -> bool {
143 logs.contains("Login has completed")
144 || logs.contains("Configuration tasks completed")
145 || logs.contains("Logged in to")
146 || logs.contains("Login successful")
147 }
148
149 pub fn new(config: DockerizedIBGatewayConfig) -> anyhow::Result<Self> {
161 let username = config
163 .username
164 .clone()
165 .or_else(|| std::env::var("TWS_USERNAME").ok())
166 .ok_or_else(|| anyhow::anyhow!("username not set nor available in env TWS_USERNAME"))?;
167
168 let password = config
170 .password
171 .clone()
172 .or_else(|| std::env::var("TWS_PASSWORD").ok())
173 .ok_or_else(|| anyhow::anyhow!("password not set nor available in env TWS_PASSWORD"))?;
174
175 let docker = Docker::connect_with_local_defaults().context(
177 "Failed to connect to the local Docker daemon. Ensure Docker is running and the local Docker socket is available",
178 )?;
179
180 let mode_str = match config.trading_mode {
182 crate::config::TradingMode::Paper => "Paper",
183 crate::config::TradingMode::Live => "Live",
184 };
185 let port = Self::host_port_for_mode(config.trading_mode);
186
187 let container_name = format!("{}-{}", Self::CONTAINER_NAME, mode_str).to_lowercase();
189
190 Ok(Self {
191 config,
192 docker,
193 username,
194 password,
195 host: "127.0.0.1".to_string(),
196 port,
197 container_name,
198 })
199 }
200
201 pub fn container_name(&self) -> &str {
203 &self.container_name
204 }
205
206 pub fn host(&self) -> &str {
208 &self.host
209 }
210
211 pub fn port(&self) -> u16 {
213 self.port
214 }
215
216 pub async fn is_logged_in(&self, container_id: &str) -> anyhow::Result<bool> {
226 let logs_options = LogsOptions {
227 stdout: true,
228 stderr: true,
229 ..Default::default()
230 };
231
232 let mut logs_stream = self.docker.logs(container_id, Some(logs_options));
233
234 let mut logged_in = false;
235
236 while let Some(log_result) = logs_stream.next().await {
237 let log_output = log_result.context("Failed to read log chunk")?;
238 let log_bytes = match log_output {
240 LogOutput::StdOut { message } | LogOutput::StdErr { message } => message,
241 LogOutput::StdIn { message } | LogOutput::Console { message } => message,
242 };
243 let log_string = String::from_utf8_lossy(&log_bytes);
244 if Self::logs_indicate_ready(&log_string) {
245 logged_in = true;
246 break;
247 }
248 }
249
250 Ok(logged_in)
251 }
252
253 pub async fn container_status(&self) -> anyhow::Result<ContainerStatus> {
259 let list_options = ListContainersOptions {
260 all: true,
261 ..Default::default()
262 };
263 let containers = self
264 .docker
265 .list_containers(Some(list_options))
266 .await
267 .context("Failed to list containers")?;
268
269 let container = containers.iter().find(|c| {
270 c.names
271 .as_ref()
272 .and_then(|names| names.first())
273 .map(|name| name.trim_start_matches('/') == self.container_name)
274 .unwrap_or(false)
275 });
276
277 let Some(container) = container else {
278 return Ok(ContainerStatus::NoContainer);
279 };
280
281 let state = container
282 .state
283 .as_ref()
284 .map(|state| state.as_ref())
285 .unwrap_or("unknown");
286
287 match state {
288 "running" => {
289 let container_id = container
290 .id
291 .as_ref()
292 .ok_or_else(|| anyhow::anyhow!("Container ID missing"))?;
293
294 if self.is_logged_in(container_id).await.unwrap_or(false) {
295 Ok(ContainerStatus::Ready)
296 } else {
297 Ok(ContainerStatus::ContainerStarting)
298 }
299 }
300 "stopped" | "exited" => Ok(ContainerStatus::ContainerStopped),
301 "created" => Ok(ContainerStatus::ContainerCreated),
302 _ => Ok(ContainerStatus::Unknown),
303 }
304 }
305
306 pub async fn start(&mut self, wait: Option<u64>) -> anyhow::Result<()> {
316 tracing::debug!("Ensuring gateway is running");
317
318 let status = self.container_status().await?;
319
320 let broken_statuses = [
321 ContainerStatus::NotLoggedIn,
322 ContainerStatus::ContainerStopped,
323 ContainerStatus::ContainerCreated,
324 ContainerStatus::Unknown,
325 ];
326
327 match status {
328 ContainerStatus::NoContainer => {
329 tracing::debug!("No container, starting");
330 }
331 status if broken_statuses.contains(&status) => {
332 tracing::debug!("Status {:?}, removing existing container", status);
333 self.stop().await?;
334 }
335 ContainerStatus::Ready | ContainerStatus::ContainerStarting => {
336 tracing::debug!("Status {:?}, using existing container", status);
337 return Ok(());
338 }
339 _ => {}
340 }
341
342 tracing::debug!("Starting new container");
343
344 let host_port = Self::host_port_for_mode(self.config.trading_mode);
346 let container_port = Self::container_port_for_mode(self.config.trading_mode);
347
348 let mut port_bindings = HashMap::new();
349 port_bindings.insert(
350 format!("{}/tcp", container_port),
351 Some(vec![PortBinding {
352 host_ip: Some(self.host.clone()),
353 host_port: Some(host_port.to_string()),
354 }]),
355 );
356
357 if let Some(vnc_port) = self.config.vnc_port {
358 port_bindings.insert(
359 format!("{}/tcp", Self::VNC_PORT_INTERNAL),
360 Some(vec![PortBinding {
361 host_ip: Some(self.host.clone()),
362 host_port: Some(vnc_port.to_string()),
363 }]),
364 );
365 }
366
367 let mode_str = match self.config.trading_mode {
369 crate::config::TradingMode::Paper => "paper",
370 crate::config::TradingMode::Live => "live",
371 };
372 let env = vec![
373 format!("TWS_USERID={}", self.username),
374 format!("TWS_PASSWORD={}", self.password),
375 format!("TRADING_MODE={}", mode_str),
376 format!(
377 "READ_ONLY_API={}",
378 if self.config.read_only_api {
379 "yes"
380 } else {
381 "no"
382 }
383 ),
384 "EXISTING_SESSION_DETECTED_ACTION=primary".to_string(),
385 ];
386
387 let container_config = ContainerCreateBody {
389 image: Some(self.config.container_image.clone()),
390 hostname: Some(self.container_name.clone()),
391 host_config: Some(HostConfig {
392 port_bindings: Some(port_bindings),
393 restart_policy: Some(RestartPolicy {
394 name: Some(RestartPolicyNameEnum::ALWAYS),
395 maximum_retry_count: None,
396 }),
397 ..Default::default()
398 }),
399 env: Some(env),
400 ..Default::default()
401 };
402
403 let create_options = CreateContainerOptions {
405 name: Some(self.container_name.clone()),
406 ..Default::default()
407 };
408
409 let create_response: ContainerCreateResponse = self
410 .docker
411 .create_container(Some(create_options), container_config)
412 .await
413 .context("Failed to create container")?;
414
415 let container_id = create_response.id;
416
417 self.docker
419 .start_container(&container_id, None::<StartContainerOptions>)
420 .await
421 .context("Failed to start container")?;
422
423 tracing::debug!(
424 "Container `{}` starting, waiting for ready",
425 self.container_name
426 );
427
428 let wait_time = wait.unwrap_or(self.config.timeout);
430 let mut waited = 0u64;
431
432 while waited < wait_time {
433 if self.is_logged_in(&container_id).await.unwrap_or(false) {
434 tracing::debug!(
435 "Gateway `{}` ready. VNC port is {:?}",
436 self.container_name,
437 self.config.vnc_port
438 );
439 return Ok(());
440 }
441
442 tracing::debug!("Waiting for IB Gateway to start");
443 tokio::time::sleep(Duration::from_secs(1)).await;
444 waited += 1;
445 }
446
447 anyhow::bail!(
448 "Gateway `{}` not ready after {} seconds",
449 self.container_name,
450 wait_time
451 )
452 }
453
454 pub async fn safe_start(&mut self, wait: Option<u64>) -> anyhow::Result<()> {
464 match self.start(wait).await {
465 Ok(()) => Ok(()),
466 Err(e) if e.to_string().contains("already exists") => {
467 tracing::warn!("Container already exists, continuing");
468 Ok(())
469 }
470 Err(e) => Err(e),
471 }
472 }
473
474 pub async fn stop(&self) -> anyhow::Result<()> {
480 let list_options = ListContainersOptions {
481 all: true,
482 ..Default::default()
483 };
484 let containers = self
485 .docker
486 .list_containers(Some(list_options))
487 .await
488 .context("Failed to list containers")?;
489
490 let container = containers.iter().find(|c| {
491 c.names
492 .as_ref()
493 .and_then(|names| names.first())
494 .map(|name| name.trim_start_matches('/') == self.container_name)
495 .unwrap_or(false)
496 });
497
498 if let Some(container) = container {
499 if let Some(container_id) = &container.id {
500 if matches!(
502 container.state.as_ref().map(|state| state.as_ref()),
503 Some("running")
504 ) {
505 self.docker
506 .stop_container(container_id, None::<StopContainerOptions>)
507 .await
508 .context("Failed to stop container")?;
509 }
510
511 let remove_options = RemoveContainerOptions {
513 force: true,
514 ..Default::default()
515 };
516
517 self.docker
518 .remove_container(container_id, Some(remove_options))
519 .await
520 .context("Failed to remove container")?;
521
522 tracing::debug!("Stopped and removed container `{}`", self.container_name);
523 }
524 }
525
526 Ok(())
527 }
528}
529
530#[cfg(not(feature = "gateway"))]
532#[derive(Debug)]
533pub struct DockerizedIBGateway;
534
535#[cfg(not(feature = "gateway"))]
536impl DockerizedIBGateway {
537 pub fn new(_config: crate::config::DockerizedIBGatewayConfig) -> anyhow::Result<Self> {
541 anyhow::bail!("Gateway feature is not enabled. Build with --features gateway")
542 }
543}
544
545#[cfg(all(test, feature = "gateway"))]
546mod tests {
547 use rstest::rstest;
548
549 use super::DockerizedIBGateway;
550 use crate::config::TradingMode;
551
552 #[rstest]
553 #[case(TradingMode::Paper, 4002)]
554 #[case(TradingMode::Live, 4001)]
555 fn host_port_matches_trading_mode(#[case] trading_mode: TradingMode, #[case] expected: u16) {
556 assert_eq!(
557 DockerizedIBGateway::host_port_for_mode(trading_mode),
558 expected
559 );
560 }
561
562 #[rstest]
563 #[case(TradingMode::Paper, 4004)]
564 #[case(TradingMode::Live, 4003)]
565 fn container_port_matches_trading_mode(
566 #[case] trading_mode: TradingMode,
567 #[case] expected: u16,
568 ) {
569 assert_eq!(
570 DockerizedIBGateway::container_port_for_mode(trading_mode),
571 expected
572 );
573 }
574
575 #[rstest]
576 #[case(TradingMode::Paper, 4002)]
577 #[case(TradingMode::Live, 4001)]
578 fn new_reports_the_host_api_port(#[case] trading_mode: TradingMode, #[case] expected: u16) {
579 let gateway = DockerizedIBGateway::new(
580 crate::config::DockerizedIBGatewayConfig::builder()
581 .username("test-user".to_string())
582 .password("test-password".to_string())
583 .trading_mode(trading_mode)
584 .build(),
585 )
586 .unwrap();
587
588 assert_eq!(gateway.port(), expected);
589 }
590
591 #[rstest]
592 #[case("Forking ::: Starting IBC Gateway", false)]
593 #[case("Started IB Gateway", false)]
594 #[case("Login has completed", true)]
595 #[case("Configuration tasks completed", true)]
596 #[case("Logged in to backend", true)]
597 #[case("Login successful", true)]
598 fn ready_log_markers_are_strict(#[case] logs: &str, #[case] expected: bool) {
599 assert_eq!(DockerizedIBGateway::logs_indicate_ready(logs), expected);
600 }
601}