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>
184 lines
5.3 KiB
Rust
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);
|
|
}
|
|
}
|