#![allow(clippy::disallowed_methods)]
use proptest::prelude::*;
mod builder_config_preservation {
use super::*;
use ccxt_exchanges::bitget::BitgetBuilder;
use std::time::Duration;
fn api_key_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{8,64}"
}
fn secret_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{16,128}"
}
fn passphrase_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{6,32}"
}
fn product_type_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("spot".to_string()),
Just("umcbl".to_string()),
Just("dmcbl".to_string()),
]
}
fn recv_window_strategy() -> impl Strategy<Value = u64> {
1000u64..60000u64
}
fn timeout_strategy() -> impl Strategy<Value = u64> {
1u64..300u64
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_api_key_preserved(api_key in api_key_strategy()) {
let bitget = BitgetBuilder::new()
.api_key(&api_key)
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(
bitget.base().config.api_key.as_ref().map(|s| s.expose_secret()),
Some(api_key.as_str()),
"Bitget builder should preserve api_key"
);
}
#[test]
fn prop_secret_preserved(secret in secret_strategy()) {
let bitget = BitgetBuilder::new()
.secret(&secret)
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(
bitget.base().config.secret.as_ref().map(|s| s.expose_secret()),
Some(secret.as_str()),
"Bitget builder should preserve secret"
);
}
#[test]
fn prop_passphrase_preserved(passphrase in passphrase_strategy()) {
let bitget = BitgetBuilder::new()
.passphrase(&passphrase)
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(
bitget.base().config.password.as_ref().map(|s| s.expose_secret()),
Some(passphrase.as_str()),
"Bitget builder should preserve passphrase"
);
}
#[test]
fn prop_sandbox_preserved(sandbox in any::<bool>()) {
let bitget = BitgetBuilder::new()
.sandbox(sandbox)
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(bitget.base().config.sandbox, sandbox);
prop_assert_eq!(bitget.options().testnet, sandbox);
}
#[test]
fn prop_product_type_preserved(product_type in product_type_strategy()) {
let bitget = BitgetBuilder::new()
.product_type(&product_type)
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(bitget.options().product_type.clone(), product_type);
}
#[test]
fn prop_recv_window_preserved(recv_window in recv_window_strategy()) {
let bitget = BitgetBuilder::new()
.recv_window(recv_window)
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(bitget.options().recv_window, recv_window);
}
#[test]
fn prop_timeout_preserved(timeout in timeout_strategy()) {
let bitget = BitgetBuilder::new()
.timeout(Duration::from_secs(timeout))
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(bitget.base().config.timeout, Duration::from_secs(timeout));
}
#[test]
fn prop_full_config_preserved(
api_key in api_key_strategy(),
secret in secret_strategy(),
passphrase in passphrase_strategy(),
sandbox in any::<bool>(),
product_type in product_type_strategy(),
recv_window in recv_window_strategy(),
timeout in timeout_strategy(),
) {
let bitget = BitgetBuilder::new()
.api_key(&api_key)
.secret(&secret)
.passphrase(&passphrase)
.sandbox(sandbox)
.product_type(&product_type)
.recv_window(recv_window)
.timeout(Duration::from_secs(timeout))
.build()
.expect("Failed to build Bitget");
prop_assert_eq!(
bitget.base().config.api_key.as_ref().map(|s| s.expose_secret()),
Some(api_key.as_str()),
"Bitget builder should preserve api_key"
);
prop_assert_eq!(
bitget.base().config.secret.as_ref().map(|s| s.expose_secret()),
Some(secret.as_str()),
"Bitget builder should preserve secret"
);
prop_assert_eq!(
bitget.base().config.password.as_ref().map(|s| s.expose_secret()),
Some(passphrase.as_str()),
"Bitget builder should preserve passphrase"
);
prop_assert_eq!(bitget.base().config.sandbox, sandbox);
prop_assert_eq!(bitget.options().testnet, sandbox);
prop_assert_eq!(bitget.options().product_type.clone(), product_type);
prop_assert_eq!(bitget.options().recv_window, recv_window);
prop_assert_eq!(bitget.base().config.timeout, Duration::from_secs(timeout));
}
}
}
mod signature_consistency {
use super::*;
use ccxt_exchanges::bitget::BitgetAuth;
fn timestamp_strategy() -> impl Strategy<Value = String> {
(1577836800000u64..1893456000000u64).prop_map(|ts| ts.to_string())
}
fn method_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("GET".to_string()),
Just("POST".to_string()),
Just("DELETE".to_string()),
Just("PUT".to_string()),
]
}
fn path_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("/api/v2/spot/account/assets".to_string()),
Just("/api/v2/spot/trade/place-order".to_string()),
Just("/api/v2/spot/trade/cancel-order".to_string()),
Just("/api/v2/spot/market/tickers".to_string()),
Just("/api/v2/mix/account/accounts".to_string()),
"[a-z]{3,10}".prop_map(|s| format!("/api/v2/spot/{}", s)),
("[a-z]{3,10}", "[a-z]{3,10}=[a-z0-9]{1,10}")
.prop_map(|(path, query)| format!("/api/v2/spot/{}?{}", path, query)),
]
}
fn body_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("".to_string()),
Just(r#"{"symbol":"BTCUSDT"}"#.to_string()),
Just(r#"{"symbol":"ETHUSDT","side":"buy","amount":"1.0"}"#.to_string()),
("[a-z]{3,10}", "[a-z0-9]{1,20}")
.prop_map(|(key, value)| format!(r#"{{"{}":"{}"}}"#, key, value)),
]
}
fn secret_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{16,64}"
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_signature_deterministic(
secret in secret_strategy(),
timestamp in timestamp_strategy(),
method in method_strategy(),
path in path_strategy(),
body in body_strategy(),
) {
let auth = BitgetAuth::new(
"test-api-key".to_string(),
secret,
"test-passphrase".to_string(),
);
let sig1 = auth.sign(×tamp, &method, &path, &body);
let sig2 = auth.sign(×tamp, &method, &path, &body);
prop_assert_eq!(sig1, sig2, "Signature should be deterministic for same inputs");
}
#[test]
fn prop_signature_varies_with_timestamp(
secret in secret_strategy(),
timestamp1 in timestamp_strategy(),
timestamp2 in timestamp_strategy(),
method in method_strategy(),
path in path_strategy(),
body in body_strategy(),
) {
prop_assume!(timestamp1 != timestamp2);
let auth = BitgetAuth::new(
"test-api-key".to_string(),
secret,
"test-passphrase".to_string(),
);
let sig1 = auth.sign(×tamp1, &method, &path, &body);
let sig2 = auth.sign(×tamp2, &method, &path, &body);
prop_assert_ne!(sig1, sig2, "Different timestamps should produce different signatures");
}
#[test]
fn prop_signature_varies_with_secret(
secret1 in secret_strategy(),
secret2 in secret_strategy(),
timestamp in timestamp_strategy(),
method in method_strategy(),
path in path_strategy(),
body in body_strategy(),
) {
prop_assume!(secret1 != secret2);
let auth1 = BitgetAuth::new(
"test-api-key".to_string(),
secret1,
"test-passphrase".to_string(),
);
let auth2 = BitgetAuth::new(
"test-api-key".to_string(),
secret2,
"test-passphrase".to_string(),
);
let sig1 = auth1.sign(×tamp, &method, &path, &body);
let sig2 = auth2.sign(×tamp, &method, &path, &body);
prop_assert_ne!(sig1, sig2, "Different secrets should produce different signatures");
}
#[test]
fn prop_signature_is_valid_base64(
secret in secret_strategy(),
timestamp in timestamp_strategy(),
method in method_strategy(),
path in path_strategy(),
body in body_strategy(),
) {
use base64::{Engine as _, engine::general_purpose};
let auth = BitgetAuth::new(
"test-api-key".to_string(),
secret,
"test-passphrase".to_string(),
);
let signature = auth.sign(×tamp, &method, &path, &body);
let decoded = general_purpose::STANDARD.decode(&signature);
prop_assert!(decoded.is_ok(), "Signature should be valid Base64");
prop_assert_eq!(decoded.unwrap().len(), 32, "Decoded signature should be 32 bytes (HMAC-SHA256)");
}
#[test]
fn prop_method_case_normalized(
secret in secret_strategy(),
timestamp in timestamp_strategy(),
path in path_strategy(),
body in body_strategy(),
) {
let auth = BitgetAuth::new(
"test-api-key".to_string(),
secret,
"test-passphrase".to_string(),
);
let sig_upper = auth.sign(×tamp, "GET", &path, &body);
let sig_lower = auth.sign(×tamp, "get", &path, &body);
let sig_mixed = auth.sign(×tamp, "Get", &path, &body);
prop_assert_eq!(sig_upper.clone(), sig_lower, "Method case should be normalized");
prop_assert_eq!(sig_upper, sig_mixed, "Method case should be normalized");
}
#[test]
fn prop_sign_string_format(
timestamp in timestamp_strategy(),
method in method_strategy(),
path in path_strategy(),
body in body_strategy(),
) {
let auth = BitgetAuth::new(
"test-api-key".to_string(),
"test-secret".to_string(),
"test-passphrase".to_string(),
);
let sign_string = auth.build_sign_string(×tamp, &method, &path, &body);
prop_assert!(sign_string.starts_with(×tamp), "Sign string should start with timestamp");
prop_assert!(sign_string.contains(&method.to_uppercase()), "Sign string should contain uppercase method");
prop_assert!(sign_string.contains(&path), "Sign string should contain path");
if body.is_empty() {
prop_assert!(sign_string.ends_with(&path), "Sign string should end with path when body is empty");
} else {
prop_assert!(sign_string.ends_with(&body), "Sign string should end with body");
}
}
}
}
mod error_response_parsing {
use super::*;
use ccxt_exchanges::bitget::error::{
BitgetErrorCode, extract_error_code, extract_error_message, is_error_response, parse_error,
};
use serde_json::json;
fn known_error_code_strategy() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just("40001"), Just("40002"), Just("40003"), Just("40004"), Just("40005"), Just("40006"), Just("40007"), ]
}
fn unknown_error_code_strategy() -> impl Strategy<Value = String> {
(10000i64..40000i64)
.prop_union(40008i64..99999i64)
.prop_map(|n| n.to_string())
}
fn any_error_code_strategy() -> impl Strategy<Value = String> {
prop_oneof![
known_error_code_strategy().prop_map(|s| s.to_string()),
unknown_error_code_strategy(),
]
}
fn error_message_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("Invalid API key".to_string()),
Just("Invalid signature".to_string()),
Just("Rate limit exceeded".to_string()),
Just("Invalid request parameters".to_string()),
Just("Insufficient balance".to_string()),
Just("Invalid trading pair".to_string()),
Just("Order not found".to_string()),
"[a-zA-Z0-9 ]{5,50}".prop_map(|s| s.to_string()),
]
}
fn error_response_strategy() -> impl Strategy<Value = serde_json::Value> {
(any_error_code_strategy(), error_message_strategy()).prop_map(|(code, msg)| {
json!({
"code": code,
"msg": msg
})
})
}
fn success_response_strategy() -> impl Strategy<Value = serde_json::Value> {
Just(json!({
"code": "00000",
"msg": "success",
"data": {}
}))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_parse_error_produces_valid_error(response in error_response_strategy()) {
let error = parse_error(&response);
let display = error.to_string();
prop_assert!(!display.is_empty(), "Error display should not be empty");
}
#[test]
fn prop_extract_error_code_consistent(
code in any_error_code_strategy(),
msg in error_message_strategy()
) {
let response = json!({
"code": code,
"msg": msg
});
let extracted = extract_error_code(&response);
prop_assert_eq!(extracted, code.as_str(), "Extracted code should match input code");
}
#[test]
fn prop_extract_error_message_consistent(
code in any_error_code_strategy(),
msg in error_message_strategy()
) {
let response = json!({
"code": code,
"msg": msg
});
let extracted = extract_error_message(&response);
prop_assert_eq!(extracted, msg.as_str(), "Extracted message should match input message");
}
#[test]
fn prop_is_error_response_identifies_errors(response in error_response_strategy()) {
let code = extract_error_code(&response);
if code != "00000" {
prop_assert!(is_error_response(&response), "Non-00000 code should be identified as error");
}
}
#[test]
fn prop_is_error_response_identifies_success(response in success_response_strategy()) {
prop_assert!(!is_error_response(&response), "00000 code should not be identified as error");
}
#[test]
fn prop_authentication_errors_mapped_correctly(
code in prop_oneof![Just("40001"), Just("40002")],
msg in error_message_strategy()
) {
let response = json!({
"code": code,
"msg": msg
});
let error = parse_error(&response);
prop_assert!(
error.as_authentication().is_some(),
"Error codes 40001 and 40002 should map to authentication errors"
);
}
#[test]
fn prop_rate_limit_errors_mapped_correctly(msg in error_message_strategy()) {
let response = json!({
"code": "40003",
"msg": msg
});
let error = parse_error(&response);
prop_assert!(
error.as_rate_limit().is_some(),
"Error code 40003 should map to rate limit error"
);
let (_, retry_after) = error.as_rate_limit().unwrap();
prop_assert!(retry_after.is_some(), "Rate limit error should have retry_after");
}
#[test]
fn prop_error_code_roundtrip(code in any_error_code_strategy()) {
let parsed = BitgetErrorCode::from_code(&code);
let back = parsed.code();
match code.as_str() {
"40001" => prop_assert_eq!(back, 40001),
"40002" => prop_assert_eq!(back, 40002),
"40003" => prop_assert_eq!(back, 40003),
"40004" => prop_assert_eq!(back, 40004),
"40005" => prop_assert_eq!(back, 40005),
"40006" => prop_assert_eq!(back, 40006),
"40007" => prop_assert_eq!(back, 40007),
_ => {
if let Ok(n) = code.parse::<i64>() {
prop_assert_eq!(back, n, "Unknown code should preserve numeric value");
}
}
}
}
#[test]
fn prop_missing_code_handled(msg in error_message_strategy()) {
let response = json!({
"msg": msg
});
let error = parse_error(&response);
let display = error.to_string();
prop_assert!(!display.is_empty(), "Error should have display even with missing code");
let code = extract_error_code(&response);
prop_assert_eq!(code, "unknown", "Missing code should return 'unknown'");
}
#[test]
fn prop_missing_message_handled(code in any_error_code_strategy()) {
let response = json!({
"code": code
});
let error = parse_error(&response);
let display = error.to_string();
prop_assert!(!display.is_empty(), "Error should have display even with missing message");
let msg = extract_error_message(&response);
prop_assert_eq!(msg, "Unknown error", "Missing message should return 'Unknown error'");
}
#[test]
fn prop_error_message_preserved(
code in known_error_code_strategy(),
msg in error_message_strategy()
) {
let response = json!({
"code": code,
"msg": msg
});
let error = parse_error(&response);
let display = error.to_string();
prop_assert!(
display.contains(&msg) || display.contains("Bad symbol"),
"Error display should contain the original message or formatted version. Got: {}, expected to contain: {}",
display,
msg
);
}
}
}
mod orderbook_sorting_invariant {
use super::*;
use ccxt_exchanges::bitget::parse_orderbook;
use serde_json::json;
fn price_strategy() -> impl Strategy<Value = String> {
(1u64..100000u64, 0u32..8u32).prop_map(|(whole, decimals)| {
if decimals == 0 {
whole.to_string()
} else {
let frac = whole % (10u64.pow(decimals));
format!(
"{}.{:0>width$}",
whole / (10u64.pow(decimals)),
frac,
width = decimals as usize
)
}
})
}
fn amount_strategy() -> impl Strategy<Value = String> {
(1u64..10000u64, 0u32..6u32).prop_map(|(whole, decimals)| {
if decimals == 0 {
whole.to_string()
} else {
let frac = whole % (10u64.pow(decimals));
format!(
"{}.{:0>width$}",
whole / (10u64.pow(decimals)),
frac,
width = decimals as usize
)
}
})
}
fn orderbook_entry_strategy() -> impl Strategy<Value = (String, String)> {
(price_strategy(), amount_strategy())
}
fn orderbook_side_strategy() -> impl Strategy<Value = Vec<(String, String)>> {
prop::collection::vec(orderbook_entry_strategy(), 1..20)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_bids_sorted_descending(entries in orderbook_side_strategy()) {
let bids_json: Vec<serde_json::Value> = entries
.iter()
.map(|(price, amount)| json!([price, amount]))
.collect();
let data = json!({
"bids": bids_json,
"asks": [],
"ts": "1700000000000"
});
let orderbook = parse_orderbook(&data, "BTC/USDT".to_string())
.expect("Failed to parse orderbook");
for i in 1..orderbook.bids.len() {
prop_assert!(
orderbook.bids[i - 1].price >= orderbook.bids[i].price,
"Bids should be sorted in descending order: {:?} >= {:?}",
orderbook.bids[i - 1].price,
orderbook.bids[i].price
);
}
}
#[test]
fn prop_asks_sorted_ascending(entries in orderbook_side_strategy()) {
let asks_json: Vec<serde_json::Value> = entries
.iter()
.map(|(price, amount)| json!([price, amount]))
.collect();
let data = json!({
"bids": [],
"asks": asks_json,
"ts": "1700000000000"
});
let orderbook = parse_orderbook(&data, "BTC/USDT".to_string())
.expect("Failed to parse orderbook");
for i in 1..orderbook.asks.len() {
prop_assert!(
orderbook.asks[i - 1].price <= orderbook.asks[i].price,
"Asks should be sorted in ascending order: {:?} <= {:?}",
orderbook.asks[i - 1].price,
orderbook.asks[i].price
);
}
}
#[test]
fn prop_both_sides_sorted(
bid_entries in orderbook_side_strategy(),
ask_entries in orderbook_side_strategy()
) {
let bids_json: Vec<serde_json::Value> = bid_entries
.iter()
.map(|(price, amount)| json!([price, amount]))
.collect();
let asks_json: Vec<serde_json::Value> = ask_entries
.iter()
.map(|(price, amount)| json!([price, amount]))
.collect();
let data = json!({
"bids": bids_json,
"asks": asks_json,
"ts": "1700000000000"
});
let orderbook = parse_orderbook(&data, "BTC/USDT".to_string())
.expect("Failed to parse orderbook");
for i in 1..orderbook.bids.len() {
prop_assert!(
orderbook.bids[i - 1].price >= orderbook.bids[i].price,
"Bids should be sorted in descending order"
);
}
for i in 1..orderbook.asks.len() {
prop_assert!(
orderbook.asks[i - 1].price <= orderbook.asks[i].price,
"Asks should be sorted in ascending order"
);
}
}
#[test]
fn prop_all_entries_preserved(entries in orderbook_side_strategy()) {
let bids_json: Vec<serde_json::Value> = entries
.iter()
.map(|(price, amount)| json!([price, amount]))
.collect();
let data = json!({
"bids": bids_json,
"asks": [],
"ts": "1700000000000"
});
let orderbook = parse_orderbook(&data, "BTC/USDT".to_string())
.expect("Failed to parse orderbook");
prop_assert_eq!(
orderbook.bids.len(),
entries.len(),
"All bid entries should be preserved"
);
}
}
}
mod decimal_precision_preservation {
use super::*;
use rust_decimal::Decimal;
use std::str::FromStr;
fn decimal_string_strategy() -> impl Strategy<Value = String> {
prop_oneof![
(1u64..1000000u64).prop_map(|n| n.to_string()),
(1u64..1000000u64, 1u32..9u32).prop_map(|(n, places)| {
let divisor = 10u64.pow(places);
let whole = n / divisor;
let frac = n % divisor;
if frac == 0 {
whole.to_string()
} else {
format!("{}.{:0>width$}", whole, frac, width = places as usize)
.trim_end_matches('0')
.to_string()
}
}),
(1u64..100000u64, 1u32..9u32).prop_map(|(n, places)| {
format!("0.{:0>width$}", n, width = places as usize)
.trim_end_matches('0')
.to_string()
}),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_decimal_roundtrip_preserves_value(s in decimal_string_strategy()) {
prop_assume!(!s.is_empty() && s != "0.");
let decimal = Decimal::from_str(&s);
prop_assume!(decimal.is_ok());
let decimal = decimal.unwrap();
let back = decimal.to_string();
let reparsed = Decimal::from_str(&back).unwrap();
prop_assert_eq!(
decimal, reparsed,
"Decimal value should be preserved after round-trip: {} -> {} -> {}",
s, back, reparsed
);
}
#[test]
fn prop_no_floating_point_errors(
whole in 0u64..1000000u64,
frac in 0u64..100000000u64,
places in 1u32..9u32
) {
let frac_normalized = frac % (10u64.pow(places));
let s = if frac_normalized == 0 {
whole.to_string()
} else {
format!("{}.{:0>width$}", whole, frac_normalized, width = places as usize)
};
let decimal = Decimal::from_str(&s);
prop_assume!(decimal.is_ok());
let decimal = decimal.unwrap();
let back = decimal.to_string();
let reparsed = Decimal::from_str(&back).unwrap();
prop_assert_eq!(
decimal, reparsed,
"No floating-point errors should occur"
);
}
#[test]
fn prop_ticker_price_precision(price_str in decimal_string_strategy()) {
use ccxt_exchanges::bitget::parse_ticker;
use serde_json::json;
prop_assume!(!price_str.is_empty() && price_str != "0.");
prop_assume!(Decimal::from_str(&price_str).is_ok());
let data = json!({
"symbol": "BTCUSDT",
"lastPr": price_str,
"ts": "1700000000000"
});
let ticker = parse_ticker(&data, None).expect("Failed to parse ticker");
if let Some(last) = ticker.last {
let original = Decimal::from_str(&price_str).unwrap();
prop_assert_eq!(
last.as_decimal(), original,
"Ticker price should preserve precision"
);
}
}
}
}
mod timestamp_conversion_consistency {
use super::*;
use ccxt_exchanges::bitget::{datetime_to_timestamp, timestamp_to_datetime};
fn timestamp_strategy() -> impl Strategy<Value = i64> {
1577836800000i64..1893456000000i64
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_timestamp_roundtrip(ts in timestamp_strategy()) {
let datetime = timestamp_to_datetime(ts);
prop_assert!(datetime.is_some(), "Should produce valid datetime string");
let datetime = datetime.unwrap();
let _back = datetime_to_timestamp(&datetime);
prop_assert!(
datetime.contains("T") && datetime.contains("Z"),
"Datetime should be in ISO8601 format: {}",
datetime
);
}
#[test]
fn prop_datetime_format_valid(ts in timestamp_strategy()) {
let datetime = timestamp_to_datetime(ts);
prop_assert!(datetime.is_some(), "Should produce valid datetime string");
let datetime = datetime.unwrap();
prop_assert!(datetime.contains("-"), "Should contain date separator");
prop_assert!(datetime.contains(":"), "Should contain time separator");
prop_assert!(datetime.contains("T"), "Should contain T separator");
prop_assert!(datetime.ends_with("Z"), "Should end with Z for UTC");
}
#[test]
fn prop_different_timestamps_different_datetimes(
ts1 in timestamp_strategy(),
ts2 in timestamp_strategy()
) {
prop_assume!(ts1 != ts2);
let dt1 = timestamp_to_datetime(ts1);
let dt2 = timestamp_to_datetime(ts2);
prop_assert!(dt1.is_some() && dt2.is_some());
prop_assert_ne!(
dt1.unwrap(),
dt2.unwrap(),
"Different timestamps should produce different datetime strings"
);
}
#[test]
fn prop_timestamp_preserved_in_ticker(ts in timestamp_strategy()) {
use ccxt_exchanges::bitget::parse_ticker;
use serde_json::json;
let data = json!({
"symbol": "BTCUSDT",
"lastPr": "50000.00",
"ts": ts.to_string()
});
let ticker = parse_ticker(&data, None).expect("Failed to parse ticker");
prop_assert_eq!(
ticker.timestamp, ts,
"Timestamp should be preserved in parsed ticker"
);
prop_assert!(ticker.datetime.is_some(), "Datetime should be set");
}
}
}
mod order_status_mapping_completeness {
use super::*;
use ccxt_core::types::OrderStatus;
use ccxt_exchanges::bitget::parse_order_status;
fn known_status_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("live".to_string()),
Just("new".to_string()),
Just("init".to_string()),
Just("partially_filled".to_string()),
Just("partial_fill".to_string()),
Just("partial-fill".to_string()),
Just("filled".to_string()),
Just("full_fill".to_string()),
Just("full-fill".to_string()),
Just("cancelled".to_string()),
Just("canceled".to_string()),
Just("cancel".to_string()),
Just("expired".to_string()),
Just("expire".to_string()),
Just("rejected".to_string()),
Just("reject".to_string()),
]
}
fn unknown_status_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z_-]{1,20}"
}
fn any_status_strategy() -> impl Strategy<Value = String> {
prop_oneof![known_status_strategy(), unknown_status_strategy(),]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_known_status_mapping(status in known_status_strategy()) {
let result = parse_order_status(&status);
match status.to_lowercase().as_str() {
"live" | "new" | "init" => {
prop_assert_eq!(result, OrderStatus::Open);
}
"partially_filled" | "partial_fill" | "partial-fill" => {
prop_assert_eq!(result, OrderStatus::Open);
}
"filled" | "full_fill" | "full-fill" => {
prop_assert_eq!(result, OrderStatus::Closed);
}
"cancelled" | "canceled" | "cancel" => {
prop_assert_eq!(result, OrderStatus::Cancelled);
}
"expired" | "expire" => {
prop_assert_eq!(result, OrderStatus::Expired);
}
"rejected" | "reject" => {
prop_assert_eq!(result, OrderStatus::Rejected);
}
_ => {}
}
}
#[test]
fn prop_any_status_no_panic(status in any_status_strategy()) {
let result = parse_order_status(&status);
prop_assert!(
matches!(
result,
OrderStatus::Open
| OrderStatus::Closed
| OrderStatus::Cancelled
| OrderStatus::Expired
| OrderStatus::Rejected
| OrderStatus::Partial
),
"Result should be a valid OrderStatus variant"
);
}
#[test]
fn prop_unknown_status_defaults_to_open(status in unknown_status_strategy()) {
let lower = status.to_lowercase();
prop_assume!(
!lower.contains("live")
&& !lower.contains("new")
&& !lower.contains("init")
&& !lower.contains("fill")
&& !lower.contains("cancel")
&& !lower.contains("expire")
&& !lower.contains("reject")
);
let result = parse_order_status(&status);
prop_assert_eq!(
result,
OrderStatus::Open,
"Unknown status '{}' should default to Open",
status
);
}
#[test]
fn prop_status_case_insensitive(status in known_status_strategy()) {
let lower = parse_order_status(&status.to_lowercase());
let upper = parse_order_status(&status.to_uppercase());
let mixed = parse_order_status(&status);
prop_assert_eq!(lower, upper, "Status parsing should be case insensitive");
prop_assert_eq!(lower, mixed, "Status parsing should be case insensitive");
}
}
}
mod data_roundtrip_consistency {
use super::*;
use ccxt_core::types::{
Market, MarketLimits, MarketPrecision, MarketType, Order, OrderSide, OrderStatus,
OrderType, Ticker, financial::Price,
};
use rust_decimal::Decimal;
fn decimal_strategy() -> impl Strategy<Value = Decimal> {
(1i64..1000000i64, 0u32..8u32).prop_map(|(n, scale)| Decimal::new(n, scale))
}
fn optional_decimal_strategy() -> impl Strategy<Value = Option<Decimal>> {
prop_oneof![Just(None), decimal_strategy().prop_map(Some),]
}
fn symbol_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("BTC/USDT".to_string()),
Just("ETH/USDT".to_string()),
Just("SOL/USDT".to_string()),
Just("DOGE/USDT".to_string()),
"[A-Z]{3,5}/[A-Z]{3,5}",
]
}
fn currency_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("BTC".to_string()),
Just("ETH".to_string()),
Just("USDT".to_string()),
Just("SOL".to_string()),
"[A-Z]{3,5}",
]
}
fn market_type_strategy() -> impl Strategy<Value = MarketType> {
prop_oneof![
Just(MarketType::Spot),
Just(MarketType::Futures),
Just(MarketType::Swap),
]
}
fn order_side_strategy() -> impl Strategy<Value = OrderSide> {
prop_oneof![Just(OrderSide::Buy), Just(OrderSide::Sell),]
}
fn order_type_strategy() -> impl Strategy<Value = OrderType> {
prop_oneof![
Just(OrderType::Market),
Just(OrderType::Limit),
Just(OrderType::StopLoss),
Just(OrderType::TakeProfit),
]
}
fn order_status_strategy() -> impl Strategy<Value = OrderStatus> {
prop_oneof![
Just(OrderStatus::Open),
Just(OrderStatus::Closed),
Just(OrderStatus::Cancelled),
Just(OrderStatus::Expired),
]
}
fn timestamp_strategy() -> impl Strategy<Value = i64> {
1577836800000i64..1893456000000i64
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_market_roundtrip(
id in "[A-Z]{3,10}",
symbol in symbol_strategy(),
base in currency_strategy(),
quote in currency_strategy(),
market_type in market_type_strategy(),
active in any::<bool>(),
maker in optional_decimal_strategy(),
taker in optional_decimal_strategy(),
) {
let market = Market {
id: id.clone(),
symbol: symbol.clone(),
parsed_symbol: None,
base: base.clone(),
quote: quote.clone(),
settle: None,
base_id: Some(base.clone()),
quote_id: Some(quote.clone()),
settle_id: None,
market_type,
active,
margin: false,
contract: Some(false),
linear: None,
inverse: None,
contract_size: None,
expiry: None,
expiry_datetime: None,
strike: None,
option_type: None,
precision: MarketPrecision::default(),
limits: MarketLimits::default(),
maker,
taker,
percentage: Some(true),
tier_based: Some(false),
fee_side: Some("quote".to_string()),
info: std::collections::HashMap::new(),
};
let json = serde_json::to_string(&market).expect("Failed to serialize Market");
let deserialized: Market = serde_json::from_str(&json).expect("Failed to deserialize Market");
prop_assert_eq!(market.id, deserialized.id);
prop_assert_eq!(market.symbol, deserialized.symbol);
prop_assert_eq!(market.base, deserialized.base);
prop_assert_eq!(market.quote, deserialized.quote);
prop_assert_eq!(market.market_type, deserialized.market_type);
prop_assert_eq!(market.active, deserialized.active);
prop_assert_eq!(market.maker, deserialized.maker);
prop_assert_eq!(market.taker, deserialized.taker);
}
#[test]
fn prop_ticker_roundtrip(
symbol in symbol_strategy(),
timestamp in timestamp_strategy(),
high in optional_decimal_strategy(),
low in optional_decimal_strategy(),
last in optional_decimal_strategy(),
bid in optional_decimal_strategy(),
ask in optional_decimal_strategy(),
) {
let ticker = Ticker {
symbol: symbol.clone(),
timestamp,
datetime: ccxt_exchanges::bitget::timestamp_to_datetime(timestamp),
high: high.map(Price::new),
low: low.map(Price::new),
bid: bid.map(Price::new),
bid_volume: None,
ask: ask.map(Price::new),
ask_volume: None,
vwap: None,
open: None,
close: last.map(Price::new),
last: last.map(Price::new),
previous_close: None,
change: None,
percentage: None,
average: None,
base_volume: None,
quote_volume: None,
info: std::collections::HashMap::new(),
funding_rate: None,
index_price: None,
mark_price: None,
open_interest: None,
};
let json = serde_json::to_string(&ticker).expect("Failed to serialize Ticker");
let deserialized: Ticker = serde_json::from_str(&json).expect("Failed to deserialize Ticker");
prop_assert_eq!(ticker.symbol, deserialized.symbol);
prop_assert_eq!(ticker.timestamp, deserialized.timestamp);
prop_assert_eq!(ticker.high, deserialized.high);
prop_assert_eq!(ticker.low, deserialized.low);
prop_assert_eq!(ticker.last, deserialized.last);
prop_assert_eq!(ticker.bid, deserialized.bid);
prop_assert_eq!(ticker.ask, deserialized.ask);
}
#[test]
fn prop_order_roundtrip(
id in "[0-9]{6,12}",
symbol in symbol_strategy(),
side in order_side_strategy(),
order_type in order_type_strategy(),
status in order_status_strategy(),
amount in decimal_strategy(),
price in optional_decimal_strategy(),
filled in optional_decimal_strategy(),
timestamp in timestamp_strategy(),
) {
let order = Order {
id: id.clone(),
client_order_id: None,
timestamp: Some(timestamp),
datetime: ccxt_exchanges::bitget::timestamp_to_datetime(timestamp),
last_trade_timestamp: None,
symbol: symbol.clone(),
order_type,
time_in_force: Some("GTC".to_string()),
post_only: None,
reduce_only: None,
side,
price,
stop_price: None,
trigger_price: None,
take_profit_price: None,
stop_loss_price: None,
trailing_delta: None,
trailing_percent: None,
activation_price: None,
callback_rate: None,
working_type: None,
amount,
filled,
remaining: filled.map(|f| amount - f),
cost: None,
average: None,
status,
fee: None,
fees: None,
trades: None,
info: std::collections::HashMap::new(),
};
let json = serde_json::to_string(&order).expect("Failed to serialize Order");
let deserialized: Order = serde_json::from_str(&json).expect("Failed to deserialize Order");
prop_assert_eq!(order.id, deserialized.id);
prop_assert_eq!(order.symbol, deserialized.symbol);
prop_assert_eq!(order.side, deserialized.side);
prop_assert_eq!(order.order_type, deserialized.order_type);
prop_assert_eq!(order.status, deserialized.status);
prop_assert_eq!(order.amount, deserialized.amount);
prop_assert_eq!(order.price, deserialized.price);
prop_assert_eq!(order.filled, deserialized.filled);
prop_assert_eq!(order.timestamp, deserialized.timestamp);
}
#[test]
fn prop_market_serialization_valid_json(
id in "[A-Z]{3,10}",
symbol in symbol_strategy(),
base in currency_strategy(),
quote in currency_strategy(),
) {
let market = Market {
id,
symbol,
parsed_symbol: None,
base: base.clone(),
quote: quote.clone(),
settle: None,
base_id: Some(base.clone()),
quote_id: Some(quote.clone()),
settle_id: None,
market_type: MarketType::Spot,
active: true,
margin: false,
contract: Some(false),
linear: None,
inverse: None,
contract_size: None,
expiry: None,
expiry_datetime: None,
strike: None,
option_type: None,
precision: MarketPrecision::default(),
limits: MarketLimits::default(),
maker: None,
taker: None,
percentage: Some(true),
tier_based: Some(false),
fee_side: Some("quote".to_string()),
info: std::collections::HashMap::new(),
};
let json = serde_json::to_string(&market);
prop_assert!(json.is_ok(), "Market should serialize to valid JSON");
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&json.unwrap());
prop_assert!(parsed.is_ok(), "Serialized Market should be valid JSON");
}
}
}
mod market_cache_consistency {
use super::*;
use ccxt_core::types::{Market, MarketLimits, MarketPrecision, MarketType};
use ccxt_exchanges::bitget::Bitget;
fn create_market(index: usize) -> Market {
let base = format!("COIN{}", index);
let quote = "USDT".to_string();
let symbol = format!("{}/{}", base, quote);
let id = format!("{}USDT", base);
Market {
id,
symbol,
parsed_symbol: None,
base: base.clone(),
quote: quote.clone(),
settle: None,
base_id: Some(base.clone()),
quote_id: Some(quote.clone()),
settle_id: None,
market_type: MarketType::Spot,
active: true,
margin: false,
contract: Some(false),
linear: None,
inverse: None,
contract_size: None,
expiry: None,
expiry_datetime: None,
strike: None,
option_type: None,
precision: MarketPrecision::default(),
limits: MarketLimits::default(),
maker: None,
taker: None,
percentage: Some(true),
tier_based: Some(false),
fee_side: Some("quote".to_string()),
info: std::collections::HashMap::new(),
}
}
fn markets_strategy() -> impl Strategy<Value = Vec<Market>> {
(1usize..10usize).prop_map(|count| (0..count).map(create_market).collect())
}
fn two_markets_strategy() -> impl Strategy<Value = (Vec<Market>, Vec<Market>)> {
((1usize..5usize), (1usize..5usize)).prop_map(|(count1, count2)| {
let markets1: Vec<Market> = (0..count1).map(create_market).collect();
let markets2: Vec<Market> = (100..(100 + count2)).map(create_market).collect();
(markets1, markets2)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_cache_set_and_read_consistent(markets in markets_strategy()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let bitget = Bitget::builder().build().expect("Failed to build Bitget");
bitget.base().set_markets(markets.clone(), None).await
.expect("Failed to set markets");
let cache = bitget.base().market_cache.read().await;
for market in &markets {
let cached = cache.get_market(&market.symbol);
assert!(cached.is_some(), "Market {} should be in cache", market.symbol);
let cached = cached.unwrap();
assert_eq!(cached.id, market.id, "Market ID should match");
assert_eq!(cached.symbol, market.symbol, "Market symbol should match");
assert_eq!(cached.base, market.base, "Market base should match");
assert_eq!(cached.quote, market.quote, "Market quote should match");
}
assert!(cache.is_loaded(), "Cache should be marked as loaded");
});
}
#[test]
fn prop_multiple_cache_reads_identical(markets in markets_strategy()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let bitget = Bitget::builder().build().expect("Failed to build Bitget");
bitget.base().set_markets(markets.clone(), None).await
.expect("Failed to set markets");
let read1 = {
let cache = bitget.base().market_cache.read().await;
cache.markets()
};
let read2 = {
let cache = bitget.base().market_cache.read().await;
cache.markets()
};
let read3 = {
let cache = bitget.base().market_cache.read().await;
cache.markets()
};
assert_eq!(read1.len(), read2.len(), "Cache reads should have same length");
assert_eq!(read2.len(), read3.len(), "Cache reads should have same length");
for (symbol, market1) in read1.iter() {
let market2 = read2.get(symbol).expect("Market should exist in read2");
let market3 = read3.get(symbol).expect("Market should exist in read3");
assert_eq!(market1.id, market2.id, "Market ID should be identical across reads");
assert_eq!(market2.id, market3.id, "Market ID should be identical across reads");
assert_eq!(market1.symbol, market2.symbol, "Market symbol should be identical across reads");
assert_eq!(market2.symbol, market3.symbol, "Market symbol should be identical across reads");
}
});
}
#[test]
fn prop_markets_by_id_consistent(markets in markets_strategy()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let bitget = Bitget::builder().build().expect("Failed to build Bitget");
bitget.base().set_markets(markets.clone(), None).await
.expect("Failed to set markets");
let cache = bitget.base().market_cache.read().await;
for market in &markets {
let markets_map = cache.markets();
let by_symbol = markets_map.get(&market.symbol);
assert!(by_symbol.is_some(), "Market should be indexed by symbol");
let by_id = cache.get_market_by_id(&market.id);
assert!(by_id.is_some(), "Market should be indexed by ID");
let by_symbol = by_symbol.unwrap();
let by_id = by_id.unwrap();
assert_eq!(by_symbol.id, by_id.id, "Market ID should match between indexes");
assert_eq!(by_symbol.symbol, by_id.symbol, "Market symbol should match between indexes");
}
});
}
#[test]
fn prop_symbols_list_consistent(markets in markets_strategy()) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let bitget = Bitget::builder().build().expect("Failed to build Bitget");
bitget.base().set_markets(markets.clone(), None).await
.expect("Failed to set markets");
let cache = bitget.base().market_cache.read().await;
for market in &markets {
assert!(
cache.symbols().contains(&market.symbol),
"Symbols list should contain {}",
market.symbol
);
}
let unique_symbols: std::collections::HashSet<_> = markets.iter()
.map(|m| m.symbol.clone())
.collect();
assert_eq!(
cache.symbols().len(),
unique_symbols.len(),
"Symbols list length should match unique market count"
);
});
}
#[test]
fn prop_cache_reload_clears_old_data(
(markets1, markets2) in two_markets_strategy()
) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let bitget = Bitget::builder().build().expect("Failed to build Bitget");
bitget.base().set_markets(markets1.clone(), None).await
.expect("Failed to set markets");
{
let cache = bitget.base().market_cache.read().await;
assert!(cache.is_loaded(), "Cache should be loaded after first set");
}
bitget.base().set_markets(markets2.clone(), None).await
.expect("Failed to set markets");
let cache = bitget.base().market_cache.read().await;
let unique_symbols2: std::collections::HashSet<_> = markets2.iter()
.map(|m| m.symbol.clone())
.collect();
for symbol in &unique_symbols2 {
assert!(
cache.markets().contains_key(symbol),
"New market {} should be in cache",
symbol
);
}
let unique_symbols1: std::collections::HashSet<_> = markets1.iter()
.map(|m| m.symbol.clone())
.collect();
for symbol in &unique_symbols1 {
if !unique_symbols2.contains(symbol) {
assert!(
!cache.markets().contains_key(symbol),
"Old market {} should NOT be in cache after reload",
symbol
);
}
}
});
}
}
}
mod exchange_trait_metadata_consistency {
use super::*;
use ccxt_core::exchange::Exchange;
use ccxt_exchanges::bitget::BitgetBuilder;
use std::time::Duration;
fn api_key_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{8,64}"
}
fn secret_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{16,128}"
}
fn passphrase_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9]{6,32}"
}
fn product_type_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("spot".to_string()),
Just("umcbl".to_string()),
Just("dmcbl".to_string()),
]
}
fn recv_window_strategy() -> impl Strategy<Value = u64> {
1000u64..60000u64
}
fn timeout_strategy() -> impl Strategy<Value = u64> {
1u64..300u64
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_id_always_bitget(
api_key in proptest::option::of(api_key_strategy()),
secret in proptest::option::of(secret_strategy()),
passphrase in proptest::option::of(passphrase_strategy()),
sandbox in any::<bool>(),
product_type in product_type_strategy(),
recv_window in recv_window_strategy(),
timeout in timeout_strategy(),
) {
let mut builder = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.recv_window(recv_window)
.timeout(Duration::from_secs(timeout));
if let Some(key) = api_key {
builder = builder.api_key(&key);
}
if let Some(sec) = secret {
builder = builder.secret(&sec);
}
if let Some(pass) = passphrase {
builder = builder.passphrase(&pass);
}
let bitget = builder.build().expect("Failed to build Bitget");
let exchange: &dyn Exchange = &bitget;
prop_assert_eq!(exchange.id(), "bitget", "id() should always return 'bitget'");
}
#[test]
fn prop_name_always_bitget(
api_key in proptest::option::of(api_key_strategy()),
secret in proptest::option::of(secret_strategy()),
passphrase in proptest::option::of(passphrase_strategy()),
sandbox in any::<bool>(),
product_type in product_type_strategy(),
recv_window in recv_window_strategy(),
timeout in timeout_strategy(),
) {
let mut builder = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.recv_window(recv_window)
.timeout(Duration::from_secs(timeout));
if let Some(key) = api_key {
builder = builder.api_key(&key);
}
if let Some(sec) = secret {
builder = builder.secret(&sec);
}
if let Some(pass) = passphrase {
builder = builder.passphrase(&pass);
}
let bitget = builder.build().expect("Failed to build Bitget");
let exchange: &dyn Exchange = &bitget;
prop_assert_eq!(exchange.name(), "Bitget", "name() should always return 'Bitget'");
}
#[test]
fn prop_metadata_consistent_across_configs(
api_key in proptest::option::of(api_key_strategy()),
secret in proptest::option::of(secret_strategy()),
passphrase in proptest::option::of(passphrase_strategy()),
sandbox in any::<bool>(),
product_type in product_type_strategy(),
recv_window in recv_window_strategy(),
timeout in timeout_strategy(),
) {
let mut builder = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.recv_window(recv_window)
.timeout(Duration::from_secs(timeout));
if let Some(key) = api_key {
builder = builder.api_key(&key);
}
if let Some(sec) = secret {
builder = builder.secret(&sec);
}
if let Some(pass) = passphrase {
builder = builder.passphrase(&pass);
}
let bitget = builder.build().expect("Failed to build Bitget");
let exchange: &dyn Exchange = &bitget;
prop_assert_eq!(exchange.id(), "bitget");
prop_assert_eq!(exchange.name(), "Bitget");
prop_assert_eq!(exchange.version(), "v2");
prop_assert!(!exchange.certified(), "Bitget should not be certified");
prop_assert!(exchange.has_websocket(), "Bitget should have websocket support");
}
#[test]
fn prop_metadata_via_boxed_trait_object(
sandbox in any::<bool>(),
product_type in product_type_strategy(),
) {
let bitget = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.build()
.expect("Failed to build Bitget");
let exchange: Box<dyn Exchange> = Box::new(bitget);
prop_assert_eq!(exchange.id(), "bitget");
prop_assert_eq!(exchange.name(), "Bitget");
}
#[test]
fn prop_rate_limit_consistent(
sandbox in any::<bool>(),
product_type in product_type_strategy(),
timeout in timeout_strategy(),
) {
let bitget = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.timeout(Duration::from_secs(timeout))
.build()
.expect("Failed to build Bitget");
let exchange: &dyn Exchange = &bitget;
prop_assert!(
exchange.rate_limit() == 20,
"rate_limit() should return 20"
);
}
#[test]
fn prop_capabilities_consistent(
sandbox in any::<bool>(),
product_type in product_type_strategy(),
) {
let bitget = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.build()
.expect("Failed to build Bitget");
let exchange: &dyn Exchange = &bitget;
let caps = exchange.capabilities();
prop_assert!(caps.fetch_markets(), "fetch_markets should be supported");
prop_assert!(caps.fetch_ticker(), "fetch_ticker should be supported");
prop_assert!(caps.fetch_tickers(), "fetch_tickers should be supported");
prop_assert!(caps.fetch_order_book(), "fetch_order_book should be supported");
prop_assert!(caps.fetch_trades(), "fetch_trades should be supported");
prop_assert!(caps.fetch_ohlcv(), "fetch_ohlcv should be supported");
prop_assert!(caps.create_order(), "create_order should be supported");
prop_assert!(caps.cancel_order(), "cancel_order should be supported");
prop_assert!(caps.fetch_balance(), "fetch_balance should be supported");
prop_assert!(caps.websocket(), "websocket should be supported");
prop_assert!(caps.watch_ticker(), "watch_ticker should be supported");
prop_assert!(caps.watch_order_book(), "watch_order_book should be supported");
prop_assert!(caps.watch_trades(), "watch_trades should be supported");
}
#[test]
fn prop_timeframes_consistent(
sandbox in any::<bool>(),
product_type in product_type_strategy(),
) {
use ccxt_core::types::Timeframe;
let bitget = BitgetBuilder::new()
.sandbox(sandbox)
.product_type(&product_type)
.build()
.expect("Failed to build Bitget");
let exchange: &dyn Exchange = &bitget;
let timeframes = exchange.timeframes();
prop_assert!(!timeframes.is_empty(), "timeframes should not be empty");
prop_assert!(timeframes.contains(&Timeframe::M1), "should support 1m timeframe");
prop_assert!(timeframes.contains(&Timeframe::M5), "should support 5m timeframe");
prop_assert!(timeframes.contains(&Timeframe::H1), "should support 1h timeframe");
prop_assert!(timeframes.contains(&Timeframe::D1), "should support 1d timeframe");
}
}
}