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; }
+ }
+}