use crate::presentation::serialization::{
string_as_bool_opt, string_as_float_opt, string_as_int_opt,
};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt};
use tracing::warn;
#[repr(u8)]
#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
pub enum ChartScale {
#[serde(rename = "SECOND")]
Second,
#[serde(rename = "1MINUTE")]
OneMinute,
#[serde(rename = "5MINUTE")]
FiveMinute,
#[serde(rename = "HOUR")]
Hour,
#[serde(rename = "TICK")]
#[default]
Tick,
}
impl fmt::Debug for ChartScale {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ChartScale::Second => "SECOND",
ChartScale::OneMinute => "1MINUTE",
ChartScale::FiveMinute => "5MINUTE",
ChartScale::Hour => "HOUR",
ChartScale::Tick => "TICK",
};
write!(f, "{}", s)
}
}
impl fmt::Display for ChartScale {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
pub struct ChartData {
pub item_name: String,
pub item_pos: usize,
#[serde(default)]
pub scale: ChartScale, pub fields: ChartFields,
pub changed_fields: ChartFields,
pub is_snapshot: bool,
}
#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
pub struct ChartFields {
#[serde(rename = "LTV")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub last_traded_volume: Option<f64>,
#[serde(rename = "TTV")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub incremental_trading_volume: Option<f64>,
#[serde(rename = "UTM")]
#[serde(with = "string_as_int_opt")]
#[serde(default)]
pub update_time: Option<i64>,
#[serde(rename = "DAY_OPEN_MID")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub day_open_mid: Option<f64>,
#[serde(rename = "DAY_NET_CHG_MID")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub day_net_change_mid: Option<f64>,
#[serde(rename = "DAY_PERC_CHG_MID")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub day_percentage_change_mid: Option<f64>,
#[serde(rename = "DAY_HIGH")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub day_high: Option<f64>,
#[serde(rename = "DAY_LOW")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub day_low: Option<f64>,
#[serde(rename = "BID")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub bid: Option<f64>,
#[serde(rename = "OFR")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub offer: Option<f64>,
#[serde(rename = "LTP")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub last_traded_price: Option<f64>,
#[serde(rename = "OFR_OPEN")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub offer_open: Option<f64>,
#[serde(rename = "OFR_HIGH")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub offer_high: Option<f64>,
#[serde(rename = "OFR_LOW")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub offer_low: Option<f64>,
#[serde(rename = "OFR_CLOSE")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub offer_close: Option<f64>,
#[serde(rename = "BID_OPEN")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub bid_open: Option<f64>,
#[serde(rename = "BID_HIGH")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub bid_high: Option<f64>,
#[serde(rename = "BID_LOW")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub bid_low: Option<f64>,
#[serde(rename = "BID_CLOSE")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub bid_close: Option<f64>,
#[serde(rename = "LTP_OPEN")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub ltp_open: Option<f64>,
#[serde(rename = "LTP_HIGH")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub ltp_high: Option<f64>,
#[serde(rename = "LTP_LOW")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub ltp_low: Option<f64>,
#[serde(rename = "LTP_CLOSE")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub ltp_close: Option<f64>,
#[serde(rename = "CONS_END")]
#[serde(with = "string_as_bool_opt")]
#[serde(default)]
pub candle_end: Option<bool>,
#[serde(rename = "CONS_TICK_COUNT")]
#[serde(with = "string_as_float_opt")]
#[serde(default)]
pub candle_tick_count: Option<f64>,
}
impl ChartData {
pub fn from_fields(
item_name: Option<&str>,
item_pos: usize,
is_snapshot: bool,
fields: &HashMap<String, Option<String>>,
changed_fields: &HashMap<String, Option<String>>,
) -> Result<Self, String> {
let scale = match item_name {
Some(name) => match name.split(':').nth(2) {
Some("TICK") => ChartScale::Tick,
Some("SECOND") => ChartScale::Second,
Some("1MINUTE") => ChartScale::OneMinute,
Some("5MINUTE") => ChartScale::FiveMinute,
Some("HOUR") => ChartScale::Hour,
other => {
warn!(
item_name = %name,
scale = ?other,
"unrecognized chart scale in item name; defaulting to {:?}",
ChartScale::default()
);
ChartScale::default()
}
},
None => ChartScale::default(),
};
let parsed_fields = Self::create_chart_fields(fields)?;
let parsed_changed_fields = Self::create_chart_fields(changed_fields)?;
Ok(ChartData {
item_name: item_name.unwrap_or_default().to_string(),
item_pos,
scale,
fields: parsed_fields,
changed_fields: parsed_changed_fields,
is_snapshot,
})
}
fn create_chart_fields(
fields_map: &HashMap<String, Option<String>>,
) -> Result<ChartFields, String> {
let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
let parse_float = |key: &str| -> Result<Option<f64>, String> {
match get_field(key) {
Some(val) if !val.is_empty() => val
.parse::<f64>()
.map(Some)
.map_err(|_| format!("Failed to parse {key} as float: {val}")),
_ => Ok(None),
}
};
let parse_int = |key: &str| -> Result<Option<i64>, String> {
match get_field(key) {
Some(val) if !val.is_empty() => val
.parse::<i64>()
.map(Some)
.map_err(|_| format!("Failed to parse {key} as integer: {val}")),
_ => Ok(None),
}
};
let candle_end = match get_field("CONS_END").as_deref() {
Some("0") => Some(false),
Some("1") => Some(true),
Some("") | None => None,
Some(val) => return Err(format!("Invalid CONS_END value: {val}")),
};
Ok(ChartFields {
last_traded_volume: parse_float("LTV")?,
incremental_trading_volume: parse_float("TTV")?,
update_time: parse_int("UTM")?,
day_open_mid: parse_float("DAY_OPEN_MID")?,
day_net_change_mid: parse_float("DAY_NET_CHG_MID")?,
day_percentage_change_mid: parse_float("DAY_PERC_CHG_MID")?,
day_high: parse_float("DAY_HIGH")?,
day_low: parse_float("DAY_LOW")?,
bid: parse_float("BID")?,
offer: parse_float("OFR")?,
last_traded_price: parse_float("LTP")?,
offer_open: parse_float("OFR_OPEN")?,
offer_high: parse_float("OFR_HIGH")?,
offer_low: parse_float("OFR_LOW")?,
offer_close: parse_float("OFR_CLOSE")?,
bid_open: parse_float("BID_OPEN")?,
bid_high: parse_float("BID_HIGH")?,
bid_low: parse_float("BID_LOW")?,
bid_close: parse_float("BID_CLOSE")?,
ltp_open: parse_float("LTP_OPEN")?,
ltp_high: parse_float("LTP_HIGH")?,
ltp_low: parse_float("LTP_LOW")?,
ltp_close: parse_float("LTP_CLOSE")?,
candle_end,
candle_tick_count: parse_float("CONS_TICK_COUNT")?,
})
}
#[must_use]
#[inline]
pub fn is_tick(&self) -> bool {
matches!(self.scale, ChartScale::Tick)
}
#[must_use]
#[inline]
pub fn is_candle(&self) -> bool {
!self.is_tick()
}
#[must_use]
#[inline]
pub fn get_scale(&self) -> &ChartScale {
&self.scale
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chart_scale_default() {
let scale = ChartScale::default();
assert_eq!(scale, ChartScale::Tick);
}
#[test]
fn test_chart_scale_debug() {
assert_eq!(format!("{:?}", ChartScale::Second), "SECOND");
assert_eq!(format!("{:?}", ChartScale::OneMinute), "1MINUTE");
assert_eq!(format!("{:?}", ChartScale::FiveMinute), "5MINUTE");
assert_eq!(format!("{:?}", ChartScale::Hour), "HOUR");
assert_eq!(format!("{:?}", ChartScale::Tick), "TICK");
}
#[test]
fn test_chart_scale_display() {
assert_eq!(format!("{}", ChartScale::Second), "SECOND");
assert_eq!(format!("{}", ChartScale::Tick), "TICK");
}
#[test]
fn test_chart_scale_serialization() {
let scale = ChartScale::OneMinute;
let json = serde_json::to_string(&scale).expect("serialize failed");
assert_eq!(json, "\"1MINUTE\"");
let deserialized: ChartScale = serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(deserialized, ChartScale::OneMinute);
}
#[test]
fn test_chart_data_default() {
let data = ChartData::default();
assert!(data.item_name.is_empty());
assert_eq!(data.item_pos, 0);
assert_eq!(data.scale, ChartScale::Tick);
assert!(!data.is_snapshot);
}
#[test]
fn test_chart_data_is_tick() {
let data = ChartData {
scale: ChartScale::Tick,
..Default::default()
};
assert!(data.is_tick());
assert!(!data.is_candle());
}
#[test]
fn test_chart_data_is_candle() {
let data = ChartData {
scale: ChartScale::OneMinute,
..Default::default()
};
assert!(!data.is_tick());
assert!(data.is_candle());
let data_hour = ChartData {
scale: ChartScale::Hour,
..Default::default()
};
assert!(data_hour.is_candle());
}
#[test]
fn test_chart_data_get_scale() {
let data = ChartData {
scale: ChartScale::FiveMinute,
..Default::default()
};
assert_eq!(*data.get_scale(), ChartScale::FiveMinute);
}
#[test]
fn test_chart_fields_default() {
let fields = ChartFields::default();
assert!(fields.bid.is_none());
assert!(fields.offer.is_none());
assert!(fields.last_traded_price.is_none());
assert!(fields.day_high.is_none());
assert!(fields.day_low.is_none());
}
#[test]
fn test_chart_fields_creation() {
let fields = ChartFields {
bid: Some(100.5),
offer: Some(101.0),
last_traded_price: Some(100.75),
day_high: Some(102.0),
day_low: Some(99.0),
..Default::default()
};
assert_eq!(fields.bid, Some(100.5));
assert_eq!(fields.offer, Some(101.0));
assert_eq!(fields.last_traded_price, Some(100.75));
}
#[test]
fn test_chart_scale_equality() {
assert_eq!(ChartScale::Tick, ChartScale::Tick);
assert_ne!(ChartScale::Tick, ChartScale::Hour);
}
#[test]
fn test_chart_scale_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(ChartScale::Tick);
set.insert(ChartScale::Tick);
assert_eq!(set.len(), 1);
}
#[test]
fn test_from_fields_derives_known_scale_from_item_name() {
let fields = HashMap::new();
let changed = HashMap::new();
let data = ChartData::from_fields(Some("CHART:EPIC:SECOND"), 1, false, &fields, &changed)
.expect("from_fields should succeed");
assert_eq!(data.scale, ChartScale::Second);
assert_eq!(data.item_name, "CHART:EPIC:SECOND");
let data =
ChartData::from_fields(Some("CHART:IX.D.DAX.IP:HOUR"), 1, false, &fields, &changed)
.expect("from_fields should succeed");
assert_eq!(data.scale, ChartScale::Hour);
}
#[test]
fn test_from_fields_unknown_scale_defaults_to_tick() {
let fields = HashMap::new();
let changed = HashMap::new();
let data = ChartData::from_fields(Some("CHART:EPIC:WEEKLY"), 1, false, &fields, &changed)
.expect("from_fields should succeed");
assert_eq!(data.scale, ChartScale::default());
assert_eq!(data.scale, ChartScale::Tick);
}
#[test]
fn test_from_fields_no_item_name_defaults_to_tick() {
let fields = HashMap::new();
let changed = HashMap::new();
let data = ChartData::from_fields(None, 1, false, &fields, &changed)
.expect("from_fields should succeed");
assert_eq!(data.scale, ChartScale::default());
assert!(data.item_name.is_empty());
}
}