//! Integration tests against a real cTrader demo server. //! //! All tests are `#[ignore]` by default — they require live credentials //! via environment variables and network access to `demo.ctraderapi.com:5035`. //! //! # Required environment variables //! //! - `CTRADER_CLIENT_ID` — OAuth2 application client ID //! - `CTRADER_CLIENT_SECRET` — OAuth2 application client secret //! - `CTRADER_ACCESS_TOKEN` — OAuth2 access token //! - `CTRADER_ACCOUNT_ID` — cTID trader account ID //! //! # Running //! //! ```bash //! CTRADER_CLIENT_ID=xxx CTRADER_CLIENT_SECRET=xxx \ //! CTRADER_ACCESS_TOKEN=xxx CTRADER_ACCOUNT_ID=12345 \ //! cargo test -p ctrader-openapi --test demo_integration -- --ignored //! ``` #![deny(clippy::unwrap_used, clippy::expect_used)] use std::time::Duration; use ctrader_openapi::config::{CTraderConfig, CTraderEnvironment}; use ctrader_openapi::orders; use ctrader_openapi::proto::{ProtoOaOrderType, ProtoOaTradeSide}; use ctrader_openapi::CTraderClient; /// Convenience error type for integration tests. type TestResult = Result<(), Box>; /// Build a `CTraderConfig` from environment variables. /// /// Returns `None` if any required variable is missing or unparseable, /// which causes tests to skip gracefully. fn demo_config() -> Option { let client_id = std::env::var("CTRADER_CLIENT_ID").ok()?; let client_secret = std::env::var("CTRADER_CLIENT_SECRET").ok()?; let access_token = std::env::var("CTRADER_ACCESS_TOKEN").ok()?; let account_id: i64 = std::env::var("CTRADER_ACCOUNT_ID").ok()?.parse().ok()?; Some(CTraderConfig { client_id, client_secret, access_token, account_id, environment: CTraderEnvironment::Demo, heartbeat_interval_secs: 10, request_timeout_ms: 5000, max_reconnect_attempts: 3, }) } // ── Test 1: Connect and authenticate ──────────────────────────────── #[tokio::test] #[ignore] async fn test_demo_connect_and_authenticate() -> TestResult { let config = match demo_config() { Some(c) => c, None => { eprintln!("skipping: CTRADER_* env vars not set"); return Ok(()); } }; let account_id = config.account_id; let client = CTraderClient::connect(config).await?; // Verify we got a valid client with the right account ID assert_eq!(client.account_id(), account_id); // Symbol mapper must have been loaded during connect assert!( !client.symbols().is_empty(), "symbol list should not be empty after connect" ); Ok(()) } // ── Test 2: Load and verify symbols ───────────────────────────────── #[tokio::test] #[ignore] async fn test_demo_load_symbols() -> TestResult { let config = match demo_config() { Some(c) => c, None => { eprintln!("skipping: CTRADER_* env vars not set"); return Ok(()); } }; let client = CTraderClient::connect(config).await?; // Symbols are loaded during connect — verify EURUSD exists let eurusd_id = client.symbol_id("EURUSD"); assert!( eurusd_id.is_ok(), "EURUSD should be in the symbol list, got: {eurusd_id:?}" ); let eurusd_info = client.symbols().resolve("EURUSD"); assert!(eurusd_info.is_some(), "EURUSD should resolve to SymbolInfo"); if let Some(info) = eurusd_info { assert!(info.enabled, "EURUSD should be enabled for trading"); assert!(info.symbol_id > 0, "EURUSD symbol ID should be positive"); } // Verify total symbol count is reasonable (ICMarkets demo typically has 200+) assert!( client.symbols().len() > 10, "expected >10 symbols, got {}", client.symbols().len() ); Ok(()) } // ── Test 3: Account info ──────────────────────────────────────────── #[tokio::test] #[ignore] async fn test_demo_account_info() -> TestResult { let config = match demo_config() { Some(c) => c, None => { eprintln!("skipping: CTRADER_* env vars not set"); return Ok(()); } }; let client = CTraderClient::connect(config).await?; let info = client.get_account_info().await?; // Demo accounts have positive balance assert!( info.balance > 0, "demo account balance should be > 0, got {}", info.balance ); // Deposit asset ID should be valid assert!( info.deposit_asset_id > 0, "deposit_asset_id should be positive, got {}", info.deposit_asset_id ); // Registration timestamp should be non-zero assert!( info.registration_timestamp > 0, "registration_timestamp should be positive" ); eprintln!( "account info: balance={}, leverage=1:{}, deposit_asset={}", info.balance, info.leverage_in_cents / 100, info.deposit_asset_id ); Ok(()) } // ── Test 4: Submit and cancel a limit order ───────────────────────── #[tokio::test] #[ignore] async fn test_demo_submit_and_cancel_limit_order() -> TestResult { let config = match demo_config() { Some(c) => c, None => { eprintln!("skipping: CTRADER_* env vars not set"); return Ok(()); } }; let client = CTraderClient::connect(config).await?; // Subscribe to execution events before placing order let mut exec_rx = client.subscribe_executions(); // Submit a far-from-market BUY LIMIT on EURUSD at a very low price. // 0.01 lots = 1000 volume, price 0.50000 is far enough from market. let order_result = client .submit_order( "EURUSD", ProtoOaTradeSide::Buy, 1000, // 0.01 lots ProtoOaOrderType::Limit, // limit order Some(0.50000), // far-from-market limit price None, // no stop price None, // no stop loss None, // no take profit Some("foxhunt-demo-test".into()), ) .await; let order_response = match order_result { Ok(resp) => resp, Err(e) => { eprintln!("order submission failed (may be expected on some accounts): {e}"); return Ok(()); } }; // Try to extract the order ID from the execution event response let order_id = orders::extract_order_id(&order_response); eprintln!( "order response payload_type={}", order_response.payload_type ); // Also check the broadcast channel for the execution event let broadcast_event = tokio::time::timeout(Duration::from_secs(5), exec_rx.recv()).await; if let Ok(Ok(event)) = broadcast_event { if let Some(exec_info) = orders::parse_execution_event(&event) { eprintln!( "execution event: order_id={}, side={}, volume={}", exec_info.order_id, exec_info.trade_side, exec_info.volume ); } } // If we got an order ID, cancel it if let Some(oid) = order_id { eprintln!("cancelling order {oid}"); let cancel_result = client.cancel_order(oid).await; match cancel_result { Ok(_) => eprintln!("order {oid} cancelled successfully"), Err(e) => eprintln!("cancel failed (order may have been rejected): {e}"), } } else { // Even without an extracted order ID, reconcile to check eprintln!("could not extract order ID from response, checking via reconcile"); let reconcile = client.get_positions().await; if let Ok(r) = reconcile { eprintln!( "reconcile: {} positions, {} pending orders", r.positions.len(), r.orders.len() ); } } Ok(()) } // ── Test 5: Reconcile positions ───────────────────────────────────── #[tokio::test] #[ignore] async fn test_demo_reconcile_positions() -> TestResult { let config = match demo_config() { Some(c) => c, None => { eprintln!("skipping: CTRADER_* env vars not set"); return Ok(()); } }; let client = CTraderClient::connect(config).await?; let result = client.get_positions().await?; // Result should be valid (positions/orders may be empty on fresh demo) eprintln!( "reconcile result: {} open positions, {} pending orders", result.positions.len(), result.orders.len() ); // Verify positions have valid structure if any exist for pos in &result.positions { assert!(pos.position_id > 0, "position ID should be positive"); } // Verify orders have valid structure if any exist for ord in &result.orders { assert!(ord.order_id > 0, "order ID should be positive"); } Ok(()) } // ── Test 6: Spot subscription ─────────────────────────────────────── #[tokio::test] #[ignore] async fn test_demo_spot_subscription() -> TestResult { let config = match demo_config() { Some(c) => c, None => { eprintln!("skipping: CTRADER_* env vars not set"); return Ok(()); } }; let client = CTraderClient::connect(config).await?; // Subscribe to EURUSD spots let mut spot_rx = client.subscribe_spots(&["EURUSD"]).await?; // Wait for at least one tick within 10 seconds let tick_result = tokio::time::timeout(Duration::from_secs(10), spot_rx.recv()).await; match tick_result { Ok(Ok(msg)) => { assert_eq!( msg.payload_type, ctrader_openapi::proto::PT_SPOT_EVENT, "expected SpotEvent payload type" ); eprintln!("received spot tick (payload_type={})", msg.payload_type); } Ok(Err(e)) => { // Broadcast channel lagged or closed — acceptable in test eprintln!("spot rx error (may be lag): {e}"); } Err(_elapsed) => { // Market may be closed (weekend) — not a hard failure eprintln!("no spot tick received within 10s (market may be closed)"); } } // Unsubscribe cleanly let unsub_result = client.unsubscribe_spots(&["EURUSD"]).await; if let Err(e) = unsub_result { eprintln!("unsubscribe warning: {e}"); } Ok(()) }