Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
//! 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<dyn std::error::Error>>;
|
|
|
|
/// 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<CTraderConfig> {
|
|
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(())
|
|
}
|