nautilus_binance/futures/
conversions.rs1use nautilus_model::{enums::OrderSide, types::Currency};
19use rust_decimal::Decimal;
20
21use crate::common::enums::BinancePositionSide;
22
23const BNFCR_ASSET: &str = "BNFCR";
24
25#[must_use]
32pub(crate) fn normalize_futures_asset<T: AsRef<str>>(
33 asset: T,
34 bnfcr_currency: Currency,
35) -> Currency {
36 let code = asset.as_ref().trim();
37 if code.eq_ignore_ascii_case(BNFCR_ASSET) {
38 bnfcr_currency
39 } else {
40 Currency::get_or_create_crypto_with_context(code, Some("futures asset"))
41 }
42}
43
44#[must_use]
50pub(crate) fn determine_position_side(
51 is_hedge_mode: bool,
52 order_side: OrderSide,
53 reduce_only: bool,
54) -> Option<BinancePositionSide> {
55 if !is_hedge_mode {
56 return None;
57 }
58
59 Some(if reduce_only {
60 match order_side {
61 OrderSide::Buy => BinancePositionSide::Short,
62 OrderSide::Sell => BinancePositionSide::Long,
63 _ => BinancePositionSide::Both,
64 }
65 } else {
66 match order_side {
67 OrderSide::Buy => BinancePositionSide::Long,
68 OrderSide::Sell => BinancePositionSide::Short,
69 _ => BinancePositionSide::Both,
70 }
71 })
72}
73
74#[must_use]
75pub(crate) const fn reduce_only_param(
76 reduce_only: bool,
77 position_side: Option<BinancePositionSide>,
78) -> Option<bool> {
79 if reduce_only && position_side.is_none() {
81 Some(true)
82 } else {
83 None
84 }
85}
86
87pub(crate) fn trailing_offset_to_callback_rate(offset: Decimal) -> anyhow::Result<Decimal> {
94 let rate = offset / rust_decimal::Decimal::ONE_HUNDRED;
95 let min_rate = rust_decimal::Decimal::new(1, 1);
96 let max_rate = rust_decimal::Decimal::new(100, 1);
97
98 if rate < min_rate || rate > max_rate {
99 anyhow::bail!("callbackRate {rate}% out of Binance range [{min_rate}, {max_rate}]");
100 }
101
102 Ok(rate)
103}
104
105pub(crate) fn trailing_offset_to_callback_rate_string(offset: Decimal) -> anyhow::Result<String> {
111 let rate = trailing_offset_to_callback_rate(offset)?;
112 Ok(format_callback_rate(rate))
113}
114
115#[must_use]
119pub(crate) fn format_callback_rate(rate: Decimal) -> String {
120 let normalized = rate.normalize();
121
122 if normalized.scale() == 0 {
123 format!("{normalized}.0")
124 } else {
125 normalized.to_string()
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use nautilus_model::enums::CurrencyType;
132 use rstest::rstest;
133
134 use super::*;
135
136 #[rstest]
137 fn test_trailing_offset_to_callback_rate_preserves_precision() {
138 let rate = trailing_offset_to_callback_rate(Decimal::from(25)).unwrap();
139 assert_eq!(rate, Decimal::new(25, 2));
140 }
141
142 #[rstest]
143 fn test_trailing_offset_to_callback_rate_string_formats_whole_percent() {
144 let rate = trailing_offset_to_callback_rate_string(Decimal::from(100)).unwrap();
145 assert_eq!(rate, "1.0");
146 }
147
148 #[rstest]
149 fn test_trailing_offset_to_callback_rate_rejects_out_of_range_values() {
150 let error = trailing_offset_to_callback_rate(Decimal::from(5)).unwrap_err();
151 assert_eq!(
152 error.to_string(),
153 "callbackRate 0.05% out of Binance range [0.1, 10.0]"
154 );
155 }
156
157 #[rstest]
158 #[case::one_way_buy(false, OrderSide::Buy, false, None)]
159 #[case::one_way_sell(false, OrderSide::Sell, false, None)]
160 #[case::one_way_buy_reduce(false, OrderSide::Buy, true, None)]
161 #[case::hedge_open_buy(true, OrderSide::Buy, false, Some(BinancePositionSide::Long))]
162 #[case::hedge_open_sell(true, OrderSide::Sell, false, Some(BinancePositionSide::Short))]
163 #[case::hedge_close_buy(true, OrderSide::Buy, true, Some(BinancePositionSide::Short))]
164 #[case::hedge_close_sell(true, OrderSide::Sell, true, Some(BinancePositionSide::Long))]
165 #[case::hedge_no_side(true, OrderSide::NoOrderSide, false, Some(BinancePositionSide::Both))]
166 fn test_determine_position_side(
167 #[case] is_hedge_mode: bool,
168 #[case] order_side: OrderSide,
169 #[case] reduce_only: bool,
170 #[case] expected: Option<BinancePositionSide>,
171 ) {
172 assert_eq!(
173 determine_position_side(is_hedge_mode, order_side, reduce_only),
174 expected,
175 );
176 }
177
178 #[rstest]
179 #[case::one_way(false, None, None)]
180 #[case::one_way_reduce(true, None, Some(true))]
181 #[case::hedge_open(false, Some(BinancePositionSide::Long), None)]
182 #[case::hedge_close_long(true, Some(BinancePositionSide::Long), None)]
183 #[case::hedge_close_short(true, Some(BinancePositionSide::Short), None)]
184 fn test_reduce_only_param(
185 #[case] reduce_only: bool,
186 #[case] position_side: Option<BinancePositionSide>,
187 #[case] expected: Option<bool>,
188 ) {
189 assert_eq!(reduce_only_param(reduce_only, position_side), expected);
190 }
191
192 #[rstest]
193 #[case::bnfcr_to_usdt("BNFCR", Currency::USDT(), Currency::USDT())]
194 #[case::bnfcr_to_usdc("BNFCR", Currency::USDC(), Currency::USDC())]
195 #[case::bnfcr_trim_and_case(" bnfcr ", Currency::USDC(), Currency::USDC())]
196 #[case::known_asset_bypasses_alias("USDT", Currency::USDC(), Currency::USDT())]
197 fn test_normalize_futures_asset_resolves_currency(
198 #[case] asset: &str,
199 #[case] bnfcr_currency: Currency,
200 #[case] expected: Currency,
201 ) {
202 assert_eq!(normalize_futures_asset(asset, bnfcr_currency), expected);
203 }
204
205 #[rstest]
206 fn test_normalize_futures_asset_registers_unknown_as_crypto() {
207 let currency = normalize_futures_asset("XYZ", Currency::USDT());
208
209 assert_eq!(currency.code.as_str(), "XYZ");
210 assert_eq!(currency.currency_type, CurrencyType::Crypto);
211 }
212}