Files
foxhunt/ctrader-openapi/src/symbols.rs
jgrusewski 397ccb9f13 test(integration): rewrite broker integration tests for cTrader OpenAPI migration
Replace FIX-protocol-based integration tests with tests using real cTrader
types (ICMarketsConfig, TradingOrder, BrokerInterface). All 21 tests pass:
broker_failover (5), icmarkets_validation (10), order_lifecycle (6).

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

184 lines
5.3 KiB
Rust

//! Symbol name-to-ID resolution and metadata cache.
//!
//! Loads the symbol list from the cTrader server and provides cached lookups.
//! Note: `ProtoOaLightSymbol` only contains basic info (name, ID, enabled).
//! Full symbol details (digits, pip position, volumes) require `ProtoOASymbolByIdReq`.
use std::collections::HashMap;
use std::time::Duration;
use prost::Message;
use tracing::{debug, info};
use crate::dispatch::MessageDispatcher;
use crate::error::{CTraderError, Result};
use crate::proto::{self, ProtoMessage, ProtoOaLightSymbol};
/// Per-symbol metadata from the light symbol list.
#[derive(Debug, Clone)]
pub struct SymbolInfo {
/// cTrader symbol ID.
pub symbol_id: i64,
/// Human-readable symbol name (e.g. "EURUSD").
pub symbol_name: String,
/// Whether the symbol is enabled for trading.
pub enabled: bool,
/// Base asset ID.
pub base_asset_id: Option<i64>,
/// Quote asset ID.
pub quote_asset_id: Option<i64>,
}
/// Cached symbol mapper loaded from the server.
pub struct SymbolMapper {
/// Name → symbol info.
by_name: HashMap<String, SymbolInfo>,
/// ID → symbol info.
by_id: HashMap<i64, SymbolInfo>,
}
impl SymbolMapper {
/// Load the full symbol list from the server.
pub async fn load(
dispatcher: &MessageDispatcher,
account_id: i64,
timeout: Duration,
) -> Result<Self> {
info!("loading symbol list");
let req = proto::ProtoOaSymbolsListReq {
payload_type: Some(proto::PT_SYMBOLS_LIST_REQ as i32),
ctid_trader_account_id: account_id,
include_archived_symbols: Some(false),
};
let envelope = ProtoMessage {
payload_type: proto::PT_SYMBOLS_LIST_REQ,
payload: Some(req.encode_to_vec()),
client_msg_id: None,
};
let resp = dispatcher.send_request(envelope, timeout).await?;
if resp.payload_type != proto::PT_SYMBOLS_LIST_RES {
return Err(CTraderError::ProtocolError(format!(
"expected SymbolsListRes ({}), got {}",
proto::PT_SYMBOLS_LIST_RES,
resp.payload_type
)));
}
let list = proto::ProtoOaSymbolsListRes::decode(
resp.payload
.as_deref()
.ok_or_else(|| CTraderError::ProtocolError("empty SymbolsListRes".into()))?,
)?;
let mut by_name = HashMap::new();
let mut by_id = HashMap::new();
for sym in &list.symbol {
let info = symbol_info_from_light(sym);
if let Some(ref name) = sym.symbol_name {
by_name.insert(name.clone(), info.clone());
}
by_id.insert(info.symbol_id, info);
}
info!(count = by_name.len(), "symbol list loaded");
debug!(
sample = ?by_name.keys().take(5).collect::<Vec<_>>(),
"sample symbols"
);
Ok(Self { by_name, by_id })
}
/// Resolve a symbol name to its info.
pub fn resolve(&self, name: &str) -> Option<&SymbolInfo> {
self.by_name.get(name)
}
/// Resolve a symbol ID to its info.
pub fn resolve_id(&self, id: i64) -> Option<&SymbolInfo> {
self.by_id.get(&id)
}
/// Get the symbol ID for a given name.
pub fn symbol_id(&self, name: &str) -> Result<i64> {
self.by_name
.get(name)
.map(|s| s.symbol_id)
.ok_or_else(|| CTraderError::UnknownSymbol(name.into()))
}
/// Number of cached symbols.
pub fn len(&self) -> usize {
self.by_name.len()
}
/// Whether the cache is empty.
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
/// Resolve a symbol ID to its name.
pub fn symbol_name(&self, id: i64) -> Result<String> {
self.by_id
.get(&id)
.map(|s| s.symbol_name.clone())
.ok_or_else(|| CTraderError::UnknownSymbol(format!("id={id}")))
}
}
fn symbol_info_from_light(sym: &ProtoOaLightSymbol) -> SymbolInfo {
SymbolInfo {
symbol_id: sym.symbol_id,
symbol_name: sym.symbol_name.clone().unwrap_or_default(),
enabled: sym.enabled.unwrap_or(true),
base_asset_id: sym.base_asset_id,
quote_asset_id: sym.quote_asset_id,
}
}
// ── Volume helpers ─────────────────────────────────────────────────
/// Convert lots (e.g. 0.01) to cTrader volume in cents.
///
/// cTrader volumes are in units of 0.01 lot.
/// 1.00 lot = 100_000 volume units for standard forex.
pub fn lots_to_volume(lots: f64) -> i64 {
(lots * 100_000.0) as i64
}
/// Convert cTrader volume (cents) back to lots.
pub fn volume_to_lots(volume: i64) -> f64 {
volume as f64 / 100_000.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lots_roundtrip() {
let lots = 1.5;
let volume = lots_to_volume(lots);
assert_eq!(volume, 150_000);
let back = volume_to_lots(volume);
assert!((back - lots).abs() < 1e-10);
}
#[test]
fn micro_lot() {
let volume = lots_to_volume(0.01);
assert_eq!(volume, 1000);
}
#[test]
fn zero_lots() {
assert_eq!(lots_to_volume(0.0), 0);
assert!((volume_to_lots(0) - 0.0).abs() < 1e-10);
}
}