use crate::core::types::*;
use crate::core::{ExchangeError, ExchangeResult};
#[derive(Debug, Clone)]
pub struct RawTick {
pub time_offset_ms: u32,
pub ask_raw: u32,
pub bid_raw: u32,
pub ask_volume: f32,
pub bid_volume: f32,
}
#[derive(Debug, Clone)]
pub struct DukascopyTick {
pub time: i64,
pub bid: f64,
pub ask: f64,
pub bid_volume: f64,
pub ask_volume: f64,
}
pub struct DukascopyParser;
impl DukascopyParser {
pub fn parse_binary_ticks(
data: &[u8],
hour_start_ms: i64,
point_value: f64,
) -> ExchangeResult<Vec<DukascopyTick>> {
const RECORD_SIZE: usize = 20;
if !data.len().is_multiple_of(RECORD_SIZE) {
return Err(ExchangeError::Parse(format!(
"Invalid data length: {} (must be multiple of {})",
data.len(),
RECORD_SIZE
)));
}
let mut ticks = Vec::with_capacity(data.len() / RECORD_SIZE);
for chunk in data.chunks_exact(RECORD_SIZE) {
let raw = Self::parse_raw_tick(chunk)?;
let tick = Self::raw_tick_to_dukascopy_tick(raw, hour_start_ms, point_value);
ticks.push(tick);
}
Ok(ticks)
}
fn parse_raw_tick(bytes: &[u8]) -> ExchangeResult<RawTick> {
if bytes.len() != 20 {
return Err(ExchangeError::Parse(format!(
"Invalid tick record size: {} (expected 20)",
bytes.len()
)));
}
let time_offset_ms = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let ask_raw = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let bid_raw = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let ask_volume = f32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]);
let bid_volume = f32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
Ok(RawTick {
time_offset_ms,
ask_raw,
bid_raw,
ask_volume,
bid_volume,
})
}
fn raw_tick_to_dukascopy_tick(
raw: RawTick,
hour_start_ms: i64,
point_value: f64,
) -> DukascopyTick {
DukascopyTick {
time: hour_start_ms + raw.time_offset_ms as i64,
bid: raw.bid_raw as f64 * point_value,
ask: raw.ask_raw as f64 * point_value,
bid_volume: raw.bid_volume as f64,
ask_volume: raw.ask_volume as f64,
}
}
pub fn ticks_to_kline(
ticks: &[DukascopyTick],
open_time: i64,
) -> ExchangeResult<Kline> {
if ticks.is_empty() {
return Err(ExchangeError::Parse("No ticks to build kline".to_string()));
}
let first_mid = (ticks[0].bid + ticks[0].ask) / 2.0;
let last_mid = (ticks[ticks.len() - 1].bid + ticks[ticks.len() - 1].ask) / 2.0;
let mut high = f64::MIN;
let mut low = f64::MAX;
let mut total_volume = 0.0;
for tick in ticks {
let mid = (tick.bid + tick.ask) / 2.0;
high = high.max(mid);
low = low.min(mid);
total_volume += tick.bid_volume + tick.ask_volume;
}
Ok(Kline {
open_time,
open: first_mid,
high,
low,
close: last_mid,
volume: total_volume,
quote_volume: None,
close_time: Some(ticks[ticks.len() - 1].time),
trades: Some(ticks.len() as u64),
})
}
pub fn ticks_to_klines(
ticks: &[DukascopyTick],
interval_ms: i64,
) -> ExchangeResult<Vec<Kline>> {
if ticks.is_empty() {
return Ok(Vec::new());
}
let mut klines = Vec::new();
let mut bucket_ticks = Vec::new();
let mut current_bucket_start = (ticks[0].time / interval_ms) * interval_ms;
for tick in ticks {
let tick_bucket = (tick.time / interval_ms) * interval_ms;
if tick_bucket != current_bucket_start {
if !bucket_ticks.is_empty() {
klines.push(Self::ticks_to_kline(&bucket_ticks, current_bucket_start)?);
bucket_ticks.clear();
}
current_bucket_start = tick_bucket;
}
bucket_ticks.push(tick.clone());
}
if !bucket_ticks.is_empty() {
klines.push(Self::ticks_to_kline(&bucket_ticks, current_bucket_start)?);
}
Ok(klines)
}
pub fn tick_to_ticker(tick: &DukascopyTick, symbol: &str) -> Ticker {
Ticker {
symbol: symbol.to_string(),
last_price: (tick.bid + tick.ask) / 2.0,
bid_price: Some(tick.bid),
ask_price: Some(tick.ask),
high_24h: None, low_24h: None, volume_24h: Some(tick.bid_volume + tick.ask_volume),
quote_volume_24h: None,
price_change_24h: None,
price_change_percent_24h: None,
timestamp: tick.time,
}
}
pub fn get_latest_price(ticks: &[DukascopyTick]) -> ExchangeResult<f64> {
ticks
.last()
.map(|tick| (tick.bid + tick.ask) / 2.0)
.ok_or_else(|| ExchangeError::Parse("No ticks available".to_string()))
}
pub fn parse_interval_to_ms(interval: &str) -> ExchangeResult<i64> {
let interval_lower = interval.to_lowercase();
let ms = match interval_lower.as_str() {
"1m" | "1min" => 60_000,
"5m" | "5min" => 300_000,
"10m" | "10min" => 600_000,
"15m" | "15min" => 900_000,
"30m" | "30min" => 1_800_000,
"1h" | "1hour" => 3_600_000,
"4h" | "4hour" => 14_400_000,
"1d" | "1day" => 86_400_000,
_ => {
return Err(ExchangeError::Parse(format!(
"Unsupported interval: {}",
interval
)))
}
};
Ok(ms)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_raw_tick() {
let bytes: [u8; 20] = [
0x00, 0x00, 0x00, 0x64, 0x00, 0x01, 0xB5, 0x07, 0x00, 0x01, 0xB4, 0xF3, 0x49, 0x74, 0x24, 0x00, 0x49, 0x74, 0x24, 0x00, ];
let raw = DukascopyParser::parse_raw_tick(&bytes).unwrap();
assert_eq!(raw.time_offset_ms, 100);
assert_eq!(raw.ask_raw, 111879);
assert_eq!(raw.bid_raw, 111859);
}
#[test]
fn test_raw_tick_to_dukascopy_tick() {
let raw = RawTick {
time_offset_ms: 1000,
ask_raw: 112347,
bid_raw: 112345,
ask_volume: 1500000.0,
bid_volume: 1200000.0,
};
let hour_start_ms = 1234567800000; let point_value = 0.00001;
let tick = DukascopyParser::raw_tick_to_dukascopy_tick(raw, hour_start_ms, point_value);
assert_eq!(tick.time, 1234567801000); assert!((tick.ask - 1.12347).abs() < 0.00001);
assert!((tick.bid - 1.12345).abs() < 0.00001);
assert_eq!(tick.ask_volume, 1500000.0);
assert_eq!(tick.bid_volume, 1200000.0);
}
#[test]
fn test_ticks_to_kline() {
let ticks = vec![
DukascopyTick {
time: 1000,
bid: 1.1000,
ask: 1.1002,
bid_volume: 100.0,
ask_volume: 100.0,
},
DukascopyTick {
time: 2000,
bid: 1.1010,
ask: 1.1012,
bid_volume: 150.0,
ask_volume: 150.0,
},
DukascopyTick {
time: 3000,
bid: 1.0990,
ask: 1.0992,
bid_volume: 120.0,
ask_volume: 120.0,
},
DukascopyTick {
time: 4000,
bid: 1.1005,
ask: 1.1007,
bid_volume: 130.0,
ask_volume: 130.0,
},
];
let kline = DukascopyParser::ticks_to_kline(&ticks, 1000).unwrap();
assert_eq!(kline.open_time, 1000);
assert!((kline.open - 1.1001).abs() < 0.0001); assert!((kline.close - 1.1006).abs() < 0.0001); assert!(kline.high >= 1.1011); assert!(kline.low <= 1.0991); assert_eq!(kline.volume, 1000.0); assert_eq!(kline.trades, Some(4)); }
#[test]
fn test_parse_interval_to_ms() {
assert_eq!(DukascopyParser::parse_interval_to_ms("1m").unwrap(), 60_000);
assert_eq!(DukascopyParser::parse_interval_to_ms("5m").unwrap(), 300_000);
assert_eq!(DukascopyParser::parse_interval_to_ms("1h").unwrap(), 3_600_000);
assert_eq!(
DukascopyParser::parse_interval_to_ms("1d").unwrap(),
86_400_000
);
assert_eq!(DukascopyParser::parse_interval_to_ms("1H").unwrap(), 3_600_000);
assert!(DukascopyParser::parse_interval_to_ms("99x").is_err());
}
#[test]
fn test_tick_to_ticker() {
let tick = DukascopyTick {
time: 1234567890000,
bid: 1.12345,
ask: 1.12347,
bid_volume: 1500000.0,
ask_volume: 1200000.0,
};
let ticker = DukascopyParser::tick_to_ticker(&tick, "EURUSD");
assert_eq!(ticker.symbol, "EURUSD");
assert!((ticker.last_price - 1.12346).abs() < 0.00001);
assert_eq!(ticker.bid_price, Some(1.12345));
assert_eq!(ticker.ask_price, Some(1.12347));
assert_eq!(ticker.timestamp, 1234567890000);
}
}