From eefb191a62f958edcd59e63c0babf28354ce2994 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Mon, 2 Mar 2026 13:28:51 +0100 Subject: [PATCH] docs: add implementation plan for fxt broker check command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 tasks: deps → config → proto → broker.rs → main.rs wiring → lint → verify. TDD approach with exact file paths and commands. Co-Authored-By: Claude Opus 4.6 --- ...6-03-02-fxt-broker-check-implementation.md | 948 ++++++++++++++++++ 1 file changed, 948 insertions(+) create mode 100644 docs/plans/2026-03-02-fxt-broker-check-implementation.md diff --git a/docs/plans/2026-03-02-fxt-broker-check-implementation.md b/docs/plans/2026-03-02-fxt-broker-check-implementation.md new file mode 100644 index 000000000..bf5d2a80f --- /dev/null +++ b/docs/plans/2026-03-02-fxt-broker-check-implementation.md @@ -0,0 +1,948 @@ +# `fxt broker check` Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `fxt broker check` command that validates IBKR connectivity via direct ibapi handshake and broker-gateway gRPC health check. + +**Architecture:** New `broker` subcommand in the fxt CLI. Direct IB check uses `ibapi` crate (optional, feature-gated). gRPC check calls `BrokerGatewayService::HealthCheck` + `GetSessionStatus`. Config reads from `~/.foxhunt/config.toml` `[broker]` section with env var and CLI flag overrides. + +**Tech Stack:** Rust, clap 4 (derive), ibapi 1.2, tonic 0.14, tokio + +--- + +### Task 1: Add `ibapi` dependency and `broker-check` feature to fxt + +**Files:** +- Modify: `bin/fxt/Cargo.toml` + +**Step 1: Add ibapi optional dep and broker-check feature** + +In `bin/fxt/Cargo.toml`, add to `[dependencies]` section after the `common.workspace = true` line: + +```toml +# Broker connectivity check (optional, for direct IB Gateway validation) +ibapi = { workspace = true, optional = true } +``` + +In `[features]` section, change to: + +```toml +[features] +default = ["broker-check"] +# Test utilities feature for integration tests +test-utils = [] +# Direct IBKR connectivity check via ibapi crate +broker-check = ["dep:ibapi"] +``` + +**Step 2: Verify it compiles** + +Run: `SQLX_OFFLINE=true cargo check -p fxt` +Expected: PASS (ibapi is optional, nothing uses it yet) + +**Step 3: Commit** + +```bash +git add bin/fxt/Cargo.toml +git commit -m "feat(fxt): add ibapi optional dep behind broker-check feature" +``` + +--- + +### Task 2: Add `BrokerConfig` to `TliConfig` + +**Files:** +- Modify: `bin/fxt/src/config.rs` + +**Step 1: Write the failing test** + +Add to the existing `mod tests` block at the bottom of `bin/fxt/src/config.rs`: + +```rust +#[test] +fn test_config_with_broker_section() { + let toml_str = r#" + api_gateway_url = "http://localhost:50051" + + [broker] + gateway_url = "http://broker-gw:50060" + + [broker.ibkr] + host = "10.0.0.5" + port = 4002 + client_id = 42 + "#; + let config: TliConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.broker.gateway_url, "http://broker-gw:50060"); + assert_eq!(config.broker.ibkr.host, "10.0.0.5"); + assert_eq!(config.broker.ibkr.port, 4002); + assert_eq!(config.broker.ibkr.client_id, 42); +} + +#[test] +fn test_config_broker_defaults() { + let config: TliConfig = toml::from_str("").unwrap(); + assert_eq!(config.broker.gateway_url, "http://localhost:50060"); + assert_eq!(config.broker.ibkr.host, "127.0.0.1"); + assert_eq!(config.broker.ibkr.port, 4002); + assert_eq!(config.broker.ibkr.client_id, 99); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `SQLX_OFFLINE=true cargo test -p fxt --lib config::tests::test_config_with_broker_section` +Expected: FAIL — `TliConfig` has no `broker` field + +**Step 3: Add BrokerConfig structs and wire into TliConfig** + +Add these structs above the `TliConfig` struct in `bin/fxt/src/config.rs`: + +```rust +/// IBKR-specific connection settings +#[derive(Debug, Serialize, Deserialize)] +pub struct IbkrConfig { + /// IB Gateway host + #[serde(default = "default_ibkr_host")] + pub host: String, + /// IB Gateway port (4002 = paper, 4001 = live) + #[serde(default = "default_ibkr_port")] + pub port: u16, + /// TWS client ID (unique per connection) + #[serde(default = "default_ibkr_client_id")] + pub client_id: i32, +} + +fn default_ibkr_host() -> String { + "127.0.0.1".to_owned() +} +fn default_ibkr_port() -> u16 { + 4002 +} +fn default_ibkr_client_id() -> i32 { + 99 +} + +impl Default for IbkrConfig { + fn default() -> Self { + Self { + host: default_ibkr_host(), + port: default_ibkr_port(), + client_id: default_ibkr_client_id(), + } + } +} + +/// Broker connectivity settings +#[derive(Debug, Serialize, Deserialize)] +pub struct BrokerConfig { + /// Broker Gateway gRPC URL + #[serde(default = "default_broker_gateway_url")] + pub gateway_url: String, + /// IBKR-specific settings + #[serde(default)] + pub ibkr: IbkrConfig, +} + +fn default_broker_gateway_url() -> String { + "http://localhost:50060".to_owned() +} + +impl Default for BrokerConfig { + fn default() -> Self { + Self { + gateway_url: default_broker_gateway_url(), + ibkr: IbkrConfig::default(), + } + } +} +``` + +Add a `broker` field to `TliConfig`: + +```rust +/// Broker connectivity settings +#[serde(default)] +pub broker: BrokerConfig, +``` + +Update the `Default` impl for `TliConfig` to include `broker: BrokerConfig::default()`. + +**Step 4: Run tests to verify they pass** + +Run: `SQLX_OFFLINE=true cargo test -p fxt --lib config::tests` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +git add bin/fxt/src/config.rs +git commit -m "feat(fxt): add BrokerConfig section to TliConfig" +``` + +--- + +### Task 3: Copy broker_gateway.proto and compile in build.rs + +**Files:** +- Create: `bin/fxt/proto/broker_gateway.proto` (copy from `services/broker_gateway_service/proto/broker_gateway.proto`) +- Modify: `bin/fxt/build.rs` +- Modify: `bin/fxt/src/lib.rs` + +**Step 1: Copy the proto file** + +```bash +cp services/broker_gateway_service/proto/broker_gateway.proto bin/fxt/proto/broker_gateway.proto +``` + +**Step 2: Add broker_gateway.proto to build.rs** + +In `bin/fxt/build.rs`, add `"proto/broker_gateway.proto"` to the `compile_protos` list and add a `rerun-if-changed` line: + +```rust +.compile_protos( + &[ + "proto/trading.proto", + "proto/health.proto", + "proto/ml.proto", + "proto/config.proto", + "proto/ml_training.proto", + "proto/trading_agent.proto", + "proto/broker_gateway.proto", + ], + &["proto"], +)?; + +println!("cargo:rerun-if-changed=proto/broker_gateway.proto"); +``` + +**Step 3: Add broker_gateway proto module to lib.rs** + +In `bin/fxt/src/lib.rs`, inside the `pub mod proto { ... }` block, add: + +```rust +/// Broker Gateway service protobuf definitions +/// +/// Contains message types and service interfaces for the broker gateway, +/// including health checks, session status, and order routing. +#[allow(clippy::shadow_reuse, clippy::impl_trait_in_params, clippy::empty_structs_with_brackets)] +pub mod broker_gateway { + tonic::include_proto!("broker_gateway"); +} +``` + +**Step 4: Verify it compiles** + +Run: `SQLX_OFFLINE=true cargo check -p fxt` +Expected: PASS + +**Step 5: Commit** + +```bash +git add bin/fxt/proto/broker_gateway.proto bin/fxt/build.rs bin/fxt/src/lib.rs +git commit -m "feat(fxt): compile broker_gateway.proto for gRPC client stubs" +``` + +--- + +### Task 4: Create `broker.rs` command module — config resolution + check runner + +**Files:** +- Create: `bin/fxt/src/commands/broker.rs` +- Modify: `bin/fxt/src/commands/mod.rs` + +**Step 1: Write the failing test for config resolution** + +Create `bin/fxt/src/commands/broker.rs` with tests first: + +```rust +//! Broker connectivity check command +//! +//! Validates IBKR TWS/Gateway connectivity via: +//! 1. Direct ibapi handshake (feature-gated behind `broker-check`) +//! 2. Broker Gateway gRPC health check + +use anyhow::Result; +use clap::{Args, Subcommand}; +use colored::Colorize; +use std::time::Instant; + +use crate::config::TliConfig; + +/// Resolved broker check settings (CLI > env > config > defaults). +#[derive(Debug)] +pub struct ResolvedBrokerConfig { + pub ibkr_host: String, + pub ibkr_port: u16, + pub ibkr_client_id: i32, + pub gateway_url: String, + pub skip_ibkr: bool, + pub skip_gateway: bool, +} + +/// Broker command arguments +#[derive(Args, Debug)] +pub struct BrokerArgs { + #[command(subcommand)] + command: BrokerCommand, +} + +/// Broker subcommands +#[derive(Subcommand, Debug, Clone)] +pub enum BrokerCommand { + /// Check broker connectivity (IB Gateway + Broker Gateway gRPC) + #[clap( + long_about = "Validate IBKR TWS/Gateway connectivity.\n\n\ + Runs two checks:\n\ + 1. Direct IB Gateway: TCP connect + ibapi handshake + account summary\n\ + 2. Broker Gateway gRPC: HealthCheck + GetSessionStatus RPCs\n\n\ + Config precedence: CLI flags > env vars > ~/.foxhunt/config.toml > defaults\n\n\ + Examples:\n\ + fxt broker check\n\ + fxt broker check --host 10.0.0.5 --port 4002\n\ + fxt broker check --skip-ibkr\n\ + fxt broker check --skip-gateway" + )] + Check(CheckArgs), +} + +/// Arguments for `fxt broker check` +#[derive(Debug, Args, Clone)] +pub struct CheckArgs { + /// IB Gateway host + #[arg(long, env = "IBKR_HOST")] + pub host: Option, + + /// IB Gateway port (4002 = paper, 4001 = live) + #[arg(long, env = "IBKR_PORT")] + pub port: Option, + + /// TWS client ID (must be unique per connection) + #[arg(long, env = "IBKR_CLIENT_ID")] + pub client_id: Option, + + /// Broker Gateway gRPC URL + #[arg(long, env = "BROKER_GATEWAY_URL")] + pub gateway_url: Option, + + /// Skip direct IB Gateway check + #[arg(long, default_value_t = false)] + pub skip_ibkr: bool, + + /// Skip Broker Gateway gRPC check + #[arg(long, default_value_t = false)] + pub skip_gateway: bool, +} + +/// Resolve config: CLI flags > env vars (handled by clap) > config file > defaults. +pub fn resolve_config(args: &CheckArgs, config: &TliConfig) -> ResolvedBrokerConfig { + ResolvedBrokerConfig { + ibkr_host: args + .host + .clone() + .unwrap_or_else(|| config.broker.ibkr.host.clone()), + ibkr_port: args.port.unwrap_or(config.broker.ibkr.port), + ibkr_client_id: args.client_id.unwrap_or(config.broker.ibkr.client_id), + gateway_url: args + .gateway_url + .clone() + .unwrap_or_else(|| config.broker.gateway_url.clone()), + skip_ibkr: args.skip_ibkr, + skip_gateway: args.skip_gateway, + } +} + +/// Single check result +#[derive(Debug)] +pub struct CheckResult { + pub name: String, + pub passed: bool, + pub detail: String, + pub duration_ms: u128, +} + +/// Print a check result line +fn print_check(result: &CheckResult) { + let status = if result.passed { + "✓".green().bold() + } else { + "✗".red().bold() + }; + let dots = ".".repeat(24_usize.saturating_sub(result.name.len())); + println!( + " {} {} {} {}", + result.name, dots, status, result.detail + ); +} + +// ── IB Gateway direct check ────────────────────────────────────────── + +/// TCP connect probe (fail-fast before ibapi handshake). +async fn check_tcp_connect(host: &str, port: u16) -> CheckResult { + let addr = format!("{host}:{port}"); + let start = Instant::now(); + match tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::net::TcpStream::connect(&addr), + ) + .await + { + Ok(Ok(_)) => CheckResult { + name: "TCP connect".to_owned(), + passed: true, + detail: format!("{addr} ({}ms)", start.elapsed().as_millis()), + duration_ms: start.elapsed().as_millis(), + }, + Ok(Err(e)) => CheckResult { + name: "TCP connect".to_owned(), + passed: false, + detail: format!("{e} ({addr})"), + duration_ms: start.elapsed().as_millis(), + }, + Err(_) => CheckResult { + name: "TCP connect".to_owned(), + passed: false, + detail: format!("Timeout after 5s ({addr})"), + duration_ms: start.elapsed().as_millis(), + }, + } +} + +/// Full ibapi handshake (blocking, runs on spawn_blocking). +#[cfg(feature = "broker-check")] +async fn check_ibapi_handshake(host: &str, port: u16, client_id: i32) -> CheckResult { + let addr = format!("{host}:{port}"); + let start = Instant::now(); + match tokio::task::spawn_blocking(move || ibapi::Client::connect(&addr, client_id)).await { + Ok(Ok(client)) => { + let server_version = client.server_version(); + // client is dropped here which disconnects + CheckResult { + name: "ibapi handshake".to_owned(), + passed: true, + detail: format!( + "client_id={client_id}, server v{server_version} ({}ms)", + start.elapsed().as_millis() + ), + duration_ms: start.elapsed().as_millis(), + } + } + Ok(Err(e)) => CheckResult { + name: "ibapi handshake".to_owned(), + passed: false, + detail: format!("{e}"), + duration_ms: start.elapsed().as_millis(), + }, + Err(e) => CheckResult { + name: "ibapi handshake".to_owned(), + passed: false, + detail: format!("Task join error: {e}"), + duration_ms: start.elapsed().as_millis(), + }, + } +} + +#[cfg(not(feature = "broker-check"))] +async fn check_ibapi_handshake(_host: &str, _port: u16, _client_id: i32) -> CheckResult { + CheckResult { + name: "ibapi handshake".to_owned(), + passed: false, + detail: "Skipped (compile with --features broker-check)".to_owned(), + duration_ms: 0, + } +} + +// ── Broker Gateway gRPC check ──────────────────────────────────────── + +/// gRPC HealthCheck against broker_gateway_service. +async fn check_grpc_health(gateway_url: &str) -> CheckResult { + use crate::proto::broker_gateway::broker_gateway_service_client::BrokerGatewayServiceClient; + use crate::proto::broker_gateway::HealthCheckRequest; + + let start = Instant::now(); + let channel = match tonic::transport::Channel::from_shared(gateway_url.to_owned()) { + Ok(endpoint) => { + match tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.connect()).await + { + Ok(Ok(ch)) => ch, + Ok(Err(e)) => { + return CheckResult { + name: "Health check".to_owned(), + passed: false, + detail: format!("Connection failed: {e}"), + duration_ms: start.elapsed().as_millis(), + }; + } + Err(_) => { + return CheckResult { + name: "Health check".to_owned(), + passed: false, + detail: format!("Timeout after 5s ({gateway_url})"), + duration_ms: start.elapsed().as_millis(), + }; + } + } + } + Err(e) => { + return CheckResult { + name: "Health check".to_owned(), + passed: false, + detail: format!("Invalid URL: {e}"), + duration_ms: start.elapsed().as_millis(), + }; + } + }; + + let mut client = BrokerGatewayServiceClient::new(channel); + match client.health_check(HealthCheckRequest {}).await { + Ok(resp) => { + let inner = resp.into_inner(); + CheckResult { + name: "Health check".to_owned(), + passed: inner.healthy, + detail: format!( + "{} ({}ms)", + inner.message, + start.elapsed().as_millis() + ), + duration_ms: start.elapsed().as_millis(), + } + } + Err(e) => CheckResult { + name: "Health check".to_owned(), + passed: false, + detail: format!("{} ({})", e.code(), e.message()), + duration_ms: start.elapsed().as_millis(), + }, + } +} + +/// gRPC GetSessionStatus against broker_gateway_service. +async fn check_grpc_session(gateway_url: &str) -> CheckResult { + use crate::proto::broker_gateway::broker_gateway_service_client::BrokerGatewayServiceClient; + use crate::proto::broker_gateway::GetSessionStatusRequest; + + let start = Instant::now(); + let channel = match tonic::transport::Channel::from_shared(gateway_url.to_owned()) { + Ok(endpoint) => match endpoint.connect().await { + Ok(ch) => ch, + Err(e) => { + return CheckResult { + name: "Session status".to_owned(), + passed: false, + detail: format!("Connection failed: {e}"), + duration_ms: start.elapsed().as_millis(), + }; + } + }, + Err(e) => { + return CheckResult { + name: "Session status".to_owned(), + passed: false, + detail: format!("Invalid URL: {e}"), + duration_ms: start.elapsed().as_millis(), + }; + } + }; + + let mut client = BrokerGatewayServiceClient::new(channel); + match client + .get_session_status(GetSessionStatusRequest { session_id: None }) + .await + { + Ok(resp) => { + let inner = resp.into_inner(); + let state_name = match inner.state { + 0 => "DISCONNECTED", + 1 => "CONNECTED", + 2 => "LOGGING_IN", + 3 => "ACTIVE", + 4 => "LOGGING_OUT", + _ => "UNKNOWN", + }; + CheckResult { + name: "Session status".to_owned(), + passed: inner.state >= 1, // CONNECTED or better + detail: format!( + "{state_name} (seq: {}/{}, RTT: {:.1}ms)", + inner.sender_seq_num, inner.target_seq_num, inner.heartbeat_rtt_ms + ), + duration_ms: start.elapsed().as_millis(), + } + } + Err(e) => CheckResult { + name: "Session status".to_owned(), + passed: false, + detail: format!("{} ({})", e.code(), e.message()), + duration_ms: start.elapsed().as_millis(), + }, + } +} + +// ── Orchestrator ───────────────────────────────────────────────────── + +/// Run all broker checks and return overall pass/fail. +pub async fn run_broker_check(resolved: &ResolvedBrokerConfig) -> Result { + println!("{}", "Broker Connectivity Check".bold()); + println!("{}", "═".repeat(40)); + + let mut all_passed = true; + + // ── IB Gateway (direct) ── + if !resolved.skip_ibkr { + println!("{}", "IB Gateway (direct)".bold()); + let tcp = check_tcp_connect(&resolved.ibkr_host, resolved.ibkr_port).await; + print_check(&tcp); + + if tcp.passed { + let handshake = check_ibapi_handshake( + &resolved.ibkr_host, + resolved.ibkr_port, + resolved.ibkr_client_id, + ) + .await; + print_check(&handshake); + if !handshake.passed { + all_passed = false; + } + } else { + all_passed = false; + } + println!(); + } + + // ── Broker Gateway (gRPC) ── + if !resolved.skip_gateway { + println!("{}", "Broker Gateway (gRPC)".bold()); + let health = check_grpc_health(&resolved.gateway_url).await; + print_check(&health); + + if health.passed { + let session = check_grpc_session(&resolved.gateway_url).await; + print_check(&session); + if !session.passed { + all_passed = false; + } + } else { + all_passed = false; + } + println!(); + } + + // ── Summary ── + if all_passed { + println!("{}", "Result: All checks passed ✓".green().bold()); + } else { + println!("{}", "Result: One or more checks failed ✗".red().bold()); + } + + Ok(all_passed) +} + +/// Execute broker command (public interface for main.rs). +pub async fn execute_broker_command(args: BrokerArgs, config: &TliConfig) -> Result { + match args.command { + BrokerCommand::Check(check_args) => { + let resolved = resolve_config(&check_args, config); + run_broker_check(&resolved).await + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::config::TliConfig; + + #[test] + fn test_resolve_config_uses_defaults() { + let args = CheckArgs { + host: None, + port: None, + client_id: None, + gateway_url: None, + skip_ibkr: false, + skip_gateway: false, + }; + let config = TliConfig::default(); + let resolved = resolve_config(&args, &config); + + assert_eq!(resolved.ibkr_host, "127.0.0.1"); + assert_eq!(resolved.ibkr_port, 4002); + assert_eq!(resolved.ibkr_client_id, 99); + assert_eq!(resolved.gateway_url, "http://localhost:50060"); + assert!(!resolved.skip_ibkr); + assert!(!resolved.skip_gateway); + } + + #[test] + fn test_resolve_config_cli_overrides() { + let args = CheckArgs { + host: Some("10.0.0.5".to_owned()), + port: Some(4001), + client_id: Some(7), + gateway_url: Some("http://custom:9090".to_owned()), + skip_ibkr: true, + skip_gateway: false, + }; + let config = TliConfig::default(); + let resolved = resolve_config(&args, &config); + + assert_eq!(resolved.ibkr_host, "10.0.0.5"); + assert_eq!(resolved.ibkr_port, 4001); + assert_eq!(resolved.ibkr_client_id, 7); + assert_eq!(resolved.gateway_url, "http://custom:9090"); + assert!(resolved.skip_ibkr); + } + + #[test] + fn test_resolve_config_file_overrides_defaults() { + let args = CheckArgs { + host: None, + port: None, + client_id: None, + gateway_url: None, + skip_ibkr: false, + skip_gateway: false, + }; + let config: TliConfig = toml::from_str( + r#" + [broker] + gateway_url = "http://broker-gw:50060" + [broker.ibkr] + host = "192.168.1.100" + port = 4001 + client_id = 55 + "#, + ) + .unwrap(); + let resolved = resolve_config(&args, &config); + + assert_eq!(resolved.ibkr_host, "192.168.1.100"); + assert_eq!(resolved.ibkr_port, 4001); + assert_eq!(resolved.ibkr_client_id, 55); + assert_eq!(resolved.gateway_url, "http://broker-gw:50060"); + } + + #[tokio::test] + async fn test_tcp_connect_refused() { + // Connect to a port that's almost certainly not listening + let result = check_tcp_connect("127.0.0.1", 19999).await; + assert!(!result.passed); + assert_eq!(result.name, "TCP connect"); + } + + #[tokio::test] + async fn test_grpc_health_unreachable() { + let result = check_grpc_health("http://127.0.0.1:19998").await; + assert!(!result.passed); + assert_eq!(result.name, "Health check"); + } +} +``` + +**Step 2: Wire broker module into commands/mod.rs** + +In `bin/fxt/src/commands/mod.rs`, add: + +```rust +pub mod broker; +``` + +And add to the re-exports: + +```rust +pub use broker::{execute_broker_command, BrokerArgs}; +``` + +**Step 3: Verify tests pass** + +Run: `SQLX_OFFLINE=true cargo test -p fxt --lib commands::broker::tests` +Expected: ALL PASS (5 tests: 3 config resolution + 2 connectivity failure) + +**Step 4: Commit** + +```bash +git add bin/fxt/src/commands/broker.rs bin/fxt/src/commands/mod.rs +git commit -m "feat(fxt): add broker check command with config resolution and connectivity checks" +``` + +--- + +### Task 5: Wire `Broker` subcommand into main.rs + +**Files:** +- Modify: `bin/fxt/src/main.rs` + +**Step 1: Add Broker variant to Commands enum** + +In `bin/fxt/src/main.rs`, add the import: + +```rust +use fxt::commands::broker::{execute_broker_command, BrokerArgs}; +``` + +Add to the `Commands` enum (after the `Trade` variant): + +```rust +/// Broker connectivity check +#[clap( + long_about = "Validate broker connectivity.\n\n\ + Checks:\n\ + 1. Direct IB Gateway: TCP + ibapi handshake\n\ + 2. Broker Gateway gRPC: HealthCheck + SessionStatus\n\n\ + Examples:\n\ + fxt broker check\n\ + fxt broker check --host 10.0.0.5 --port 4002\n\ + fxt broker check --skip-ibkr" +)] +Broker { + #[command(flatten)] + broker_args: BrokerArgs, +}, +``` + +**Step 2: Add match arm in main()** + +In the `match cli.command { ... }` block in `main()`, add: + +```rust +Commands::Broker { broker_args } => { + let passed = execute_broker_command(broker_args, &config).await?; + if !passed { + std::process::exit(1); + } + Ok(()) +} +``` + +Note: `Broker` does NOT require JWT auth — it's an infrastructure check. + +**Step 3: Pass config to execute_broker_command** + +The `config` variable is already loaded at the top of `main()`. Pass it directly. + +**Step 4: Verify it compiles and CLI help works** + +Run: `SQLX_OFFLINE=true cargo build -p fxt` +Run: `./target/debug/fxt broker check --help` +Expected: Shows help with `--host`, `--port`, `--client-id`, `--gateway-url`, `--skip-ibkr`, `--skip-gateway` + +**Step 5: Add CLI parsing test** + +In the `mod tests` block at the bottom of `main.rs`, add: + +```rust +#[test] +fn test_cli_parsing_broker_check() { + let cli = Cli::parse_from(&["fxt", "broker", "check"]); + match cli.command { + Commands::Broker { .. } => {}, + _ => panic!("Expected Broker command"), + } +} + +#[test] +fn test_cli_parsing_broker_check_with_flags() { + let cli = Cli::parse_from(&[ + "fxt", + "broker", + "check", + "--host", + "10.0.0.5", + "--port", + "4001", + "--skip-gateway", + ]); + match cli.command { + Commands::Broker { .. } => {}, + _ => panic!("Expected Broker command"), + } +} +``` + +**Step 6: Run all fxt tests** + +Run: `SQLX_OFFLINE=true cargo test -p fxt --lib` +Expected: ALL PASS + +**Step 7: Commit** + +```bash +git add bin/fxt/src/main.rs +git commit -m "feat(fxt): wire broker check subcommand into CLI" +``` + +--- + +### Task 6: Suppress `unused_crate_dependencies` for ibapi + +**Files:** +- Modify: `bin/fxt/src/main.rs` +- Modify: `bin/fxt/src/lib.rs` + +Since `ibapi` is used only inside `cfg(feature)` blocks in `broker.rs`, Rust may warn about unused crate dependencies in the top-level `main.rs` or `lib.rs`. + +**Step 1: Add ibapi suppression** + +In `bin/fxt/src/main.rs`, in the section with all the `use X as _;` lines, add: + +```rust +#[cfg(feature = "broker-check")] +use ibapi as _; +``` + +**Step 2: Verify clean compile** + +Run: `SQLX_OFFLINE=true cargo check -p fxt 2>&1 | grep -i warning` +Expected: No ibapi-related warnings + +**Step 3: Run clippy** + +Run: `SQLX_OFFLINE=true cargo clippy -p fxt -- -D warnings` +Expected: PASS (0 errors, 0 warnings) + +**Step 4: Commit** + +```bash +git add bin/fxt/src/main.rs bin/fxt/src/lib.rs +git commit -m "chore(fxt): suppress unused_crate_dependencies for ibapi" +``` + +--- + +### Task 7: Full integration test and final verification + +**Files:** +- (none new — verification only) + +**Step 1: Run full workspace check** + +Run: `SQLX_OFFLINE=true cargo check --workspace` +Expected: PASS + +**Step 2: Run all fxt tests** + +Run: `SQLX_OFFLINE=true cargo test -p fxt --lib` +Expected: ALL PASS + +**Step 3: Run clippy on fxt** + +Run: `SQLX_OFFLINE=true cargo clippy -p fxt -- -D warnings` +Expected: 0 errors, 0 warnings + +**Step 4: Smoke test the binary** + +Run: `SQLX_OFFLINE=true cargo run -p fxt -- broker check --skip-ibkr --skip-gateway` +Expected: Prints header, skips both checks, exits 0 + +Run: `SQLX_OFFLINE=true cargo run -p fxt -- broker check --skip-gateway` +Expected: Attempts TCP connect to 127.0.0.1:4002, likely fails (no IB Gateway running locally), exits 1 + +**Step 5: Final commit (if any fixups needed)** + +```bash +git add -A +git commit -m "feat(fxt): fxt broker check command complete" +```