nautilus-binance 0.59.0

Binance exchange integration adapter for the Nautilus trading engine
// -------------------------------------------------------------------------------------------------
//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
//  https://nautechsystems.io
//
//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
//  You may not use this file except in compliance with the License.
//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
// -------------------------------------------------------------------------------------------------

//! Value conversions between Nautilus domain types and Binance Futures venue types.

use nautilus_model::{enums::OrderSide, types::Currency};
use rust_decimal::Decimal;

use crate::common::enums::BinancePositionSide;

const BNFCR_ASSET: &str = "BNFCR";

/// Resolves a Binance Futures asset code to a Nautilus [`Currency`].
///
/// In Credits Trading Mode (EU), the futures wallet is denominated in `BNFCR`, a
/// USD-pegged credit unit absent from the currency table; it resolves to
/// `bnfcr_currency` so the account reconciles against stablecoin-settled instruments.
/// Any other unrecognized asset is registered as a generic crypto rather than panicking.
#[must_use]
pub(crate) fn normalize_futures_asset<T: AsRef<str>>(
    asset: T,
    bnfcr_currency: Currency,
) -> Currency {
    let code = asset.as_ref().trim();
    if code.eq_ignore_ascii_case(BNFCR_ASSET) {
        bnfcr_currency
    } else {
        Currency::get_or_create_crypto_with_context(code, Some("futures asset"))
    }
}

/// Determines the Binance `positionSide` for hedge mode from the Nautilus order side.
///
/// Returns `None` when not in hedge mode (one-way mode orders omit `positionSide`).
/// In hedge mode, `reduce_only` flips the mapping so that Buy closes Short and
/// Sell closes Long.
#[must_use]
pub(crate) fn determine_position_side(
    is_hedge_mode: bool,
    order_side: OrderSide,
    reduce_only: bool,
) -> Option<BinancePositionSide> {
    if !is_hedge_mode {
        return None;
    }

    Some(if reduce_only {
        match order_side {
            OrderSide::Buy => BinancePositionSide::Short,
            OrderSide::Sell => BinancePositionSide::Long,
            _ => BinancePositionSide::Both,
        }
    } else {
        match order_side {
            OrderSide::Buy => BinancePositionSide::Long,
            OrderSide::Sell => BinancePositionSide::Short,
            _ => BinancePositionSide::Both,
        }
    })
}

#[must_use]
pub(crate) const fn reduce_only_param(
    reduce_only: bool,
    position_side: Option<BinancePositionSide>,
) -> Option<bool> {
    // Binance rejects reduceOnly when positionSide is present in hedge mode
    if reduce_only && position_side.is_none() {
        Some(true)
    } else {
        None
    }
}

/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` decimal.
///
/// # Errors
///
/// Returns an error if the computed rate is outside the Binance accepted range
/// `[0.1%, 10.0%]`.
pub(crate) fn trailing_offset_to_callback_rate(offset: Decimal) -> anyhow::Result<Decimal> {
    let rate = offset / rust_decimal::Decimal::ONE_HUNDRED;
    let min_rate = rust_decimal::Decimal::new(1, 1);
    let max_rate = rust_decimal::Decimal::new(100, 1);

    if rate < min_rate || rate > max_rate {
        anyhow::bail!("callbackRate {rate}% out of Binance range [{min_rate}, {max_rate}]");
    }

    Ok(rate)
}

/// Converts a Nautilus trailing offset (percent) into a Binance `callbackRate` string.
///
/// # Errors
///
/// Returns an error if the computed rate is outside the Binance accepted range.
pub(crate) fn trailing_offset_to_callback_rate_string(offset: Decimal) -> anyhow::Result<String> {
    let rate = trailing_offset_to_callback_rate(offset)?;
    Ok(format_callback_rate(rate))
}

/// Formats a `callbackRate` decimal for Binance request params.
///
/// Whole percents are rendered with a trailing `.0` to match Binance examples.
#[must_use]
pub(crate) fn format_callback_rate(rate: Decimal) -> String {
    let normalized = rate.normalize();

    if normalized.scale() == 0 {
        format!("{normalized}.0")
    } else {
        normalized.to_string()
    }
}

#[cfg(test)]
mod tests {
    use nautilus_model::enums::CurrencyType;
    use rstest::rstest;

    use super::*;

    #[rstest]
    fn test_trailing_offset_to_callback_rate_preserves_precision() {
        let rate = trailing_offset_to_callback_rate(Decimal::from(25)).unwrap();
        assert_eq!(rate, Decimal::new(25, 2));
    }

    #[rstest]
    fn test_trailing_offset_to_callback_rate_string_formats_whole_percent() {
        let rate = trailing_offset_to_callback_rate_string(Decimal::from(100)).unwrap();
        assert_eq!(rate, "1.0");
    }

    #[rstest]
    fn test_trailing_offset_to_callback_rate_rejects_out_of_range_values() {
        let error = trailing_offset_to_callback_rate(Decimal::from(5)).unwrap_err();
        assert_eq!(
            error.to_string(),
            "callbackRate 0.05% out of Binance range [0.1, 10.0]"
        );
    }

    #[rstest]
    #[case::one_way_buy(false, OrderSide::Buy, false, None)]
    #[case::one_way_sell(false, OrderSide::Sell, false, None)]
    #[case::one_way_buy_reduce(false, OrderSide::Buy, true, None)]
    #[case::hedge_open_buy(true, OrderSide::Buy, false, Some(BinancePositionSide::Long))]
    #[case::hedge_open_sell(true, OrderSide::Sell, false, Some(BinancePositionSide::Short))]
    #[case::hedge_close_buy(true, OrderSide::Buy, true, Some(BinancePositionSide::Short))]
    #[case::hedge_close_sell(true, OrderSide::Sell, true, Some(BinancePositionSide::Long))]
    #[case::hedge_no_side(true, OrderSide::NoOrderSide, false, Some(BinancePositionSide::Both))]
    fn test_determine_position_side(
        #[case] is_hedge_mode: bool,
        #[case] order_side: OrderSide,
        #[case] reduce_only: bool,
        #[case] expected: Option<BinancePositionSide>,
    ) {
        assert_eq!(
            determine_position_side(is_hedge_mode, order_side, reduce_only),
            expected,
        );
    }

    #[rstest]
    #[case::one_way(false, None, None)]
    #[case::one_way_reduce(true, None, Some(true))]
    #[case::hedge_open(false, Some(BinancePositionSide::Long), None)]
    #[case::hedge_close_long(true, Some(BinancePositionSide::Long), None)]
    #[case::hedge_close_short(true, Some(BinancePositionSide::Short), None)]
    fn test_reduce_only_param(
        #[case] reduce_only: bool,
        #[case] position_side: Option<BinancePositionSide>,
        #[case] expected: Option<bool>,
    ) {
        assert_eq!(reduce_only_param(reduce_only, position_side), expected);
    }

    #[rstest]
    #[case::bnfcr_to_usdt("BNFCR", Currency::USDT(), Currency::USDT())]
    #[case::bnfcr_to_usdc("BNFCR", Currency::USDC(), Currency::USDC())]
    #[case::bnfcr_trim_and_case(" bnfcr ", Currency::USDC(), Currency::USDC())]
    #[case::known_asset_bypasses_alias("USDT", Currency::USDC(), Currency::USDT())]
    fn test_normalize_futures_asset_resolves_currency(
        #[case] asset: &str,
        #[case] bnfcr_currency: Currency,
        #[case] expected: Currency,
    ) {
        assert_eq!(normalize_futures_asset(asset, bnfcr_currency), expected);
    }

    #[rstest]
    fn test_normalize_futures_asset_registers_unknown_as_crypto() {
        let currency = normalize_futures_asset("XYZ", Currency::USDT());

        assert_eq!(currency.code.as_str(), "XYZ");
        assert_eq!(currency.currency_type, CurrencyType::Crypto);
    }
}