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;
19use rust_decimal::Decimal;
20
21use crate::common::enums::BinancePositionSide;
22
23/// Determines the Binance `positionSide` for hedge mode from the Nautilus order side.
24///
25/// Returns `None` when not in hedge mode (one-way mode orders omit `positionSide`).
26/// In hedge mode, `reduce_only` flips the mapping so that Buy closes Short and
27/// Sell closes Long.
28#[must_use]
29pub(crate) fn determine_position_side(
30    is_hedge_mode: bool,
31    order_side: OrderSide,
32    reduce_only: bool,
33) -> Option<BinancePositionSide> {
34    if !is_hedge_mode {
35        return None;
36    }
37
38    Some(if reduce_only {
39        match order_side {
40            OrderSide::Buy => BinancePositionSide::Short,
41            OrderSide::Sell => BinancePositionSide::Long,
42            _ => BinancePositionSide::Both,
43        }
44    } else {
45        match order_side {
46            OrderSide::Buy => BinancePositionSide::Long,
47            OrderSide::Sell => BinancePositionSide::Short,
48            _ => BinancePositionSide::Both,
49        }
50    })
51}
52
53/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` decimal.
54///
55/// # Errors
56///
57/// Returns an error if the computed rate is outside the Binance accepted range
58/// `[0.1%, 10.0%]`.
59pub(crate) fn trailing_offset_to_callback_rate(offset: Decimal) -> anyhow::Result<Decimal> {
60    let rate = offset / rust_decimal::Decimal::ONE_HUNDRED;
61    let min_rate = rust_decimal::Decimal::new(1, 1);
62    let max_rate = rust_decimal::Decimal::new(100, 1);
63
64    if rate < min_rate || rate > max_rate {
65        anyhow::bail!("callbackRate {rate}% out of Binance range [{min_rate}, {max_rate}]");
66    }
67
68    Ok(rate)
69}
70
71/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` string.
72///
73/// # Errors
74///
75/// Returns an error if the computed rate is outside the Binance accepted range.
76pub(crate) fn trailing_offset_to_callback_rate_string(offset: Decimal) -> anyhow::Result<String> {
77    let rate = trailing_offset_to_callback_rate(offset)?;
78    Ok(format_callback_rate(rate))
79}
80
81/// Formats a `callbackRate` decimal for Binance request params.
82///
83/// Whole percents are rendered with a trailing `.0` to match Binance examples.
84#[must_use]
85pub(crate) fn format_callback_rate(rate: Decimal) -> String {
86    let normalized = rate.normalize();
87
88    if normalized.scale() == 0 {
89        format!("{normalized}.0")
90    } else {
91        normalized.to_string()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use rstest::rstest;
98
99    use super::*;
100
101    #[rstest]
102    fn test_trailing_offset_to_callback_rate_preserves_precision() {
103        let rate = trailing_offset_to_callback_rate(Decimal::from(25)).unwrap();
104        assert_eq!(rate, Decimal::new(25, 2));
105    }
106
107    #[rstest]
108    fn test_trailing_offset_to_callback_rate_string_formats_whole_percent() {
109        let rate = trailing_offset_to_callback_rate_string(Decimal::from(100)).unwrap();
110        assert_eq!(rate, "1.0");
111    }
112
113    #[rstest]
114    fn test_trailing_offset_to_callback_rate_rejects_out_of_range_values() {
115        let error = trailing_offset_to_callback_rate(Decimal::from(5)).unwrap_err();
116        assert_eq!(
117            error.to_string(),
118            "callbackRate 0.05% out of Binance range [0.1, 10.0]"
119        );
120    }
121
122    #[rstest]
123    #[case::one_way_buy(false, OrderSide::Buy, false, None)]
124    #[case::one_way_sell(false, OrderSide::Sell, false, None)]
125    #[case::one_way_buy_reduce(false, OrderSide::Buy, true, None)]
126    #[case::hedge_open_buy(true, OrderSide::Buy, false, Some(BinancePositionSide::Long))]
127    #[case::hedge_open_sell(true, OrderSide::Sell, false, Some(BinancePositionSide::Short))]
128    #[case::hedge_close_buy(true, OrderSide::Buy, true, Some(BinancePositionSide::Short))]
129    #[case::hedge_close_sell(true, OrderSide::Sell, true, Some(BinancePositionSide::Long))]
130    #[case::hedge_no_side(true, OrderSide::NoOrderSide, false, Some(BinancePositionSide::Both))]
131    fn test_determine_position_side(
132        #[case] is_hedge_mode: bool,
133        #[case] order_side: OrderSide,
134        #[case] reduce_only: bool,
135        #[case] expected: Option<BinancePositionSide>,
136    ) {
137        assert_eq!(
138            determine_position_side(is_hedge_mode, order_side, reduce_only),
139            expected,
140        );
141    }
142}