docs: add implementation plan for fxt broker check command
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 <noreply@anthropic.com>
This commit is contained in:
948
docs/plans/2026-03-02-fxt-broker-check-implementation.md
Normal file
948
docs/plans/2026-03-02-fxt-broker-check-implementation.md
Normal file
@@ -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<String>,
|
||||
|
||||
/// IB Gateway port (4002 = paper, 4001 = live)
|
||||
#[arg(long, env = "IBKR_PORT")]
|
||||
pub port: Option<u16>,
|
||||
|
||||
/// TWS client ID (must be unique per connection)
|
||||
#[arg(long, env = "IBKR_CLIENT_ID")]
|
||||
pub client_id: Option<i32>,
|
||||
|
||||
/// Broker Gateway gRPC URL
|
||||
#[arg(long, env = "BROKER_GATEWAY_URL")]
|
||||
pub gateway_url: Option<String>,
|
||||
|
||||
/// 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<bool> {
|
||||
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<bool> {
|
||||
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"
|
||||
```
|
||||
Reference in New Issue
Block a user