diff --git a/README.md b/README.md index f0df15f5..8ba0de41 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The following cryptocurrency exchanges are supported: | BTSE | x | x | | | Bybit | x | x | R | Has public method for Websocket Positions | Coinbase | x | x | T R U | +| Coinmate | x | x | | | Digifinex | x | x | R B | | FTX | x | x | T | | gate.io | x | x | | diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/ExchangeCoinmateAPI.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/ExchangeCoinmateAPI.cs new file mode 100644 index 00000000..0d571572 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/ExchangeCoinmateAPI.cs @@ -0,0 +1,303 @@ +using ExchangeSharp.API.Exchanges.Coinmate.Models; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace ExchangeSharp +{ + public class ExchangeCoinmateAPI : ExchangeAPI + { + public override string BaseUrl { get; set; } = "https://coinmate.io/api"; + + public ExchangeCoinmateAPI() + { + RequestContentType = "application/x-www-form-urlencoded"; + MarketSymbolSeparator = "_"; + NonceStyle = NonceStyle.UnixMilliseconds; + } + + public override string Name => "Coinmate"; + + /// + /// Coinmate private API requires a client id. Internally this is secured in the PassPhrase property. + /// + public string ClientId + { + get { return Passphrase.ToUnsecureString(); } + set { Passphrase = value.ToSecureString(); } + } + + protected override async Task OnGetTickerAsync(string marketSymbol) + { + var response = await MakeCoinmateRequest($"/ticker?currencyPair={marketSymbol}"); + return await this.ParseTickerAsync(response, marketSymbol, "ask", "bid", "last", "amount", null, "timestamp", TimestampType.UnixSeconds); + } + + protected override async Task> OnGetMarketSymbolsAsync() + { + var response = await MakeCoinmateRequest("/products"); + return response.Select(x => $"{x.FromSymbol}{MarketSymbolSeparator}{x.ToSymbol}").ToArray(); + } + + protected internal override async Task> OnGetMarketSymbolsMetadataAsync() + { + var response = await MakeCoinmateRequest("/tradingPairs"); + return response.Select(x => new ExchangeMarket + { + IsActive = true, + BaseCurrency = x.FirstCurrency, + QuoteCurrency = x.SecondCurrency, + MarketSymbol = x.Name, + MinTradeSize = x.MinAmount, + PriceStepSize = 1 / (decimal)(Math.Pow(10, x.PriceDecimals)), + QuantityStepSize = 1 / (decimal)(Math.Pow(10, x.LotDecimals)) + }).ToArray(); + } + + protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100) + { + var book = await MakeCoinmateRequest("/orderBook?&groupByPriceLimit=False¤cyPair=" + marketSymbol); + var result = new ExchangeOrderBook + { + MarketSymbol = marketSymbol, + }; + + book.Asks + .GroupBy(x => x.Price) + .ToList() + .ForEach(x => result.Asks.Add(x.Key, new ExchangeOrderPrice { Amount = x.Sum(x => x.Amount), Price = x.Key })); + + book.Bids + .GroupBy(x => x.Price) + .ToList() + .ForEach(x => result.Bids.Add(x.Key, new ExchangeOrderPrice { Amount = x.Sum(x => x.Amount), Price = x.Key })); + + return result; + } + + protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = null) + { + var txs = await MakeCoinmateRequest("/transactions?minutesIntoHistory=1440¤cyPair=" + marketSymbol); + return txs.Select(x => new ExchangeTrade + { + Amount = x.Amount, + Id = x.TransactionId, + IsBuy = x.TradeType == "BUY", + Price = x.Price, + Timestamp = CryptoUtility.ParseTimestamp(x.Timestamp, TimestampType.UnixMilliseconds) + }) + .Take(limit ?? int.MaxValue) + .ToArray(); + } + + protected override async Task> OnGetAmountsAsync() + { + var payload = await GetNoncePayloadAsync(); + var balances = await MakeCoinmateRequest>("/balances", payload, "POST"); + + return balances.ToDictionary(x => x.Key, x => x.Value.Balance); + } + + protected override async Task> OnGetAmountsAvailableToTradeAsync() + { + var payload = await GetNoncePayloadAsync(); + var balances = await MakeCoinmateRequest>("/balances", payload, "POST"); + + return balances.ToDictionary(x => x.Key, x => x.Value.Available); + } + + protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + { + var payload = await GetNoncePayloadAsync(); + + CoinmateOrder o; + + if (isClientOrderId) + { + payload["clientOrderId"] = orderId; + var orders = await MakeCoinmateRequest("/order", payload, "POST"); + o = orders.OrderByDescending(x => x.Timestamp).FirstOrDefault(); + } + else + { + payload["orderId"] = orderId; + o = await MakeCoinmateRequest("/orderById", payload, "POST"); + } + + if (o == null) return null; + + return new ExchangeOrderResult + { + Amount = o.OriginalAmount, + AmountFilled = o.OriginalAmount - o.RemainingAmount, + AveragePrice = o.AvgPrice, + ClientOrderId = isClientOrderId ? orderId : null, + OrderId = o.Id.ToString(), + Price = o.Price, + IsBuy = o.Type == "BUY", + OrderDate = CryptoUtility.ParseTimestamp(o.Timestamp, TimestampType.UnixMilliseconds), + ResultCode = o.Status, + Result = o.Status switch + { + "CANCELLED" => ExchangeAPIOrderResult.Canceled, + "FILLED" => ExchangeAPIOrderResult.Filled, + "PARTIALLY_FILLED" => ExchangeAPIOrderResult.FilledPartially, + "OPEN" => ExchangeAPIOrderResult.Open, + _ => ExchangeAPIOrderResult.Unknown + }, + MarketSymbol = marketSymbol + }; + } + + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null) + { + var payload = await GetNoncePayloadAsync(); + payload["orderId"] = orderId; + + await MakeCoinmateRequest("/cancelOrder", payload, "POST"); + } + + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + var payload = await GetNoncePayloadAsync(); + + if (order.OrderType != OrderType.Limit && order.OrderType != OrderType.Stop) + { + throw new NotImplementedException("This type of order is currently not supported."); + } + + payload["amount"] = order.Amount; + payload["price"] = order.Price; + payload["currencyPair"] = order.MarketSymbol; + payload["postOnly"] = order.IsPostOnly.GetValueOrDefault() ? 1 : 0; + + if (order.OrderType == OrderType.Stop) + { + payload["stopPrice"] = order.StopPrice; + } + + if (order.ClientOrderId != null) + { + if (!long.TryParse(order.ClientOrderId, out var clientOrderId)) + { + throw new InvalidOperationException("ClientId must be numerical for Coinmate"); + } + + payload["clientOrderId"] = clientOrderId; + } + + var url = order.IsBuy ? "/buyLimit" : "/sellLimit"; + var id = await MakeCoinmateRequest(url, payload, "POST"); + + try + { + return await GetOrderDetailsAsync(id?.ToString(), marketSymbol: order.MarketSymbol); + } + catch + { + return new ExchangeOrderResult { OrderId = id?.ToString() }; + } + } + + protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) + { + var payload = await GetNoncePayloadAsync(); + payload["currencyPair"] = marketSymbol; + + var orders = await MakeCoinmateRequest("/openOrders", payload, "POST"); + + return orders.Select(x => new ExchangeOrderResult + { + Amount = x.Amount, + ClientOrderId = x.ClientOrderId?.ToString(), + IsBuy = x.Type == "BUY", + MarketSymbol = x.CurrencyPair, + OrderDate = CryptoUtility.ParseTimestamp(x.Timestamp, TimestampType.UnixMilliseconds), + OrderId = x.Id.ToString(), + Price = x.Price, + + }).ToArray(); + } + + protected override async Task OnGetDepositAddressAsync(string currency, bool forceRegenerate = false) + { + var payload = await GetNoncePayloadAsync(); + var currencyName = GetCurrencyName(currency); + var addresses = await MakeCoinmateRequest($"/{currencyName}DepositAddresses", payload, "POST"); + + return new ExchangeDepositDetails + { + Address = addresses.FirstOrDefault(), + Currency = currency, + }; + } + + protected override async Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) + { + var payload = await GetNoncePayloadAsync(); + var currencyName = GetCurrencyName(withdrawalRequest.Currency); + + payload["amount"] = withdrawalRequest.Amount; + payload["address"] = withdrawalRequest.Address; + payload["amountType"] = withdrawalRequest.TakeFeeFromAmount ? "NET" : "GROSS"; + + var id = await MakeCoinmateRequest($"/{currencyName}Withdrawal", payload, "POST"); + + return new ExchangeWithdrawalResponse + { + Id = id?.ToString(), + Success = id != null + }; + } + + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + if (CanMakeAuthenticatedRequest(payload)) + { + if (string.IsNullOrWhiteSpace(ClientId)) + { + throw new APIException("Client ID is not set for Coinmate"); + } + + var apiKey = PublicApiKey.ToUnsecureString(); + var messageToSign = payload["nonce"].ToStringInvariant() + ClientId + apiKey; + var signature = CryptoUtility.SHA256Sign(messageToSign, PrivateApiKey.ToUnsecureString()).ToUpperInvariant(); + payload["signature"] = signature; + payload["clientId"] = ClientId; + payload["publicKey"] = apiKey; + await CryptoUtility.WritePayloadFormToRequestAsync(request, payload); + } + } + + private async Task MakeCoinmateRequest(string url, Dictionary payload = null, string method = null) + { + var response = await MakeJsonRequestAsync>(url, null, payload, method); + + if (response.Error) + { + throw new APIException(response.ErrorMessage); + } + + return response.Data; + } + + private string GetCurrencyName(string currency) + { + return currency.ToUpper() switch + { + "BTC" => "bitcoin", + "LTC" => "litecoin", + "BCH" => "bitcoinCash", + "ETH" => "ethereum", + "XRP" => "ripple", + "DASH" => "dash", + "DAI" => "dai", + _ => throw new NotImplementedException("Unsupported currency") + }; + } + + public partial class ExchangeName { public const string Coinmate = "Coinmate"; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateBalance.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateBalance.cs new file mode 100644 index 00000000..630607ad --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateBalance.cs @@ -0,0 +1,10 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateBalance + { + public string Currency { get; set; } + public decimal Balance { get; set; } + public decimal Reserved { get; set; } + public decimal Available { get; set; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOpenOrder.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOpenOrder.cs new file mode 100644 index 00000000..53af4fa8 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOpenOrder.cs @@ -0,0 +1,18 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateOpenOrder + { + public int Id { get; set; } + public long Timestamp { get; set; } + public string Type { get; set; } + public string CurrencyPair { get; set; } + public decimal Price { get; set; } + public decimal Amount { get; set; } + public decimal? StopPrice { get; set; } + public string OrderTradeType { get; set; } + public bool Hidden { get; set; } + public bool Trailing { get; set; } + public long? StopLossOrderId { get; set; } + public long? ClientOrderId { get; set; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOrder.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOrder.cs new file mode 100644 index 00000000..c64ce7c6 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOrder.cs @@ -0,0 +1,19 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateOrder + { + public int Id { get; set; } + public long Timestamp { get; set; } + public string Type { get; set; } + public decimal? Price { get; set; } + public decimal? RemainingAmount { get; set; } + public decimal OriginalAmount { get; set; } + public decimal? StopPrice { get; set; } + public string Status { get; set; } + public string OrderTradeType { get; set; } + public decimal? AvgPrice { get; set; } + public bool Trailing { get; set; } + public string StopLossOrderId { get; set; } + public string OriginalOrderId { get; set; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOrderBook.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOrderBook.cs new file mode 100644 index 00000000..b0c61eb7 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateOrderBook.cs @@ -0,0 +1,14 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateOrderBook + { + public AskBid[] Asks { get; set; } + public AskBid[] Bids { get; set; } + + public class AskBid + { + public decimal Price { get; set; } + public decimal Amount { get; set; } + } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateResponse.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateResponse.cs new file mode 100644 index 00000000..1f1a28dc --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateResponse.cs @@ -0,0 +1,9 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateResponse + { + public bool Error { get; set; } + public string ErrorMessage { get; set; } + public T Data { get; set; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateSymbol.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateSymbol.cs new file mode 100644 index 00000000..57443474 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateSymbol.cs @@ -0,0 +1,9 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateSymbol + { + public string Id { get; set; } + public string FromSymbol { get; set; } + public string ToSymbol { get; set; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateTradingPair.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateTradingPair.cs new file mode 100644 index 00000000..4bbe57a9 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateTradingPair.cs @@ -0,0 +1,15 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateTradingPair + { + public string Name { get; set; } + public string FirstCurrency { get; set; } + public string SecondCurrency { get; set; } + public int PriceDecimals { get; set; } + public int LotDecimals { get; set; } + public decimal MinAmount { get; set; } + public string TradesWebSocketChannelId { get; set; } + public string OrderBookWebSocketChannelId { get; set; } + public string TradeStatisticsWebSocketChannelId { get; set; } + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateTransaction.cs b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateTransaction.cs new file mode 100644 index 00000000..90a19573 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Coinmate/Models/CoinmateTransaction.cs @@ -0,0 +1,12 @@ +namespace ExchangeSharp.API.Exchanges.Coinmate.Models +{ + public class CoinmateTransaction + { + public long Timestamp { get; set; } + public string TransactionId { get; set; } + public decimal Price { get; set; } + public decimal Amount { get; set; } + public string CurrencyPair { get; set; } + public string TradeType { get; set; } + } +}