nautilus_binance/futures/
conversions.rs1use nautilus_core::UnixNanos;
19use nautilus_model::{enums::OrderSide, types::Currency};
20use rust_decimal::Decimal;
21
22use crate::common::{enums::BinancePositionSide, parse::parse_millis};
23
24const BNFCR_ASSET: &str = "BNFCR";
25
26#[must_use]
33pub(crate) fn normalize_futures_asset<T: AsRef<str>>(
34 asset: T,
35 bnfcr_currency: Currency,
36) -> Currency {
37 let code = asset.as_ref().trim();
38 if code.eq_ignore_ascii_case(BNFCR_ASSET) {
39 bnfcr_currency
40 } else {
41 Currency::get_or_create_crypto_with_context(code, Some("futures asset"))
42 }
43}
44
45#[must_use]
53pub(crate) fn determine_position_side(
54 is_hedge_mode: bool,
55 order_side: OrderSide,
56 is_closing: bool,
57) -> Option<BinancePositionSide> {
58 if !is_hedge_mode {
59 return None;
60 }
61
62 Some(if is_closing {
63 match order_side {
64 OrderSide::Buy => BinancePositionSide::Short,
65 OrderSide::Sell => BinancePositionSide::Long,
66 }
67 } else {
68 match order_side {
69 OrderSide::Buy => BinancePositionSide::Long,
70 OrderSide::Sell => BinancePositionSide::Short,
71 }
72 })
73}
74
75#[must_use]
76pub(crate) const fn reduce_only_param(
77 reduce_only: bool,
78 position_side: Option<BinancePositionSide>,
79) -> Option<bool> {
80 if reduce_only && position_side.is_none() {
82 Some(true)
83 } else {
84 None
85 }
86}
87
88pub(crate) fn trailing_offset_to_callback_rate(offset: Decimal) -> anyhow::Result<Decimal> {
95 let rate = offset / rust_decimal::Decimal::ONE_HUNDRED;
96 let min_rate = rust_decimal::Decimal::new(1, 1);
97 let max_rate = rust_decimal::Decimal::new(100, 1);
98
99 if rate < min_rate || rate > max_rate {
100 anyhow::bail!("callbackRate {rate}% out of Binance range [{min_rate}, {max_rate}]");
101 }
102
103 Ok(rate)
104}
105
106pub(crate) fn trailing_offset_to_callback_rate_string(offset: Decimal) -> anyhow::Result<String> {
112 let rate = trailing_offset_to_callback_rate(offset)?;
113 Ok(format_callback_rate(rate))
114}
115
116#[must_use]
120pub(crate) fn format_callback_rate(rate: Decimal) -> String {
121 let normalized = rate.normalize();
122
123 if normalized.scale() == 0 {
124 format!("{normalized}.0")
125 } else {
126 normalized.to_string()
127 }
128}
129
130pub(crate) fn parse_good_till_date(value: Option<i64>) -> anyhow::Result<Option<UnixNanos>> {
131 let Some(value) = value.filter(|value| *value != 0) else {
132 return Ok(None);
133 };
134
135 parse_millis(value, "goodTillDate").map(Some)
136}
137
138#[cfg(test)]
139mod tests {
140 use nautilus_model::enums::CurrencyType;
141 use rstest::rstest;
142
143 use super::*;
144
145 #[rstest]
146 fn test_trailing_offset_to_callback_rate_preserves_precision() {
147 let rate = trailing_offset_to_callback_rate(Decimal::from(25)).unwrap();
148 assert_eq!(rate, Decimal::new(25, 2));
149 }
150
151 #[rstest]
152 fn test_trailing_offset_to_callback_rate_string_formats_whole_percent() {
153 let rate = trailing_offset_to_callback_rate_string(Decimal::from(100)).unwrap();
154 assert_eq!(rate, "1.0");
155 }
156
157 #[rstest]
158 fn test_trailing_offset_to_callback_rate_rejects_out_of_range_values() {
159 let error = trailing_offset_to_callback_rate(Decimal::from(5)).unwrap_err();
160 assert_eq!(
161 error.to_string(),
162 "callbackRate 0.05% out of Binance range [0.1, 10.0]"
163 );
164 }
165
166 #[rstest]
167 #[case::missing(None)]
168 #[case::zero(Some(0))]
169 fn test_parse_good_till_date_omits_missing_expiry(#[case] value: Option<i64>) {
170 assert_eq!(parse_good_till_date(value).unwrap(), None);
171 }
172
173 #[rstest]
174 fn test_parse_good_till_date_preserves_milliseconds() {
175 let value = 1_700_000_000_000;
176 assert_eq!(
177 parse_good_till_date(Some(value)).unwrap(),
178 Some(UnixNanos::from_millis(value as u64)),
179 );
180 }
181
182 #[rstest]
183 #[case::negative(-1, "invalid negative Binance goodTillDate")]
184 #[case::overflow(i64::MAX, "outside the UnixNanos range")]
185 fn test_parse_good_till_date_rejects_invalid_values(
186 #[case] value: i64,
187 #[case] expected: &str,
188 ) {
189 let error = parse_good_till_date(Some(value)).unwrap_err();
190 assert!(error.to_string().contains(expected));
191 }
192
193 #[rstest]
194 #[case::one_way_buy(false, OrderSide::Buy, false, None)]
195 #[case::one_way_sell(false, OrderSide::Sell, false, None)]
196 #[case::one_way_buy_reduce(false, OrderSide::Buy, true, None)]
197 #[case::hedge_open_buy(true, OrderSide::Buy, false, Some(BinancePositionSide::Long))]
198 #[case::hedge_open_sell(true, OrderSide::Sell, false, Some(BinancePositionSide::Short))]
199 #[case::hedge_close_buy(true, OrderSide::Buy, true, Some(BinancePositionSide::Short))]
200 #[case::hedge_close_sell(true, OrderSide::Sell, true, Some(BinancePositionSide::Long))]
201 fn test_determine_position_side(
202 #[case] is_hedge_mode: bool,
203 #[case] order_side: OrderSide,
204 #[case] is_closing: bool,
205 #[case] expected: Option<BinancePositionSide>,
206 ) {
207 assert_eq!(
208 determine_position_side(is_hedge_mode, order_side, is_closing),
209 expected,
210 );
211 }
212
213 #[rstest]
214 #[case::one_way(false, None, None)]
215 #[case::one_way_reduce(true, None, Some(true))]
216 #[case::hedge_open(false, Some(BinancePositionSide::Long), None)]
217 #[case::hedge_close_long(true, Some(BinancePositionSide::Long), None)]
218 #[case::hedge_close_short(true, Some(BinancePositionSide::Short), None)]
219 fn test_reduce_only_param(
220 #[case] reduce_only: bool,
221 #[case] position_side: Option<BinancePositionSide>,
222 #[case] expected: Option<bool>,
223 ) {
224 assert_eq!(reduce_only_param(reduce_only, position_side), expected);
225 }
226
227 #[rstest]
228 #[case::bnfcr_to_usdt("BNFCR", Currency::USDT(), Currency::USDT())]
229 #[case::bnfcr_to_usdc("BNFCR", Currency::USDC(), Currency::USDC())]
230 #[case::bnfcr_trim_and_case(" bnfcr ", Currency::USDC(), Currency::USDC())]
231 #[case::known_asset_bypasses_alias("USDT", Currency::USDC(), Currency::USDT())]
232 fn test_normalize_futures_asset_resolves_currency(
233 #[case] asset: &str,
234 #[case] bnfcr_currency: Currency,
235 #[case] expected: Currency,
236 ) {
237 assert_eq!(normalize_futures_asset(asset, bnfcr_currency), expected);
238 }
239
240 #[rstest]
241 fn test_normalize_futures_asset_registers_unknown_as_crypto() {
242 let currency = normalize_futures_asset("XYZ", Currency::USDT());
243
244 assert_eq!(currency.code.as_str(), "XYZ");
245 assert_eq!(currency.currency_type, CurrencyType::Crypto);
246 }
247}