use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FutOptPriceLevel {
pub price: f64,
pub size: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FutOptTotalStats {
#[serde(rename = "tradeVolume")]
pub trade_volume: i64,
#[serde(rename = "totalBidMatch")]
pub total_bid_match: Option<i64>,
#[serde(rename = "totalAskMatch")]
pub total_ask_match: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FutOptLastTrade {
pub price: f64,
pub size: i64,
pub time: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FutOptQuote {
pub date: String,
#[serde(rename = "type")]
pub contract_type: Option<String>,
pub exchange: Option<String>,
pub symbol: String,
pub name: Option<String>,
#[serde(rename = "previousClose")]
pub previous_close: Option<f64>,
#[serde(rename = "openPrice")]
pub open_price: Option<f64>,
#[serde(rename = "openTime")]
pub open_time: Option<i64>,
#[serde(rename = "highPrice")]
pub high_price: Option<f64>,
#[serde(rename = "highTime")]
pub high_time: Option<i64>,
#[serde(rename = "lowPrice")]
pub low_price: Option<f64>,
#[serde(rename = "lowTime")]
pub low_time: Option<i64>,
#[serde(rename = "closePrice")]
pub close_price: Option<f64>,
#[serde(rename = "closeTime")]
pub close_time: Option<i64>,
#[serde(rename = "lastPrice")]
pub last_price: Option<f64>,
#[serde(rename = "lastSize")]
pub last_size: Option<i64>,
#[serde(rename = "avgPrice")]
pub avg_price: Option<f64>,
pub change: Option<f64>,
#[serde(rename = "changePercent")]
pub change_percent: Option<f64>,
pub amplitude: Option<f64>,
#[serde(default)]
pub bids: Vec<FutOptPriceLevel>,
#[serde(default)]
pub asks: Vec<FutOptPriceLevel>,
pub total: Option<FutOptTotalStats>,
#[serde(rename = "lastTrade")]
pub last_trade: Option<FutOptLastTrade>,
#[serde(rename = "lastUpdated")]
pub last_updated: Option<i64>,
}
impl FutOptQuote {
pub fn spread(&self) -> Option<f64> {
let best_bid = self.bids.first().map(|l| l.price);
let best_ask = self.asks.first().map(|l| l.price);
match (best_ask, best_bid) {
(Some(ask), Some(bid)) => Some(ask - bid),
_ => None,
}
}
pub fn mid_price(&self) -> Option<f64> {
let best_bid = self.bids.first().map(|l| l.price);
let best_ask = self.asks.first().map(|l| l.price);
match (best_ask, best_bid) {
(Some(ask), Some(bid)) => Some((ask + bid) / 2.0),
_ => None,
}
}
pub fn has_price_data(&self) -> bool {
self.last_price.is_some() || self.close_price.is_some()
}
pub fn total_volume(&self) -> Option<i64> {
self.total.as_ref().map(|t| t.trade_volume)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_futopt_quote_deserialization() {
let json = r#"{
"date": "2024-01-15",
"type": "FUTURE",
"exchange": "TAIFEX",
"symbol": "TXFC4",
"name": "臺股期貨",
"previousClose": 17500.0,
"openPrice": 17520.0,
"openTime": 1705287000000,
"highPrice": 17580.0,
"highTime": 1705290600000,
"lowPrice": 17480.0,
"lowTime": 1705288800000,
"closePrice": 17550.0,
"closeTime": 1705302000000,
"lastPrice": 17550.0,
"lastSize": 2,
"avgPrice": 17530.0,
"change": 50.0,
"changePercent": 0.29,
"amplitude": 0.57,
"bids": [
{"price": 17549.0, "size": 50},
{"price": 17548.0, "size": 30}
],
"asks": [
{"price": 17550.0, "size": 30},
{"price": 17551.0, "size": 40}
],
"total": {
"tradeVolume": 50000,
"totalBidMatch": 25000,
"totalAskMatch": 25000
},
"lastTrade": {
"price": 17550.0,
"size": 2,
"time": 1705302000000
},
"lastUpdated": 1705302000000
}"#;
let quote: FutOptQuote = serde_json::from_str(json).unwrap();
assert_eq!(quote.symbol, "TXFC4");
assert_eq!(quote.name.as_deref(), Some("臺股期貨"));
assert_eq!(quote.contract_type.as_deref(), Some("FUTURE"));
assert_eq!(quote.exchange.as_deref(), Some("TAIFEX"));
assert_eq!(quote.last_price, Some(17550.0));
assert_eq!(quote.previous_close, Some(17500.0));
assert_eq!(quote.change, Some(50.0));
assert_eq!(quote.bids.len(), 2);
assert_eq!(quote.asks.len(), 2);
assert_eq!(quote.bids[0].price, 17549.0);
assert_eq!(quote.asks[0].price, 17550.0);
assert_eq!(quote.total_volume(), Some(50000));
}
#[test]
fn test_futopt_quote_spread() {
let quote = FutOptQuote {
bids: vec![FutOptPriceLevel {
price: 17549.0,
size: 50,
}],
asks: vec![FutOptPriceLevel {
price: 17550.0,
size: 30,
}],
..Default::default()
};
assert_eq!(quote.spread(), Some(1.0));
}
#[test]
fn test_futopt_quote_mid_price() {
let quote = FutOptQuote {
bids: vec![FutOptPriceLevel {
price: 100.0,
size: 10,
}],
asks: vec![FutOptPriceLevel {
price: 102.0,
size: 10,
}],
..Default::default()
};
assert_eq!(quote.mid_price(), Some(101.0));
}
#[test]
fn test_futopt_quote_minimal() {
let json = r#"{"date": "2024-01-15", "symbol": "TXFC4"}"#;
let quote: FutOptQuote = serde_json::from_str(json).unwrap();
assert_eq!(quote.symbol, "TXFC4");
assert!(quote.bids.is_empty());
assert!(quote.asks.is_empty());
assert!(!quote.has_price_data());
}
#[test]
fn test_futopt_quote_has_price_data() {
let mut quote = FutOptQuote::default();
assert!(!quote.has_price_data());
quote.last_price = Some(17550.0);
assert!(quote.has_price_data());
quote.last_price = None;
quote.close_price = Some(17550.0);
assert!(quote.has_price_data());
}
#[test]
fn test_futopt_total_stats_deserialization() {
let json = r#"{"tradeVolume": 50000, "totalBidMatch": 25000, "totalAskMatch": 25000}"#;
let stats: FutOptTotalStats = serde_json::from_str(json).unwrap();
assert_eq!(stats.trade_volume, 50000);
assert_eq!(stats.total_bid_match, Some(25000));
assert_eq!(stats.total_ask_match, Some(25000));
}
#[test]
fn test_futopt_last_trade_deserialization() {
let json = r#"{"price": 17550.0, "size": 2, "time": 1705302000000}"#;
let trade: FutOptLastTrade = serde_json::from_str(json).unwrap();
assert_eq!(trade.price, 17550.0);
assert_eq!(trade.size, 2);
assert_eq!(trade.time, 1705302000000);
}
}