Files
foxhunt/ctrader-openapi/src/client.rs
jgrusewski 2c3a070a9b feat(ctrader-openapi): complete client API with orders, symbols, account, market data
Add rate limiter (50/s + 5/s historical), symbol mapper, order builders,
account queries, market data subscriptions, and high-level CTraderClient
that orchestrates the full connect/auth/symbol-load lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:23:30 +01:00

288 lines
8.8 KiB
Rust

//! High-level cTrader Open API client.
//!
//! `CTraderClient` wraps the connection, dispatcher, authenticator,
//! rate limiter, and symbol mapper into a single convenient API.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tracing::{debug, info};
use crate::account::{self, ReconcileResult, TraderInfo};
use crate::auth;
use crate::config::CTraderConfig;
use crate::connection::CTraderConnection;
use crate::dispatch::MessageDispatcher;
use crate::error::{CTraderError, RateLimitBucket, Result};
use crate::market_data;
use crate::orders;
use crate::proto::{self, ProtoMessage, ProtoOaOrderType, ProtoOaTradeSide};
use crate::rate_limiter::RateLimiter;
use crate::symbols::SymbolMapper;
/// High-level cTrader client that manages the full lifecycle.
pub struct CTraderClient {
/// Message dispatcher (owns connection + reader task).
dispatcher: Arc<MessageDispatcher>,
/// Rate limiter for request throttling.
rate_limiter: RateLimiter,
/// Cached symbol mapper.
symbols: SymbolMapper,
/// Account configuration.
config: CTraderConfig,
/// Default request timeout.
request_timeout: Duration,
}
impl CTraderClient {
/// Connect to cTrader, authenticate, and load symbols.
///
/// Full sequence: TCP → TLS → Auth → Heartbeat → Dispatcher → Symbol load.
pub async fn connect(config: CTraderConfig) -> Result<Self> {
let request_timeout = Duration::from_millis(config.request_timeout_ms);
info!(
env = ?config.environment,
account_id = config.account_id,
"connecting to cTrader"
);
// 1. TCP + TLS connection (no heartbeat yet)
let mut conn = CTraderConnection::connect(&config).await?;
// 2. Protobuf authentication (app + account)
// Uses conn.recv() directly before the dispatcher takes over.
auth::authenticate(&mut conn, &config).await?;
// 3. Start heartbeat after successful auth
conn.start_heartbeat(Duration::from_secs(config.heartbeat_interval_secs));
// 4. Create dispatcher (takes ownership of the receiver stream)
let dispatcher = Arc::new(MessageDispatcher::new(conn));
// 5. Load symbol list
let symbols =
SymbolMapper::load(&dispatcher, config.account_id, request_timeout).await?;
info!(symbols = symbols.len(), "client ready");
Ok(Self {
dispatcher,
rate_limiter: RateLimiter::new(),
symbols,
config,
request_timeout,
})
}
/// Submit a new order.
#[allow(clippy::too_many_arguments)]
pub async fn submit_order(
&self,
symbol: &str,
side: ProtoOaTradeSide,
volume: i64,
order_type: ProtoOaOrderType,
limit_price: Option<f64>,
stop_price: Option<f64>,
stop_loss: Option<f64>,
take_profit: Option<f64>,
comment: Option<String>,
) -> Result<ProtoMessage> {
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
let symbol_id = self.symbols.symbol_id(symbol)?;
let msg = orders::new_order(
self.config.account_id,
symbol_id,
side,
volume,
order_type,
limit_price,
stop_price,
stop_loss,
take_profit,
comment,
);
let resp = self
.dispatcher
.send_request(msg, self.request_timeout)
.await?;
// Check for order error event
if resp.payload_type == proto::PT_ORDER_ERROR_EVENT {
let desc = if let Some(ref payload) = resp.payload {
prost::Message::decode(payload.as_slice())
.map(|e: proto::ProtoOaOrderErrorEvent| {
e.description.unwrap_or_default()
})
.unwrap_or_else(|_| "unknown order error".into())
} else {
"unknown order error".into()
};
return Err(CTraderError::ServerError {
code: proto::PT_ORDER_ERROR_EVENT.to_string(),
description: desc,
});
}
debug!(payload_type = resp.payload_type, "order response received");
Ok(resp)
}
/// Cancel a pending order by cTrader order ID.
pub async fn cancel_order(&self, order_id: i64) -> Result<ProtoMessage> {
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
let msg = orders::cancel_order(self.config.account_id, order_id);
self.dispatcher
.send_request(msg, self.request_timeout)
.await
}
/// Amend an existing order.
#[allow(clippy::too_many_arguments)]
pub async fn amend_order(
&self,
order_id: i64,
volume: Option<i64>,
limit_price: Option<f64>,
stop_price: Option<f64>,
stop_loss: Option<f64>,
take_profit: Option<f64>,
) -> Result<ProtoMessage> {
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
let msg = orders::amend_order(
self.config.account_id,
order_id,
volume,
limit_price,
stop_price,
stop_loss,
take_profit,
);
self.dispatcher
.send_request(msg, self.request_timeout)
.await
}
/// Close an open position.
pub async fn close_position(&self, position_id: i64, volume: i64) -> Result<ProtoMessage> {
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
let msg = orders::close_position(self.config.account_id, position_id, volume);
self.dispatcher
.send_request(msg, self.request_timeout)
.await
}
/// Get account trader information (balance, leverage, etc.).
pub async fn get_account_info(&self) -> Result<TraderInfo> {
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
account::get_trader_info(&self.dispatcher, self.config.account_id, self.request_timeout)
.await
}
/// Get open positions and pending orders via reconciliation.
pub async fn get_positions(&self) -> Result<ReconcileResult> {
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
account::reconcile(&self.dispatcher, self.config.account_id, self.request_timeout).await
}
/// Subscribe to execution events (fills, cancels, etc.).
pub fn subscribe_executions(&self) -> broadcast::Receiver<ProtoMessage> {
self.dispatcher.subscribe_executions()
}
/// Subscribe to spot price events for the given symbols.
pub async fn subscribe_spots(
&self,
symbol_names: &[&str],
) -> Result<broadcast::Receiver<ProtoMessage>> {
let mut symbol_ids = Vec::with_capacity(symbol_names.len());
for name in symbol_names {
symbol_ids.push(self.symbols.symbol_id(name)?);
}
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
market_data::subscribe_spots(
&self.dispatcher,
self.config.account_id,
&symbol_ids,
self.request_timeout,
)
.await?;
Ok(self.dispatcher.subscribe_spots())
}
/// Unsubscribe from spot price events.
pub async fn unsubscribe_spots(&self, symbol_names: &[&str]) -> Result<()> {
let mut symbol_ids = Vec::with_capacity(symbol_names.len());
for name in symbol_names {
symbol_ids.push(self.symbols.symbol_id(name)?);
}
self.rate_limiter
.acquire(RateLimitBucket::NonHistorical)
.await?;
market_data::unsubscribe_spots(
&self.dispatcher,
self.config.account_id,
&symbol_ids,
self.request_timeout,
)
.await
}
/// Subscribe to error events.
pub fn subscribe_errors(&self) -> broadcast::Receiver<ProtoMessage> {
self.dispatcher.subscribe_errors()
}
/// Resolve a symbol name to its ID.
pub fn symbol_id(&self, name: &str) -> Result<i64> {
self.symbols.symbol_id(name)
}
/// Get the symbol mapper for advanced lookups.
pub fn symbols(&self) -> &SymbolMapper {
&self.symbols
}
/// Get the account ID.
pub fn account_id(&self) -> i64 {
self.config.account_id
}
}
impl std::fmt::Debug for CTraderClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CTraderClient")
.field("environment", &self.config.environment)
.field("account_id", &self.config.account_id)
.finish()
}
}