use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TickSize {
#[serde(skip_serializing_if = "Option::is_none")]
pub begin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<String>,
#[serde(rename = "tickSize", skip_serializing_if = "Option::is_none")]
pub tick_size: Option<f64>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Contract {
#[serde(rename = "contractId", skip_serializing_if = "Option::is_none")]
pub contract_id: Option<i64>,
pub symbol: String,
#[serde(rename = "secType")]
pub sec_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exchange: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strike: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub multiplier: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub market: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tradeable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conid: Option<i64>,
#[serde(rename = "shortMargin", skip_serializing_if = "Option::is_none")]
pub short_margin: Option<f64>,
#[serde(rename = "shortInitialMargin", skip_serializing_if = "Option::is_none")]
pub short_initial_margin: Option<f64>,
#[serde(rename = "shortMaintenanceMargin", skip_serializing_if = "Option::is_none")]
pub short_maintenace_margin: Option<f64>,
#[serde(rename = "longInitialMargin", skip_serializing_if = "Option::is_none")]
pub long_initial_margin: Option<f64>,
#[serde(rename = "longMaintenanceMargin", skip_serializing_if = "Option::is_none")]
pub long_maintenace_margin: Option<f64>,
#[serde(rename = "tickSizes", skip_serializing_if = "Option::is_none")]
pub tick_sizes: Option<Vec<TickSize>>,
#[serde(rename = "lotSize", skip_serializing_if = "Option::is_none")]
pub lot_size: Option<f64>,
}
pub fn stock_contract(symbol: &str, currency: &str) -> Contract {
Contract {
contract_id: None,
symbol: symbol.to_string(),
sec_type: "STK".to_string(),
currency: Some(currency.to_string()),
exchange: None,
expiry: None,
strike: None,
right: None,
multiplier: None,
identifier: None,
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
pub fn option_contract(identifier: &str) -> Contract {
Contract {
contract_id: None,
symbol: String::new(),
sec_type: "OPT".to_string(),
currency: None,
exchange: None,
expiry: None,
strike: None,
right: None,
multiplier: None,
identifier: Some(identifier.to_string()),
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
pub fn option_contract_by_symbol(
symbol: &str,
expiry: &str,
strike: f64,
right: &str,
currency: &str,
) -> Contract {
Contract {
contract_id: None,
symbol: symbol.to_string(),
sec_type: "OPT".to_string(),
currency: Some(currency.to_string()),
exchange: None,
expiry: Some(expiry.to_string()),
strike: Some(strike),
right: Some(right.to_string()),
multiplier: None,
identifier: None,
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
pub fn future_contract(symbol: &str, currency: &str, expiry: &str) -> Contract {
Contract {
contract_id: None,
symbol: symbol.to_string(),
sec_type: "FUT".to_string(),
currency: Some(currency.to_string()),
exchange: None,
expiry: Some(expiry.to_string()),
strike: None,
right: None,
multiplier: None,
identifier: None,
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
pub fn cash_contract(symbol: &str) -> Contract {
Contract {
contract_id: None,
symbol: symbol.to_string(),
sec_type: "CASH".to_string(),
currency: None,
exchange: None,
expiry: None,
strike: None,
right: None,
multiplier: None,
identifier: None,
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
pub fn fund_contract(symbol: &str, currency: &str) -> Contract {
Contract {
contract_id: None,
symbol: symbol.to_string(),
sec_type: "FUND".to_string(),
currency: Some(currency.to_string()),
exchange: None,
expiry: None,
strike: None,
right: None,
multiplier: None,
identifier: None,
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
pub fn warrant_contract(
symbol: &str,
currency: &str,
expiry: &str,
strike: f64,
right: &str,
) -> Contract {
Contract {
contract_id: None,
symbol: symbol.to_string(),
sec_type: "WAR".to_string(),
currency: Some(currency.to_string()),
exchange: None,
expiry: Some(expiry.to_string()),
strike: Some(strike),
right: Some(right.to_string()),
multiplier: None,
identifier: None,
name: None,
market: None,
tradeable: None,
conid: None,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_contract_json_round_trip_stock() {
let contract = stock_contract("AAPL", "USD");
let json = serde_json::to_string(&contract).unwrap();
let deserialized: Contract = serde_json::from_str(&json).unwrap();
assert_eq!(contract, deserialized);
}
#[test]
fn test_contract_json_round_trip_option() {
let contract = option_contract_by_symbol("AAPL", "20251219", 150.0, "CALL", "USD");
let json = serde_json::to_string(&contract).unwrap();
let deserialized: Contract = serde_json::from_str(&json).unwrap();
assert_eq!(contract, deserialized);
}
#[test]
fn test_contract_json_round_trip_future() {
let contract = future_contract("ES", "USD", "20251219");
let json = serde_json::to_string(&contract).unwrap();
let deserialized: Contract = serde_json::from_str(&json).unwrap();
assert_eq!(contract, deserialized);
}
#[test]
fn test_contract_serde_rename_field_names() {
let contract = Contract {
contract_id: Some(12345),
symbol: "AAPL".to_string(),
sec_type: "STK".to_string(),
currency: Some("USD".to_string()),
exchange: None,
expiry: None,
strike: None,
right: Some("CALL".to_string()),
multiplier: None,
identifier: None,
name: Some("Apple Inc".to_string()),
market: Some("US".to_string()),
tradeable: Some(true),
conid: Some(99999),
short_margin: Some(0.25),
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: Some(1.0),
};
let json_value: serde_json::Value = serde_json::to_value(&contract).unwrap();
let obj = json_value.as_object().unwrap();
assert!(obj.contains_key("contractId"), "应映射为 contractId");
assert!(obj.contains_key("secType"), "应映射为 secType");
assert!(obj.contains_key("lotSize"), "应映射为 lotSize");
assert!(obj.contains_key("shortMargin"), "应映射为 shortMargin");
assert!(obj.contains_key("right"), "right 字段不应改名");
assert!(obj.contains_key("tradeable"), "tradeable 字段不应改名");
assert!(obj.contains_key("conid"), "conid 字段不应改名");
assert!(obj.contains_key("symbol"), "symbol 字段不应改名");
assert!(!obj.contains_key("putCall"), "不应出现 putCall");
assert!(!obj.contains_key("put_call"), "不应出现 put_call");
assert!(!obj.contains_key("trade"), "不应出现 trade");
assert!(!obj.contains_key("contract_id"), "不应出现 snake_case 的 contract_id");
assert!(!obj.contains_key("sec_type"), "不应出现 snake_case 的 sec_type");
}
#[test]
fn test_contract_deserialize_from_api_json() {
let json = r#"{
"contractId": 12345,
"symbol": "AAPL",
"secType": "STK",
"currency": "USD",
"right": "CALL",
"tradeable": true,
"conid": 99999,
"shortMargin": 0.25,
"lotSize": 1.0,
"tickSizes": [{"begin": "0", "end": "1", "tickSize": 0.01}]
}"#;
let contract: Contract = serde_json::from_str(json).unwrap();
assert_eq!(contract.contract_id, Some(12345));
assert_eq!(contract.symbol, "AAPL");
assert_eq!(contract.sec_type, "STK");
assert_eq!(contract.right, Some("CALL".to_string()));
assert_eq!(contract.tradeable, Some(true));
assert_eq!(contract.conid, Some(99999));
assert_eq!(contract.short_margin, Some(0.25));
assert_eq!(contract.lot_size, Some(1.0));
assert!(contract.tick_sizes.is_some());
let tick_sizes = contract.tick_sizes.unwrap();
assert_eq!(tick_sizes.len(), 1);
assert_eq!(tick_sizes[0].tick_size, Some(0.01));
}
#[test]
fn test_contract_skip_none_fields() {
let contract = stock_contract("AAPL", "USD");
let json_value: serde_json::Value = serde_json::to_value(&contract).unwrap();
let obj = json_value.as_object().unwrap();
assert!(obj.contains_key("symbol"));
assert!(obj.contains_key("secType"));
assert!(!obj.contains_key("contractId"));
assert!(!obj.contains_key("exchange"));
assert!(!obj.contains_key("expiry"));
assert!(!obj.contains_key("strike"));
assert!(!obj.contains_key("right"));
assert!(!obj.contains_key("tradeable"));
assert!(!obj.contains_key("conid"));
}
#[test]
fn test_stock_contract() {
let c = stock_contract("AAPL", "USD");
assert_eq!(c.symbol, "AAPL");
assert_eq!(c.sec_type, "STK");
assert_eq!(c.currency, Some("USD".to_string()));
}
#[test]
fn test_option_contract() {
let c = option_contract("AAPL 20251219 150.0 CALL");
assert_eq!(c.sec_type, "OPT");
assert_eq!(c.identifier, Some("AAPL 20251219 150.0 CALL".to_string()));
}
#[test]
fn test_option_contract_by_symbol() {
let c = option_contract_by_symbol("AAPL", "20251219", 150.0, "CALL", "USD");
assert_eq!(c.symbol, "AAPL");
assert_eq!(c.sec_type, "OPT");
assert_eq!(c.expiry, Some("20251219".to_string()));
assert_eq!(c.strike, Some(150.0));
assert_eq!(c.right, Some("CALL".to_string()));
assert_eq!(c.currency, Some("USD".to_string()));
}
#[test]
fn test_future_contract() {
let c = future_contract("ES", "USD", "20251219");
assert_eq!(c.symbol, "ES");
assert_eq!(c.sec_type, "FUT");
assert_eq!(c.currency, Some("USD".to_string()));
assert_eq!(c.expiry, Some("20251219".to_string()));
}
#[test]
fn test_cash_contract() {
let c = cash_contract("USD.HKD");
assert_eq!(c.symbol, "USD.HKD");
assert_eq!(c.sec_type, "CASH");
assert_eq!(c.currency, None);
}
#[test]
fn test_fund_contract() {
let c = fund_contract("SPY", "USD");
assert_eq!(c.symbol, "SPY");
assert_eq!(c.sec_type, "FUND");
assert_eq!(c.currency, Some("USD".to_string()));
}
#[test]
fn test_warrant_contract() {
let c = warrant_contract("00700", "HKD", "20251219", 350.0, "CALL");
assert_eq!(c.symbol, "00700");
assert_eq!(c.sec_type, "WAR");
assert_eq!(c.currency, Some("HKD".to_string()));
assert_eq!(c.expiry, Some("20251219".to_string()));
assert_eq!(c.strike, Some(350.0));
assert_eq!(c.right, Some("CALL".to_string()));
}
fn arb_contract() -> impl Strategy<Value = Contract> {
let group1 = (
prop::option::of(any::<i64>()), "[A-Z]{1,5}", prop::sample::select(vec!["STK", "OPT", "FUT", "WAR", "CASH", "FUND"]),
prop::option::of("[A-Z]{3}"), prop::option::of("[0-9]{8}"), prop::option::of((1i64..1000000i64).prop_map(|v| v as f64 / 100.0)), );
let group2 = (
prop::option::of(prop::sample::select(vec!["PUT", "CALL"])),
prop::option::of("[A-Za-z ]{1,20}"), prop::option::of(prop::sample::select(vec!["US", "HK", "CN", "SG"])),
prop::option::of(any::<bool>()), prop::option::of(any::<i64>()), );
(group1, group2).prop_map(|((cid, sym, st, cur, exp, strike), (right, name, mkt, trd, conid))| {
Contract {
contract_id: cid,
symbol: sym,
sec_type: st.to_string(),
currency: cur,
exchange: None,
expiry: exp,
strike,
right: right.map(|s| s.to_string()),
multiplier: None,
identifier: None,
name,
market: mkt.map(|s| s.to_string()),
tradeable: trd,
conid,
short_margin: None,
short_initial_margin: None,
short_maintenace_margin: None,
long_initial_margin: None,
long_maintenace_margin: None,
tick_sizes: None,
lot_size: None,
}
})
}
proptest! {
#[test]
fn prop_contract_json_round_trip(contract in arb_contract()) {
let json = serde_json::to_string(&contract).unwrap();
let deserialized: Contract = serde_json::from_str(&json).unwrap();
prop_assert_eq!(&contract, &deserialized);
let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = json_value.as_object().unwrap();
prop_assert!(!obj.contains_key("contract_id"), "不应出现 snake_case 的 contract_id");
prop_assert!(!obj.contains_key("sec_type"), "不应出现 snake_case 的 sec_type");
prop_assert!(!obj.contains_key("putCall"), "不应出现 putCall");
prop_assert!(!obj.contains_key("put_call"), "不应出现 put_call");
prop_assert!(!obj.contains_key("trade"), "不应出现 trade");
prop_assert!(obj.contains_key("symbol"));
prop_assert!(obj.contains_key("secType"));
if contract.contract_id.is_some() {
prop_assert!(obj.contains_key("contractId"));
}
}
}
}