scuriolus 0.2.0

Scuriolus is a modular trading bot platform.
Documentation
use mexc_rs::spot::{
    v3::{
        account_information::AccountInformationEndpoint,
        cancel_order::{CancelOrderEndpoint, CancelOrderOutput, CancelOrderParams},
        enums::KlineInterval as MexcKlineInterval,
        klines::{Kline as MexcKline, KlinesEndpoint as _, KlinesParams as MexcKlinesParams},
        order::{OrderEndpoint, OrderParams},
        ping::PingEndpoint,
        query_order::{QueryOrderEndpoint, QueryOrderOutput, QueryOrderParams},
        ApiError,
    },
    MexcSpotApiClient, MexcSpotApiClientWithAuthentication, MexcSpotApiEndpoint,
};
use std::fmt;
use tracing::{debug, trace};

use super::{Balance, Crypto, Kline, KlinesParams, Market, MarketFactory, Order};
use crate::{
    clock::TrueClock,
    core::{CoreError, CoreResult},
    data::{KlineInterval, SpecificOrderDetails},
};

/// Max number of klines to fetch. The Mexc API anounces 1000 but it is actually 500 from testing.
pub const KLINES_LIMIT: i32 = 500; // Mexc Documentation is wrong, it is not 1000

#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone, Default)]
pub struct MexcOrderDetails {
    pub id: Option<String>,
}

impl MexcOrderDetails {
    pub fn set_id(&mut self, id: String) {
        self.id = Some(id);
    }
}

impl fmt::Display for MexcOrderDetails {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:?}", self.id)
    }
}

impl SpecificOrderDetails for MexcOrderDetails {
    const NAME: &'static str = "Mexc";
}

pub struct MexcFactory;

impl MarketFactory for MexcFactory {
    type _Market = Mexc;

    fn with_clock(self, _: <Self::_Market as Market>::_Clock) -> Self {
        self
    }

    async fn build(self) -> CoreResult<Self::_Market> {
        Ok(Mexc::new().await)
    }
}

/// API to real **Mexc** website.
pub struct Mexc {
    client_auth: MexcSpotApiClientWithAuthentication,
    client: MexcSpotApiClient,
}

impl Mexc {
    pub async fn new() -> Self {
        let api_key = std::env::var("MEXC_API_KEY").expect("MEXC_API_KEY not set");
        let secret_key = std::env::var("MEXC_SECRET_KEY").expect("MEXC_SECRET_KEY not set");

        if std::env::var("SERIOUS").is_ok_and(|v| v == "true") {
            tracing::info!("Careful, this is not an exercise, and you're about to use real money.");
        } else {
            tracing::info!("SERIOUS isn't `true`, so this is a dry run. Your money is safe.");
        }

        let client = MexcSpotApiClient::new(MexcSpotApiEndpoint::Base);
        client.ping().await.expect("API injoignable");

        let client_auth = MexcSpotApiClientWithAuthentication::new(
            MexcSpotApiEndpoint::Base,
            api_key,
            secret_key,
        );
        client_auth.ping().await.expect("Connexion impossible");

        trace!("Mymexc initialised");

        Mexc {
            client_auth,
            client,
        }
    }
}

impl fmt::Debug for Mexc {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Mexc")
            .field("client", &"local client")
            .field("client_auth", &"local client auth")
            .finish()
    }
}

impl Market for Mexc {
    type _Clock = TrueClock;
    type _OrderDetails = MexcOrderDetails;

    fn source_name() -> String {
        "Mexc".to_string()
    }

    async fn klines(&self, params: KlinesParams) -> CoreResult<Vec<Kline>> {
        let mexc_params = MexcKlinesParams {
            symbol: &mexc_symbol(&params.asset, &params.quote),
            interval: params.interval.into(),
            start_time: Some(params.start_time),
            end_time: Some(params.end_time),
            limit: None,
        };

        let klines: Vec<_> = self
            .client
            .klines(mexc_params)
            .await?
            .klines
            .into_iter()
            .map(Kline::from)
            .collect();

        trace!("Fetched {} klines from Mexc API", klines.len());

        Ok(klines)
    }

    async fn get_balance(&self, crypto: Crypto) -> CoreResult<Balance> {
        let account_info = self.client_auth.account_information().await?;
        let mexc_balance = account_info
            .balances
            .iter()
            .find(|balance| balance.asset == crypto.to_string())
            .expect("Balance not found");
        Ok(Balance {
            free: mexc_balance.free,
            locked: mexc_balance.locked,
        })
    }

    /// Sends an order to the Mexc exchange using the authenticated client.
    ///
    /// # Arguments
    ///
    /// * `order` - A reference to an `Order` struct containing details of the order to be sent.
    ///
    /// | Order Type | Side | `price`     | `quantity::Asset` | `quantity::Quote`| Notes                         |
    /// | ---------- | ---- | ----------- | ----------------- | ---------------- | ----------------------------- |
    /// | Limit      | Buy  | ✅ required | ✅ required      | ❌ not allowed   | Buy at a specific price.      |
    /// | Limit      | Sell | ✅ required | ✅ required      | ❌ not allowed   | Sell at a specific price.     |
    /// | Market     | Buy  | ❌ ignored  | ❌ not allowed   | ✅ required      | Buy for a given quote amount. |
    /// | Market     | Sell | ❌ ignored  | ✅ required      | ❌ not allowed   | Sell a given asset quantity.  |
    ///
    /// # Returns
    ///
    /// * `Result<String, Error>` - On success, returns the order ID as a `String`. On failure, returns an `Error`.
    ///
    /// # Remarks
    ///
    /// This function constructs an `OrderParams` object from the provided `Order` and sends it to the Mexc exchange.
    /// It requires the client to be authenticated with the necessary API keys.
    async fn send_order(&self, order: &mut Order<MexcOrderDetails>) -> CoreResult<()> {
        if std::env::var("SERIOUS").unwrap_or("false".to_string()) != "true" {
            Err(CoreError::SafetyError)
        } else {
            let (quantity, quote_order_quantity) = order.quantity().get_amounts();

            let params = OrderParams {
                symbol: &order.mexc_symbol(),
                side: *order.side(),
                order_type: *order.order_type(),
                quantity,
                price: *order.price(),
                quote_order_quantity,
                new_client_order_id: None,
            };
            debug!("Order to Mexc, [{:#?}]", params);

            let ret = self.client_auth.order(params).await?;

            order.details_mut().set_id(ret.order_id);

            Ok(())
        }
    }

    async fn cancel_order(&self, order: &Order<MexcOrderDetails>) -> CoreResult<CancelOrderOutput> {
        Ok(self
            .client_auth
            .cancel_order(CancelOrderParams {
                symbol: &order.mexc_symbol(),
                order_id: Some(&order.details().id.clone().expect("Order ID should be set")),
                new_client_order_id: None,
                original_client_order_id: None,
            })
            .await?)
    }

    async fn update_order(&self, order: &Order<MexcOrderDetails>) -> CoreResult<QueryOrderOutput> {
        Ok(self
            .client_auth
            .query_order(QueryOrderParams {
                symbol: &order.mexc_symbol(),
                order_id: Some(&order.details().id.clone().expect("Order ID should be set")),
                original_client_order_id: None,
            })
            .await?)
    }

    fn klines_limit(&self) -> i32 {
        KLINES_LIMIT
    }
}

fn mexc_symbol(asset: &Crypto, quote: &Crypto) -> String {
    format!("{}{}", asset, quote)
}

trait MexcSymbol {
    fn mexc_symbol(&self) -> String;
}

impl MexcSymbol for Order<MexcOrderDetails> {
    fn mexc_symbol(&self) -> String {
        mexc_symbol(self.asset(), self.quote())
    }
}

impl From<ApiError> for CoreError {
    fn from(value: ApiError) -> Self {
        CoreError::ApiError(value.to_string())
    }
}

impl From<MexcKline> for Kline {
    fn from(kline: MexcKline) -> Self {
        Kline {
            open_time: kline.open_time,
            close_time: kline.close_time,
            open: kline.open,
            high: kline.high,
            low: kline.low,
            close: kline.close,
            volume: kline.volume,
            quote_asset_volume: kline.quote_asset_volume,
        }
    }
}

impl From<KlineInterval> for MexcKlineInterval {
    fn from(value: KlineInterval) -> Self {
        match value {
            KlineInterval::OneMinute => MexcKlineInterval::OneMinute,
            KlineInterval::FiveMinutes => MexcKlineInterval::FiveMinutes,
            KlineInterval::FifteenMinutes => MexcKlineInterval::FifteenMinutes,
            KlineInterval::ThirtyMinutes => MexcKlineInterval::ThirtyMinutes,
            KlineInterval::OneHour => MexcKlineInterval::OneHour,
            KlineInterval::FourHours => MexcKlineInterval::FourHours,
            KlineInterval::OneDay => MexcKlineInterval::OneDay,
            KlineInterval::OneWeek => MexcKlineInterval::OneWeek,
            KlineInterval::OneMonth => MexcKlineInterval::OneMonth,
        }
    }
}