Skip to main content

nautilus_binance/futures/
conversions.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Value conversions between Nautilus domain types and Binance Futures venue types.
17
18use 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/// Resolves a Binance Futures asset code to a Nautilus [`Currency`].
27///
28/// In Credits Trading Mode (EU), the futures wallet is denominated in `BNFCR`, a
29/// USD-pegged credit unit absent from the currency table; it resolves to
30/// `bnfcr_currency` so the account reconciles against stablecoin-settled instruments.
31/// Any other unrecognized asset is registered as a generic crypto rather than panicking.
32#[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/// Determines the Binance `positionSide` for hedge mode from the Nautilus order side.
46///
47/// Returns `None` when not in hedge mode (one-way mode orders omit `positionSide`).
48/// In hedge mode, `is_closing` flips the mapping so that Buy closes Short and
49/// Sell closes Long. Close intent comes from the Nautilus `reduce_only` flag for
50/// explicit-quantity orders and from the `close_position` order parameter for
51/// whole-leg exits, which cannot carry `reduce_only`.
52#[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    // Binance rejects reduceOnly when positionSide is present in hedge mode
81    if reduce_only && position_side.is_none() {
82        Some(true)
83    } else {
84        None
85    }
86}
87
88/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` decimal.
89///
90/// # Errors
91///
92/// Returns an error if the computed rate is outside the Binance accepted range
93/// `[0.1%, 10.0%]`.
94pub(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
106/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` string.
107///
108/// # Errors
109///
110/// Returns an error if the computed rate is outside the Binance accepted range.
111pub(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/// Formats a `callbackRate` decimal for Binance request params.
117///
118/// Whole percents are rendered with a trailing `.0` to match Binance examples.
119#[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}