scuriolus 0.3.0

Scuriolus is a modular trading bot platform.
Documentation
use mexc_rs::spot::{
    MexcSpotApiClientWithAuthentication, MexcSpotApiEndpoint,
    v3::{
        ApiError,
        account_information::AccountInformationEndpoint,
        cancel_order::{CancelOrderEndpoint, CancelOrderParams},
        enums::{
            OrderSide as MexcOrderSide, OrderStatus as MexcOrderStatus, OrderType as MexcOrderType,
        },
        order::{OrderEndpoint, OrderParams},
        ping::PingEndpoint,
        query_order::{QueryOrderEndpoint, QueryOrderParams},
    },
};
use std::fmt;
use tracing::{debug, trace};

use super::{Balance, Crypto, Market, MarketFactory, Order};
use crate::{
    clock::TrueClock,
    core::{CoreError, CoreResult},
    generics::order::{OrderSide, OrderStatus, OrderType, SpecificOrderDetails, UpdateOrderOutput},
};

#[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";

    fn try_update(&mut self, item: Self) -> CoreResult<()> {
        match item.id {
            None => tracing::warn!("Update with no id"),
            Some(id) => match self.id.clone() {
                None => self.set_id(id),
                Some(old_id) => {
                    if old_id != id {
                        return Err(CoreError::ApiError(format!(
                            "Id changed from {old_id} to {id}"
                        )));
                    }
                }
            },
        };
        Ok(())
    }
}

pub struct MexcMarketFactory;

impl MarketFactory for MexcMarketFactory {
    type _Market = MexcMarket;

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

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

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

impl MexcMarket {
    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_auth = MexcSpotApiClientWithAuthentication::new(
            MexcSpotApiEndpoint::Base,
            api_key,
            secret_key,
        );
        client_auth.ping().await.expect("Connexion impossible");

        trace!("Mymexc initialised");

        MexcMarket { client_auth }
    }
}

impl fmt::Debug for MexcMarket {
    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 MexcMarket {
    type _Clock = TrueClock;
    type _OrderDetails = MexcOrderDetails;

    fn name(&self) -> String {
        "Mexc".to_string()
    }

    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: &Order<MexcOrderDetails>,
    ) -> CoreResult<UpdateOrderOutput<MexcOrderDetails>> {
        if std::env::var("SERIOUS").unwrap_or("false".to_string()) != "true" {
            Err(CoreError::SafetyError)
        } else {
            let params = OrderParams {
                symbol: &order.mexc_symbol(),
                side: (*order.side()).into(),
                order_type: (*order.order_type()).into(),
                quantity: *order.qty_asset(),
                price: *order.price(),
                quote_order_quantity: *order.qty_quote(),
                new_client_order_id: None,
            };
            debug!("Order to Mexc");
            trace!("params: {:?}", params);

            let ret = self.client_auth.order(params).await?;
            Ok(UpdateOrderOutput {
                status: *order.status(),
                executed_qty_asset: *order.executed_qty_asset(),
                executed_qty_quote: *order.executed_qty_quote(),
                details: MexcOrderDetails {
                    id: Some(ret.order_id),
                },
            })
        }
    }

    async fn cancel_order(
        &self,
        order: &Order<MexcOrderDetails>,
    ) -> CoreResult<UpdateOrderOutput<MexcOrderDetails>> {
        let ret = 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?;

        Ok(UpdateOrderOutput {
            status: ret.status.into(),
            executed_qty_asset: ret.executed_quantity,
            executed_qty_quote: ret.cummulative_quote_quantity,
            details: MexcOrderDetails {
                id: Some(ret.order_id),
            },
        })
    }

    async fn update_order(
        &self,
        order: &Order<MexcOrderDetails>,
    ) -> CoreResult<UpdateOrderOutput<MexcOrderDetails>> {
        let ret = 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?;

        Ok(UpdateOrderOutput {
            status: ret.status.into(),
            executed_qty_asset: ret.executed_quantity,
            executed_qty_quote: ret.cummulative_quote_quantity,
            details: MexcOrderDetails {
                id: Some(ret.order_id),
            },
        })
    }
}

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<OrderStatus> for MexcOrderStatus {
    fn from(status: OrderStatus) -> Self {
        match status {
            OrderStatus::Open => MexcOrderStatus::New,
            OrderStatus::Filled => MexcOrderStatus::Filled,
            OrderStatus::Canceled => MexcOrderStatus::Canceled,
            OrderStatus::PartiallyFilled => MexcOrderStatus::PartiallyFilled,
            OrderStatus::PartiallyCanceled => MexcOrderStatus::PartiallyCanceled,
            OrderStatus::Tested => MexcOrderStatus::Canceled,
            OrderStatus::Failed => MexcOrderStatus::Canceled,
        }
    }
}

impl From<MexcOrderStatus> for OrderStatus {
    fn from(status: MexcOrderStatus) -> Self {
        match status {
            MexcOrderStatus::New => OrderStatus::Open,
            MexcOrderStatus::Filled => OrderStatus::Filled,
            MexcOrderStatus::Canceled => OrderStatus::Canceled,
            MexcOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
            MexcOrderStatus::PartiallyCanceled => OrderStatus::PartiallyCanceled,
        }
    }
}

impl From<OrderSide> for MexcOrderSide {
    fn from(side: OrderSide) -> Self {
        match side {
            OrderSide::Buy => MexcOrderSide::Buy,
            OrderSide::Sell => MexcOrderSide::Sell,
        }
    }
}

impl From<OrderType> for MexcOrderType {
    fn from(order_type: OrderType) -> Self {
        match order_type {
            OrderType::Limit => MexcOrderType::Limit,
            OrderType::Market => MexcOrderType::Market,
            OrderType::LimitMaker => MexcOrderType::LimitMaker,
            OrderType::ImmediateOrCancel => MexcOrderType::ImmediateOrCancel,
            OrderType::FillOrKill => MexcOrderType::FillOrKill,
        }
    }
}

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