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_model::{enums::OrderSide, types::Currency};
19use rust_decimal::Decimal;
20
21use crate::common::enums::BinancePositionSide;
22
23const BNFCR_ASSET: &str = "BNFCR";
24
25/// Resolves a Binance Futures asset code to a Nautilus [`Currency`].
26///
27/// In Credits Trading Mode (EU), the futures wallet is denominated in `BNFCR`, a
28/// USD-pegged credit unit absent from the currency table; it resolves to
29/// `bnfcr_currency` so the account reconciles against stablecoin-settled instruments.
30/// Any other unrecognized asset is registered as a generic crypto rather than panicking.
31#[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/// Determines the Binance `positionSide` for hedge mode from the Nautilus order side.
45///
46/// Returns `None` when not in hedge mode (one-way mode orders omit `positionSide`).
47/// In hedge mode, `reduce_only` flips the mapping so that Buy closes Short and
48/// Sell closes Long.
49#[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    // Binance rejects reduceOnly when positionSide is present in hedge mode
80    if reduce_only && position_side.is_none() {
81        Some(true)
82    } else {
83        None
84    }
85}
86
87/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` decimal.
88///
89/// # Errors
90///
91/// Returns an error if the computed rate is outside the Binance accepted range
92/// `[0.1%, 10.0%]`.
93pub(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
105/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` string.
106///
107/// # Errors
108///
109/// Returns an error if the computed rate is outside the Binance accepted range.
110pub(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/// Formats a `callbackRate` decimal for Binance request params.
116///
117/// Whole percents are rendered with a trailing `.0` to match Binance examples.
118#[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}