diff --git a/ExchangeSharp/API/Common/APIRequestMaker.cs b/ExchangeSharp/API/Common/APIRequestMaker.cs index 297ec641..2416781b 100644 --- a/ExchangeSharp/API/Common/APIRequestMaker.cs +++ b/ExchangeSharp/API/Common/APIRequestMaker.cs @@ -1,4 +1,4 @@ -/* +/* MIT LICENSE Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com @@ -14,7 +14,6 @@ The above copyright notice and this permission notice shall be included in all c using System.Collections.Generic; using System.IO; using System.Net; -using System.Text; using System.Threading.Tasks; namespace ExchangeSharp @@ -27,14 +26,28 @@ public sealed class APIRequestMaker : IAPIRequestMaker { private readonly IAPIRequestHandler api; - private class InternalHttpWebRequest : IHttpWebRequest + internal class InternalHttpWebRequest : IHttpWebRequest { - internal readonly HttpWebRequest request; + internal readonly HttpWebRequest Request; + internal static WebProxy Proxy; + + static InternalHttpWebRequest() + { + var httpProxy = Environment.GetEnvironmentVariable("http_proxy"); + httpProxy ??= Environment.GetEnvironmentVariable("HTTP_PROXY"); + + if (string.IsNullOrEmpty(httpProxy)) + return; + + var uri = new Uri(httpProxy); + Proxy = new WebProxy(uri); + } public InternalHttpWebRequest(Uri fullUri) { - request = (HttpWebRequest.Create(fullUri) as HttpWebRequest ?? throw new NullReferenceException("Failed to create HttpWebRequest")); - request.KeepAlive = false; + Request = (HttpWebRequest.Create(fullUri) as HttpWebRequest ?? throw new NullReferenceException("Failed to create HttpWebRequest")); + Request.Proxy = Proxy; + Request.KeepAlive = false; } public void AddHeader(string header, string value) @@ -42,64 +55,64 @@ public void AddHeader(string header, string value) switch (header.ToStringLowerInvariant()) { case "content-type": - request.ContentType = value; + Request.ContentType = value; break; case "content-length": - request.ContentLength = value.ConvertInvariant(); + Request.ContentLength = value.ConvertInvariant(); break; case "user-agent": - request.UserAgent = value; + Request.UserAgent = value; break; case "accept": - request.Accept = value; + Request.Accept = value; break; case "connection": - request.Connection = value; + Request.Connection = value; break; default: - request.Headers[header] = value; + Request.Headers[header] = value; break; } } public Uri RequestUri { - get { return request.RequestUri; } + get { return Request.RequestUri; } } public string Method { - get { return request.Method; } - set { request.Method = value; } + get { return Request.Method; } + set { Request.Method = value; } } public int Timeout { - get { return request.Timeout; } - set { request.Timeout = value; } + get { return Request.Timeout; } + set { Request.Timeout = value; } } public int ReadWriteTimeout { - get { return request.ReadWriteTimeout; } - set { request.ReadWriteTimeout = value; } + get { return Request.ReadWriteTimeout; } + set { Request.ReadWriteTimeout = value; } } public async Task WriteAllAsync(byte[] data, int index, int length) { - using (Stream stream = await request.GetRequestStreamAsync()) + using (Stream stream = await Request.GetRequestStreamAsync()) { await stream.WriteAsync(data, 0, data.Length); } } } - private class InternalHttpWebResponse : IHttpWebResponse + internal class InternalHttpWebResponse : IHttpWebResponse { private readonly HttpWebResponse response; @@ -175,7 +188,7 @@ public async Task MakeRequestAsync(string url, string? baseUrl = null, D try { RequestStateChanged?.Invoke(this, RequestMakerState.Begin, uri.AbsoluteUri);// when start make a request we send the uri, this helps developers to track the http requests. - response = await request.request.GetResponseAsync() as HttpWebResponse; + response = await request.Request.GetResponseAsync() as HttpWebResponse; if (response == null) { throw new APIException("Unknown response from server"); @@ -191,16 +204,19 @@ public async Task MakeRequestAsync(string url, string? baseUrl = null, D } using (Stream responseStream = response.GetResponseStream()) using (StreamReader responseStreamReader = new StreamReader(responseStream)) - responseString = responseStreamReader.ReadToEnd(); + responseString = responseStreamReader.ReadToEnd(); + if (response.StatusCode != HttpStatusCode.OK) { - // 404 maybe return empty responseString - if (string.IsNullOrWhiteSpace(responseString)) - { + // 404 maybe return empty responseString + if (string.IsNullOrWhiteSpace(responseString)) + { throw new APIException(string.Format("{0} - {1}", response.StatusCode.ConvertInvariant(), response.StatusCode)); - } - throw new APIException(responseString); + } + + throw new APIException(responseString); } + api.ProcessResponse(new InternalHttpWebResponse(response)); RequestStateChanged?.Invoke(this, RequestMakerState.Finished, responseString); } diff --git a/ExchangeSharp/API/Exchanges/BL3P/BL3PException.cs b/ExchangeSharp/API/Exchanges/BL3P/BL3PException.cs new file mode 100644 index 00000000..c9d42809 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/BL3PException.cs @@ -0,0 +1,37 @@ +using System; +using System.Runtime.Serialization; +using ExchangeSharp.API.Exchanges.BL3P.Models; + +namespace ExchangeSharp.API.Exchanges.BL3P +{ + [Serializable] + internal class BL3PException : Exception + { + public string ErrorCode { get; } + + internal BL3PException(BL3PResponsePayloadError error) + : this(error?.Message) + { + if (error == null) + throw new ArgumentNullException(nameof(error)); + ErrorCode = error.ErrorCode; + } + + public BL3PException(string message) + : base(message) + { + } + + public BL3PException(string message, Exception inner) + : base(message, inner) + { + } + + protected BL3PException( + SerializationInfo info, + StreamingContext context + ) : base(info, context) + { + } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Converters/BL3PResponseConverter.cs b/ExchangeSharp/API/Exchanges/BL3P/Converters/BL3PResponseConverter.cs new file mode 100644 index 00000000..f6cbb206 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Converters/BL3PResponseConverter.cs @@ -0,0 +1,34 @@ +using ExchangeSharp.API.Exchanges.BL3P.Models; +using ExchangeSharp.Dependencies.Converters; +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Converters +{ + internal class BL3PResponseConverter : JsonComplexObjectConverter + where TSuccess : BL3PResponsePayload, new() + { + protected override BL3PResponsePayload Create(JsonReader reader) + { + while (reader.Read()) + { + if (reader.TokenType != JsonToken.PropertyName) + { + continue; + } + + var prop = (string) reader.Value; + + switch (prop) + { + // this is the first prop on an error object + case "code": + return new BL3PResponsePayloadError(); + default: + return new TSuccess(); + } + } + + throw new JsonException("Could not locate key property in json."); + } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3PCurrencyFee.cs b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3PCurrencyFee.cs new file mode 100644 index 00000000..78965fb3 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3PCurrencyFee.cs @@ -0,0 +1,8 @@ +namespace ExchangeSharp.API.Exchanges.BL3P.Enums +{ + public enum BL3PCurrencyFee : byte + { + BTC = 0, + EUR = 1 + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3POrderStatus.cs b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3POrderStatus.cs new file mode 100644 index 00000000..ff92e3e0 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3POrderStatus.cs @@ -0,0 +1,11 @@ +namespace ExchangeSharp.API.Exchanges.BL3P.Enums +{ + internal enum BL3POrderStatus + { + Pending = 0, + Open, + Closed, + Cancelled, + Placed + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3POrderType.cs b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3POrderType.cs new file mode 100644 index 00000000..8bd8a8fb --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3POrderType.cs @@ -0,0 +1,8 @@ +namespace ExchangeSharp.API.Exchanges.BL3P.Enums +{ + internal enum BL3POrderType + { + Bid, + Ask + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3PResponseType.cs b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3PResponseType.cs new file mode 100644 index 00000000..bc07f438 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Enums/BL3PResponseType.cs @@ -0,0 +1,9 @@ +namespace ExchangeSharp.API.Exchanges.BL3P.Enums +{ + internal enum BL3PResponseType + { + Unknown = 0, + Error, + Success + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/ExchangeBL3PAPI.cs b/ExchangeSharp/API/Exchanges/BL3P/ExchangeBL3PAPI.cs index 36129e35..046ca5c2 100644 --- a/ExchangeSharp/API/Exchanges/BL3P/ExchangeBL3PAPI.cs +++ b/ExchangeSharp/API/Exchanges/BL3P/ExchangeBL3PAPI.cs @@ -1,30 +1,58 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; -using ExchangeSharp.BL3P; +using ExchangeSharp.API.Exchanges.BL3P; +using ExchangeSharp.API.Exchanges.BL3P.Enums; +using ExchangeSharp.API.Exchanges.BL3P.Extensions; +using ExchangeSharp.API.Exchanges.BL3P.Models; +using ExchangeSharp.API.Exchanges.BL3P.Models.Orders.Add; +using ExchangeSharp.API.Exchanges.BL3P.Models.Orders.Result; +using ExchangeSharp.Utility; using Newtonsoft.Json; using Newtonsoft.Json.Linq; // ReSharper disable once CheckNamespace namespace ExchangeSharp { +#nullable enable // ReSharper disable once InconsistentNaming - public sealed partial class ExchangeBL3PAPI : ExchangeAPI + public sealed class ExchangeBL3PAPI : ExchangeAPI { - public override string BaseUrl { get; set; } = "https://api.bl3p.eu/"; + private readonly FixedIntDecimalConverter converterToEight; + + private readonly FixedIntDecimalConverter converterToFive; + + public override string BaseUrl { get; set; } = "https://api.bl3p.eu/1"; public override string BaseUrlWebSocket { get; set; } = "wss://api.bl3p.eu/1/"; + /// + /// The default currency that will be used when calling + /// You can also use the parameter fee_currency to set it per request. + /// + public BL3PCurrencyFee DefaultFeeCurrency { get; set; } = BL3PCurrencyFee.BTC; + public ExchangeBL3PAPI() { MarketSymbolIsUppercase = true; MarketSymbolIsReversed = true; MarketSymbolSeparator = string.Empty; WebSocketOrderBookType = WebSocketOrderBookType.FullBookAlways; + RequestContentType = "application/x-www-form-urlencoded"; + RequestMethod = "POST"; + + RateLimit = new RateGate(600, TimeSpan.FromMinutes(10)); + + converterToEight = new FixedIntDecimalConverter(8); + converterToFive = new FixedIntDecimalConverter(5); } public ExchangeBL3PAPI(ref string publicApiKey, ref string privateApiKey) + : this() { if (publicApiKey == null) throw new ArgumentNullException(nameof(publicApiKey)); @@ -99,6 +127,11 @@ protected override async Task OnGetDeltaOrderBookWebSocketAsync( params string[] marketSymbols ) { + if (marketSymbols == null) + throw new ArgumentNullException(nameof(marketSymbols)); + if (marketSymbols.Length == 0) + throw new ArgumentException("Value cannot be an empty collection.", nameof(marketSymbols)); + Task MessageCallback(IWebSocket _, byte[] msg) { var bl3POrderBook = JsonConvert.DeserializeObject(msg.ToStringFromUTF8()); @@ -137,9 +170,151 @@ await Task.WhenAll( ); } - public partial class ExchangeName + protected override bool CanMakeAuthenticatedRequest(IReadOnlyDictionary payload) + { + return !(PublicApiKey is null) && !(PrivateApiKey is null); + } + + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + var formData = await request.WritePayloadFormToRequestAsync(payload) + .ConfigureAwait(false); + + if (CanMakeAuthenticatedRequest(payload)) + { + request.AddHeader("Rest-Key", PublicApiKey.ToUnsecureString()); + var signKey = GetSignKey(request, formData); + request.AddHeader("Rest-Sign", signKey); + } + } + + private string GetSignKey(IHttpWebRequest request, string formData) + { + //TODO: Use csharp8 ranges + var index = Array.IndexOf(request.RequestUri.Segments, "1/"); + var callPath = string.Join(string.Empty, request.RequestUri.Segments.Skip(index + 1)).TrimStart('/'); + var postData = $"{callPath}\0{formData}"; + var privateKeyBase64 = Convert.FromBase64String(PrivateApiKey.ToUnsecureString()); + + byte[] hashBytes; + using (var hmacSha512 = new HMACSHA512(privateKeyBase64)) + { + hashBytes = hmacSha512.ComputeHash(Encoding.UTF8.GetBytes(postData)); + } + + return Convert.ToBase64String(hashBytes); + } + + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + var roundedAmount = order.RoundAmount(); + var amountInt = converterToEight.FromDecimal(roundedAmount); + + var feeCurrency = (order.ExtraParameters.ContainsKey("fee_currency") + ? order.ExtraParameters["fee_currency"] ?? DefaultFeeCurrency + : DefaultFeeCurrency) + .ToString() + .ToUpperInvariant(); + + var data = new Dictionary + { + {"amount_int", amountInt}, + {"type", order.IsBuy ? "bid" : "ask"}, + {"fee_currency", feeCurrency}, + }; + + switch (order.OrderType) + { + case OrderType.Limit: + data["price_int"] = converterToFive.FromDecimal(order.Price); + break; + case OrderType.Market: + data["amount_funds_int"] = converterToFive.FromDecimal(roundedAmount * order.Price); + break; + default: + throw new NotSupportedException($"{order.OrderType} is not supported"); + } + + var resultBody = await MakeRequestAsync( + $"/{order.MarketSymbol}/money/order/add", + payload: data + ) + .ConfigureAwait(false); + + var result = JsonConvert.DeserializeObject(resultBody) + .Except(); + + var orderDetails = await GetOrderDetailsAsync(result.OrderId, order.MarketSymbol) + .ConfigureAwait(false); + + return orderDetails; + } + + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null) + { + if (string.IsNullOrWhiteSpace(marketSymbol)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(marketSymbol)); + + var resultBody = await MakeRequestAsync( + $"/{marketSymbol}/money/order/cancel", + payload: new Dictionary + { + {"order_id", orderId} + } + ) + .ConfigureAwait(false); + + JsonConvert.DeserializeObject(resultBody) + .Except(); + } + + public override async Task GetOrderDetailsAsync(string orderId, string marketSymbol = null) + { + if (string.IsNullOrWhiteSpace(marketSymbol)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(marketSymbol)); + + var data = new Dictionary + { + {"order_id", orderId} + }; + + var resultBody = await MakeRequestAsync( + $"/{marketSymbol}/money/order/result", + payload: data + ) + .ConfigureAwait(false); + + + var result = JsonConvert.DeserializeObject(resultBody) + .Except(); + + return new ExchangeOrderResult + { + Amount = result.Amount.Value, + Fees = result.TotalFee.Value, + Message = $"Order created via: \"{result.APIKeyLabel}\"", + Price = result.Price.Value, + Result = result.Status.ToResult(result.TotalAmount), + AmountFilled = result.TotalAmount.Value, + AveragePrice = result.AverageCost?.Value ?? 0M, + FeesCurrency = result.TotalFee.Currency, + FillDate = result.DateClosed ?? DateTime.MinValue, + IsBuy = result.Type == BL3POrderType.Bid, + MarketSymbol = marketSymbol, + OrderDate = result.Date, + OrderId = result.OrderId, + TradeId = result.TradeId + }; + } + + protected override Task OnPlaceOrdersAsync(params ExchangeOrderRequest[] order) { - public const string BL3P = "BL3P"; + Debug.WriteLine( + "Splitting orders in single order calls as BL3P does not support batch operations yet", + "WARN" + ); + return Task.WhenAll(order.Select(OnPlaceOrderAsync)); } } +#nullable disable } diff --git a/ExchangeSharp/API/Exchanges/BL3P/ExchangeName.cs b/ExchangeSharp/API/Exchanges/BL3P/ExchangeName.cs new file mode 100644 index 00000000..de5bd97a --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/ExchangeName.cs @@ -0,0 +1,10 @@ +// ReSharper disable once CheckNamespace + +namespace ExchangeSharp +{ + public partial class ExchangeName + { + // ReSharper disable once InconsistentNaming + public const string BL3P = "BL3P"; + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Extensions/BL3PExtensions.cs b/ExchangeSharp/API/Exchanges/BL3P/Extensions/BL3PExtensions.cs new file mode 100644 index 00000000..37a11680 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Extensions/BL3PExtensions.cs @@ -0,0 +1,22 @@ +using ExchangeSharp.API.Exchanges.BL3P.Enums; +using ExchangeSharp.API.Exchanges.BL3P.Models; + +namespace ExchangeSharp.API.Exchanges.BL3P.Extensions +{ + internal static class BL3PExtensions + { + public static ExchangeAPIOrderResult ToResult(this BL3POrderStatus status, BL3PAmount amount) + { + return status switch + { + BL3POrderStatus.Cancelled => ExchangeAPIOrderResult.Canceled, + BL3POrderStatus.Closed => ExchangeAPIOrderResult.Filled, + BL3POrderStatus.Open when amount.Value > 0 => ExchangeAPIOrderResult.FilledPartially, + BL3POrderStatus.Open => ExchangeAPIOrderResult.Pending, + BL3POrderStatus.Pending => ExchangeAPIOrderResult.Pending, + BL3POrderStatus.Placed => ExchangeAPIOrderResult.Pending, + _ => ExchangeAPIOrderResult.Unknown + }; + } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PAmount.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PAmount.cs new file mode 100644 index 00000000..3bc19ffb --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PAmount.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models +{ + internal class BL3PAmount + { + [JsonProperty("value_int")] public long ValueInt { get; set; } + + [JsonProperty("display_short")] public string DisplayShort { get; set; } + + [JsonProperty("display")] public string Display { get; set; } + + [JsonProperty("currency")] public string Currency { get; set; } + + [JsonProperty("value")] public decimal Value { get; set; } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrder.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrder.cs deleted file mode 100644 index 5ccacf7a..00000000 --- a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrder.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Newtonsoft.Json; - -namespace ExchangeSharp -{ - public sealed partial class ExchangeBL3PAPI : ExchangeAPI - { - // ReSharper disable once InconsistentNaming - class BL3POrder - { - [JsonProperty("price_int")] - [JsonConverter(typeof(FixedIntDecimalConverter), 5)] - public decimal Price { get; set; } - - - [JsonProperty("amount_int")] - [JsonConverter(typeof(FixedIntDecimalConverter), 8)] - public decimal Amount { get; set; } - - public ExchangeOrderPrice ToExchangeOrder() - { - return new ExchangeOrderPrice - { - Amount = Amount, - Price = Price - }; - } - } - } -} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderBook.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderBook.cs index e1cef959..db80817c 100644 --- a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderBook.cs +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderBook.cs @@ -1,20 +1,17 @@ using Newtonsoft.Json; -namespace ExchangeSharp +namespace ExchangeSharp.API.Exchanges.BL3P.Models { - public sealed partial class ExchangeBL3PAPI : ExchangeAPI + // ReSharper disable once InconsistentNaming + internal class BL3POrderBook { - // ReSharper disable once InconsistentNaming - class BL3POrderBook - { - [JsonProperty("marketplace")] - public string MarketSymbol { get; set; } + [JsonProperty("marketplace")] + public string MarketSymbol { get; set; } - [JsonProperty("asks")] - public BL3POrder[] Asks { get; set; } + [JsonProperty("asks")] + public BL3POrderRequest[] Asks { get; set; } - [JsonProperty("bids")] - public BL3POrder[] Bids { get; set; } - } + [JsonProperty("bids")] + public BL3POrderRequest[] Bids { get; set; } } } diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderRequest.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderRequest.cs new file mode 100644 index 00000000..3bad1e7a --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3POrderRequest.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models +{ + // ReSharper disable once InconsistentNaming + internal class BL3POrderRequest + { + [JsonProperty("price_int")] + [JsonConverter(typeof(FixedIntDecimalJsonConverter), 5)] + public decimal Price { get; set; } + + + [JsonProperty("amount_int")] + [JsonConverter(typeof(FixedIntDecimalJsonConverter), 8)] + public decimal Amount { get; set; } + + public ExchangeOrderPrice ToExchangeOrder() + { + return new ExchangeOrderPrice + { + Amount = Amount, + Price = Price + }; + } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PResponse.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PResponse.cs new file mode 100644 index 00000000..ae913c1f --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PResponse.cs @@ -0,0 +1,60 @@ +using ExchangeSharp.API.Exchanges.BL3P.Enums; +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models +{ + internal class BL3PEmptyResponse + : BL3PResponse + { + [JsonProperty("data")] + protected override BL3PResponsePayload Data { get; set; } + } + + internal abstract class BL3PResponse + : BL3PResponse + where TSuccess : BL3PResponsePayload + { + } + + internal abstract class BL3PResponse + where TSuccess : BL3PResponsePayload + where TFail : BL3PResponsePayloadError + { + [JsonProperty("result", Required = Required.Always)] + public BL3PResponseType Result { get; set; } + + [JsonProperty("data", Required = Required.Always)] + protected abstract BL3PResponsePayload Data { get; set; } + + [JsonIgnore] + public virtual TSuccess Success => (TSuccess) Data; + + [JsonIgnore] + public virtual TFail Error => (TFail) Data; + + /// + /// Returns TSuccess or nothing + /// + public virtual TSuccess Unwrap() + { + return Result switch + { + BL3PResponseType.Success => Success, + _ => null + }; + } + + /// + /// Returns TSuccess or throws an exception + /// + /// + public virtual TSuccess Except() + { + return Result switch + { + BL3PResponseType.Success => Success, + _ => throw new BL3PException(Error) + }; + } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PResponsePayload.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PResponsePayload.cs new file mode 100644 index 00000000..7c0c11d8 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/BL3PResponsePayload.cs @@ -0,0 +1,6 @@ +namespace ExchangeSharp.API.Exchanges.BL3P.Models +{ + internal class BL3PResponsePayload + { + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/Bl3PResponsePayloadError.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/Bl3PResponsePayloadError.cs new file mode 100644 index 00000000..3479dbbe --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/Bl3PResponsePayloadError.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models +{ + internal class BL3PResponsePayloadError : BL3PResponsePayload + { + [JsonProperty("code", Required = Required.Always)] + public string ErrorCode { get; set; } + + [JsonProperty("message", Required = Required.Always)] + public string Message { get; set; } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Add/BL3POrderAddResponse.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Add/BL3POrderAddResponse.cs new file mode 100644 index 00000000..135b9284 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Add/BL3POrderAddResponse.cs @@ -0,0 +1,11 @@ +using ExchangeSharp.API.Exchanges.BL3P.Converters; +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models.Orders.Add +{ + internal class BL3POrderAddResponse : BL3PResponse + { + [JsonConverter(typeof(BL3PResponseConverter))] + protected override BL3PResponsePayload Data { get; set; } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Add/BL3POrderAddSuccess.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Add/BL3POrderAddSuccess.cs new file mode 100644 index 00000000..b8ff2be4 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Add/BL3POrderAddSuccess.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models.Orders.Add +{ + internal class BL3POrderAddSuccess : BL3PResponsePayload + { + [JsonProperty("order_id", Required = Required.Always)] + public string OrderId { get; set; } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Result/BL3POrderResultResponse.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Result/BL3POrderResultResponse.cs new file mode 100644 index 00000000..c116ece3 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Result/BL3POrderResultResponse.cs @@ -0,0 +1,11 @@ +using ExchangeSharp.API.Exchanges.BL3P.Converters; +using Newtonsoft.Json; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models.Orders.Result +{ + internal class BL3POrderResultResponse : BL3PResponse + { + [JsonConverter(typeof(BL3PResponseConverter))] + protected override BL3PResponsePayload Data { get; set; } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Result/BL3POrderResultSuccess.cs b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Result/BL3POrderResultSuccess.cs new file mode 100644 index 00000000..36eebaa6 --- /dev/null +++ b/ExchangeSharp/API/Exchanges/BL3P/Models/Orders/Result/BL3POrderResultSuccess.cs @@ -0,0 +1,113 @@ +using System; +using ExchangeSharp.API.Exchanges.BL3P.Enums; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace ExchangeSharp.API.Exchanges.BL3P.Models.Orders.Result +{ + internal class BL3POrderResultSuccess : BL3PResponsePayload + { + [JsonProperty("date")] + [JsonConverter(typeof(UnixDateTimeConverter))] + public DateTime Date { get; set; } + + /// + /// The time the order got closed. (not available for market orders and cancelled orders) + /// + [JsonProperty("date_closed")] + [JsonConverter(typeof(UnixDateTimeConverter))] + public DateTime? DateClosed { get; set; } + + /// + /// Total amount in EUR of the trades that got executed. + /// + + [JsonProperty("total_spent")] + public BL3PAmount TotalSpent { get; set; } + + /// + /// Total order amount of BTC or LTC. + /// + [JsonProperty("amount")] + public BL3PAmount Amount { get; set; } + + /// + /// Amount of funds (usually EUR) used in this order + /// + [JsonProperty("amount_funds")] + public BL3PAmount AmountFunds { get; set; } + + /// + /// Total amount of the trades that got executed. (Can be: BTC or LTC). + /// + [JsonProperty("total_amount")] + public BL3PAmount TotalAmount { get; set; } + + /// + /// Order limit price. + /// + [JsonProperty("price")] + public BL3PAmount Price { get; set; } + + /// + /// Total fee incurred in BTC or LTC. + /// + [JsonProperty("total_fee")] + public BL3PAmount TotalFee { get; set; } + + /// + /// Average cost of executed trades. + /// + [JsonProperty("avg_cost")] + public BL3PAmount? AverageCost { get; set; } + + /// + /// The item that will be traded for `currency`. (Can be: 'BTC') + /// + [JsonProperty("item")] + public string Item { get; set; } + + /// + /// Array of trades executed for the this order. + /// + [JsonProperty("trades")] + public BL3PAmount[] Trades { get; set; } + + /// + /// API Key Label + /// + [JsonProperty("label")] + public string APIKeyLabel { get; set; } + + /// + /// Type of the order. Can be bid or ask + /// + [JsonProperty("type", Required = Required.Always)] + public BL3POrderType Type { get; set; } + + /// + /// Currency of the order. (Is now by default 'EUR') + /// + /// + [JsonProperty("currency")] + public string Currency { get; set; } + + /// + /// Id of the order. + /// + [JsonProperty("order_id")] + public string OrderId { get; set; } + + /// + /// Trade ID + /// + [JsonProperty("trade_id")] + public string? TradeId { get; set; } + + /// + /// Order status + /// + [JsonProperty("status", Required = Required.Always)] + public BL3POrderStatus Status { get; set; } + } +} diff --git a/ExchangeSharp/API/Exchanges/BL3P/MultiWebsocketWrapper.cs b/ExchangeSharp/API/Exchanges/BL3P/MultiWebsocketWrapper.cs index 24e393eb..717116f8 100644 --- a/ExchangeSharp/API/Exchanges/BL3P/MultiWebsocketWrapper.cs +++ b/ExchangeSharp/API/Exchanges/BL3P/MultiWebsocketWrapper.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Threading.Tasks; -namespace ExchangeSharp.BL3P +namespace ExchangeSharp.API.Exchanges.BL3P { internal sealed class MultiWebsocketWrapper : IWebSocket { diff --git a/ExchangeSharp/Dependencies/Converters/FixedIntDecimalConverter.cs b/ExchangeSharp/Dependencies/Converters/FixedIntDecimalConverter.cs deleted file mode 100644 index 3ffef135..00000000 --- a/ExchangeSharp/Dependencies/Converters/FixedIntDecimalConverter.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using Newtonsoft.Json; - -// ReSharper disable once CheckNamespace -namespace ExchangeSharp -{ - public class FixedIntDecimalConverter : JsonConverter - { - private readonly decimal dec; - - public FixedIntDecimalConverter() - { - } - - public FixedIntDecimalConverter(int dec) - { - this.dec = decimal.Parse(1.ToString().PadRight(dec + 1, '0')); - } - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - throw new NotImplementedException(); - } - - public override object ReadJson( - JsonReader reader, - Type objectType, - object existingValue, - JsonSerializer serializer - ) - { - var valueInt = Convert.ToInt64(reader.Value); - return valueInt / dec; - } - - public override bool CanConvert(Type objectType) - { - return objectType == typeof(decimal); - } - - public override bool CanRead { get; } = true; - - public override bool CanWrite { get; } = false; - } -} diff --git a/ExchangeSharp/Dependencies/Converters/FixedIntDecimalJsonConverter.cs b/ExchangeSharp/Dependencies/Converters/FixedIntDecimalJsonConverter.cs new file mode 100644 index 00000000..2074280e --- /dev/null +++ b/ExchangeSharp/Dependencies/Converters/FixedIntDecimalJsonConverter.cs @@ -0,0 +1,49 @@ +using System; +using ExchangeSharp.Utility; +using Newtonsoft.Json; + +// ReSharper disable once CheckNamespace +namespace ExchangeSharp +{ + public class FixedIntDecimalJsonConverter : JsonConverter + { + private readonly FixedIntDecimalConverter converter; + + public FixedIntDecimalJsonConverter() + : this(1) + { + } + + public FixedIntDecimalJsonConverter(int multiplier) + { + converter = new FixedIntDecimalConverter(multiplier); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var valueLong = converter.FromDecimal((decimal) value); + writer.WriteValue(valueLong); + } + + public override object ReadJson( + JsonReader reader, + Type objectType, + object existingValue, + JsonSerializer serializer + ) + { + var valueLong = Convert.ToInt64(reader.Value); + return converter.ToDecimal(valueLong); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(decimal) + || objectType == typeof(long); + } + + public override bool CanRead { get; } = true; + + public override bool CanWrite { get; } = true; + } +} diff --git a/ExchangeSharp/Dependencies/Converters/JsonComplexObjectConverter.cs b/ExchangeSharp/Dependencies/Converters/JsonComplexObjectConverter.cs new file mode 100644 index 00000000..7cd76073 --- /dev/null +++ b/ExchangeSharp/Dependencies/Converters/JsonComplexObjectConverter.cs @@ -0,0 +1,47 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace ExchangeSharp.Dependencies.Converters +{ + /// + /// Allows deserializing complex json objects + /// + /// https://stackoverflow.com/a/8031283/4084610 + public abstract class JsonComplexObjectConverter : JsonConverter + { + public override bool CanRead => true; + + public override bool CanWrite => false; + + protected abstract T Create(JsonReader reader); + + public override bool CanConvert(Type objectType) + { + return typeof(T).IsAssignableFrom(objectType); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException("This converted should not be used to write."); + } + + public override object ReadJson( + JsonReader reader, + Type objectType, + object existingValue, + JsonSerializer serializer + ) + { + var jObject = JObject.Load(reader); + + using var jsonReader = jObject.CreateReader(); + var target = Create(jsonReader); + + using var jsonReaderPopulate = jObject.CreateReader(); + serializer.Populate(jsonReaderPopulate, target); + + return target; + } + } +} diff --git a/ExchangeSharp/ExchangeSharp.csproj b/ExchangeSharp/ExchangeSharp.csproj index 2ca23093..c46a57cf 100644 --- a/ExchangeSharp/ExchangeSharp.csproj +++ b/ExchangeSharp/ExchangeSharp.csproj @@ -5,7 +5,7 @@ Copyright 2017, Digital Ruby, LLC - www.digitalruby.com false true - latest + 8 DigitalRuby.ExchangeSharp ExchangeSharp - C# API for cryptocurrency exchanges 0.6.2 diff --git a/ExchangeSharp/Utility/ClientWebSocket.cs b/ExchangeSharp/Utility/ClientWebSocket.cs index 44bf504e..6e9b63ee 100644 --- a/ExchangeSharp/Utility/ClientWebSocket.cs +++ b/ExchangeSharp/Utility/ClientWebSocket.cs @@ -89,7 +89,10 @@ public interface IClientWebSocketImplementation : IDisposable private class ClientWebSocketImplementation : IClientWebSocketImplementation { - private readonly System.Net.WebSockets.ClientWebSocket webSocket = new System.Net.WebSockets.ClientWebSocket(); + private readonly System.Net.WebSockets.ClientWebSocket webSocket = new System.Net.WebSockets.ClientWebSocket() + { + Options = { Proxy = APIRequestMaker.InternalHttpWebRequest.Proxy } + }; public WebSocketState State { @@ -316,7 +319,7 @@ public Task SendMessageAsync(object message) }); return Task.FromResult(true); } - return Task.FromResult(false); + return Task.FromResult(false); } private void QueueActions(params Func[] actions) @@ -423,7 +426,7 @@ private async Task ReadTask() { if (result.MessageType == WebSocketMessageType.Close) { - await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, new CancellationToken()); // if it's closing, then let it complete QueueActions(InvokeDisconnected); } diff --git a/ExchangeSharp/Utility/CryptoUtility.cs b/ExchangeSharp/Utility/CryptoUtility.cs index 6ec280a3..02c1f458 100644 --- a/ExchangeSharp/Utility/CryptoUtility.cs +++ b/ExchangeSharp/Utility/CryptoUtility.cs @@ -261,6 +261,20 @@ public static byte[] StringToByteArray(this string hex) .ToArray(); } + public static string ToHexString(this byte[] bytes) + { + var sb = new StringBuilder(); + + // Loop through each byte of the hashed data + // and format each one as a hexadecimal string. + for (int i = 0; i < bytes.Length; i++) + { + sb.Append(bytes[i].ToString("x2")); + } + + return sb.ToString(); + } + /// /// Covnert a secure string to a non-secure string /// @@ -962,10 +976,15 @@ public static string SHA384SignBase64(string message, byte[] key) /// Signature in hex public static string SHA512Sign(string message, string key) { - var hmac = new HMACSHA512(key.ToBytesUTF8()); - var messagebyte = message.ToBytesUTF8(); - var hashmessage = hmac.ComputeHash(messagebyte); - return BitConverter.ToString(hashmessage).Replace("-", ""); + byte[] hashmessage; + + using (var hmac = new HMACSHA512(key.ToBytesUTF8())) + { + var messagebyte = message.ToBytesUTF8(); + hashmessage = hmac.ComputeHash(messagebyte); + } + + return BitConverter.ToString(hashmessage).Replace("-", ""); } /// @@ -976,10 +995,14 @@ public static string SHA512Sign(string message, string key) /// Signature in hex public static string SHA512Sign(string message, byte[] key) { - var hmac = new HMACSHA512(key); - var messagebyte = message.ToBytesUTF8(); - var hashmessage = hmac.ComputeHash(messagebyte); - return BitConverter.ToString(hashmessage).Replace("-", ""); + byte[] hashmessage; + using (var hmac = new HMACSHA512(key)) + { + var messagebyte = message.ToBytesUTF8(); + hashmessage = hmac.ComputeHash(messagebyte); + } + + return BitConverter.ToString(hashmessage).Replace("-", ""); } /// @@ -1194,23 +1217,21 @@ public static string SecondsToPeriodStringLong(int seconds) /// Protected data public static SecureString[] LoadProtectedStringsFromFile(string path) { - byte[] bytes = File.ReadAllBytes(path); + var bytes = File.ReadAllBytes(path); // while unprotectedBytes is populated, app is vulnerable - we clear this array ASAP to remove sensitive data from memory - byte[] unprotectedBytes = DataProtector.Unprotect(bytes); + var unprotectedBytes = DataProtector.Unprotect(bytes); - MemoryStream memory = new MemoryStream(unprotectedBytes); - BinaryReader reader = new BinaryReader(memory, Encoding.UTF8); - SecureString current; - int len; - List strings = new List(); + using var memory = new MemoryStream(unprotectedBytes); + using var reader = new BinaryReader(memory, Encoding.UTF8); + var strings = new List(); while (memory.Position != memory.Length) { // copy char by char into secure string to avoid making additional string copies of sensitive data - current = new SecureString(); + var current = new SecureString(); strings.Add(current); - len = reader.ReadInt32(); + var len = reader.ReadInt32(); while (len-- > 0) { current.AppendChar(reader.ReadChar()); diff --git a/ExchangeSharp/Utility/FixedIntDecimalConverter.cs b/ExchangeSharp/Utility/FixedIntDecimalConverter.cs new file mode 100644 index 00000000..e1bd46e8 --- /dev/null +++ b/ExchangeSharp/Utility/FixedIntDecimalConverter.cs @@ -0,0 +1,20 @@ +namespace ExchangeSharp.Utility +{ + public class FixedIntDecimalConverter + { + private readonly decimal multiplier; + + public FixedIntDecimalConverter(int multiplier) + { + this.multiplier = decimal.Parse( + 1.ToString().PadRight(multiplier + 1, '0') + ); + } + + public long FromDecimal(decimal value) + => (long) (value * multiplier); + + public decimal ToDecimal(long value) + => value / multiplier; + } +} diff --git a/ExchangeSharpConsole/Options/BuyOption.cs b/ExchangeSharpConsole/Options/BuyOption.cs new file mode 100644 index 00000000..7de49919 --- /dev/null +++ b/ExchangeSharpConsole/Options/BuyOption.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading.Tasks; +using CommandLine; +using ExchangeSharp; +using ExchangeSharpConsole.Options.Interfaces; + +namespace ExchangeSharpConsole.Options +{ + [Verb("buy", HelpText = "Adds a buy order to a given exchange.\n" + + "This sub-command will perform an action that can lead to loss of funds.\n" + + "Be sure to test it first with a dry-run.")] + public class BuyOption : BaseOption, + IOptionPerExchange, IOptionWithDryRun, + IOptionWithKey, IOptionWithInterval, IOptionWithWait, IOptionWithOrderInfo + { + public override async Task RunCommand() + { + await AddOrder(true); + } + + protected async Task AddOrder(bool isBuyOrder) + { + using var api = GetExchangeInstance(ExchangeName); + + var exchangeOrderRequest = GetExchangeOrderRequest(isBuyOrder, api); + + if (IsDryRun) + { + DumpRequest(exchangeOrderRequest); + return; + } + + api.LoadAPIKeys(KeyPath); + var result = await api.PlaceOrderAsync(exchangeOrderRequest); + + if (Wait) + { + await WaitForOrder(result, api) + .ConfigureAwait(false); + } + else + { + DumpResponse(result); + } + } + + private async Task WaitForOrder(ExchangeOrderResult order, IExchangeAPI api) + { + while (order.Result == ExchangeAPIOrderResult.Pending) + { + Console.Clear(); + Console.WriteLine(order); + + await Task.Delay(IntervalMs) + .ConfigureAwait(false); + + order = await api.GetOrderDetailsAsync(order.OrderId, order.MarketSymbol); + } + + Console.Clear(); + Console.WriteLine(order); + Console.WriteLine($"Your order changed the status to \"{order.Result}\""); + } + + private ExchangeOrderRequest GetExchangeOrderRequest(bool isBuyOrder, IExchangeAPI api) + { + var exchangeOrderRequest = new ExchangeOrderRequest + { + Amount = Amount, + Price = Price, + IsBuy = isBuyOrder, + IsMargin = IsMargin, + MarketSymbol = api.NormalizeMarketSymbol(MarketSymbol), + OrderType = OrderType, + StopPrice = StopPrice, + ShouldRoundAmount = ShouldRoundAmount + }; + + Amount = exchangeOrderRequest.RoundAmount(); + + return exchangeOrderRequest; + } + + private void DumpResponse(ExchangeOrderResult orderResult) + { + Console.WriteLine($"Order Id: {orderResult.OrderId}"); + Console.WriteLine($"Trade Id: {orderResult.TradeId}"); + Console.WriteLine($"Order Date: {orderResult.OrderDate:R}"); + Console.WriteLine($"Fill Date: {orderResult.FillDate:R}"); + Console.WriteLine($"Type: {(orderResult.IsBuy ? "Bid" : "Ask")}"); + Console.WriteLine($"Market symbol: {orderResult.MarketSymbol}"); + Console.WriteLine($"Status: {orderResult.Result}"); + Console.WriteLine($"Price: {orderResult.Price:N}"); + Console.WriteLine($"Amount: {orderResult.Amount:N}"); + Console.WriteLine($"Amount Filled: {orderResult.AmountFilled:N}"); + Console.WriteLine($"Fees: {orderResult.Fees:N}"); + Console.WriteLine($"Fees currency: {orderResult.FeesCurrency}"); + Console.WriteLine($"Message: {orderResult.Message}"); + Console.WriteLine($"Average Price: {orderResult.AveragePrice:N}"); + } + + private void DumpRequest(ExchangeOrderRequest orderRequest) + { + Console.WriteLine($"Exchange: {ExchangeName}"); + Console.WriteLine($"KeyPath: {KeyPath}"); + Console.WriteLine("---"); + Console.WriteLine($"Price: {orderRequest.Price:N}"); + Console.WriteLine($"Amount: {orderRequest.Amount:N}"); + Console.WriteLine($"StopPrice: {orderRequest.StopPrice:N}"); + Console.WriteLine($"OrderType: {orderRequest.OrderType}"); + Console.WriteLine($"IsMargin: {(orderRequest.IsMargin ? "Y" : "N")}"); + Console.WriteLine($"ShouldRoundAmount: {(orderRequest.ShouldRoundAmount ? "Y" : "N")}"); + Console.WriteLine($"MarketSymbol: {orderRequest.MarketSymbol}"); + } + + public string ExchangeName { get; set; } + + public bool Wait { get; set; } + + public bool IsDryRun { get; set; } + + public string KeyPath { get; set; } + + public decimal Price { get; set; } + + public decimal Amount { get; set; } + + public decimal StopPrice { get; set; } + + public OrderType OrderType { get; set; } + + public bool IsMargin { get; set; } + + public bool ShouldRoundAmount { get; set; } + + public string MarketSymbol { get; set; } + + public int IntervalMs { get; set; } + } +} diff --git a/ExchangeSharpConsole/Options/CancelOrderOption.cs b/ExchangeSharpConsole/Options/CancelOrderOption.cs new file mode 100644 index 00000000..a4171194 --- /dev/null +++ b/ExchangeSharpConsole/Options/CancelOrderOption.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using CommandLine; +using ExchangeSharpConsole.Options.Interfaces; + +namespace ExchangeSharpConsole.Options +{ + [Verb("cancel", HelpText = "Cancel an order in a given exchange.")] + public class CancelOrderOption : BaseOption, + IOptionPerOrderId, IOptionPerExchange, IOptionWithKey, IOptionWithMarketSymbol + { + public override async Task RunCommand() + { + using var api = GetExchangeInstance(ExchangeName); + + api.LoadAPIKeys(KeyPath); + + await api.CancelOrderAsync(OrderId, api.NormalizeMarketSymbol(MarketSymbol)); + + Console.WriteLine("Done."); + } + + public string OrderId { get; set; } + + public string ExchangeName { get; set; } + + public string MarketSymbol { get; set; } + + public string KeyPath { get; set; } + } +} diff --git a/ExchangeSharpConsole/Options/Interfaces/IOptionWithDryRun.cs b/ExchangeSharpConsole/Options/Interfaces/IOptionWithDryRun.cs new file mode 100644 index 00000000..90edf528 --- /dev/null +++ b/ExchangeSharpConsole/Options/Interfaces/IOptionWithDryRun.cs @@ -0,0 +1,11 @@ +using CommandLine; + +namespace ExchangeSharpConsole.Options.Interfaces +{ + public interface IOptionWithDryRun + { + [Option("dry-run", Default = false, + HelpText = "Prevents any requests from being performed.")] + bool IsDryRun { get; set; } + } +} diff --git a/ExchangeSharpConsole/Options/Interfaces/IOptionWithOrderInfo.cs b/ExchangeSharpConsole/Options/Interfaces/IOptionWithOrderInfo.cs new file mode 100644 index 00000000..ec296e27 --- /dev/null +++ b/ExchangeSharpConsole/Options/Interfaces/IOptionWithOrderInfo.cs @@ -0,0 +1,45 @@ +using CommandLine; +using ExchangeSharp; + +namespace ExchangeSharpConsole.Options.Interfaces +{ + public interface IOptionWithOrderInfo : IOptionPerMarketSymbol + { + [Option('r', "price", Required = true, HelpText = "The price to buy or sell at.")] + decimal Price { get; set; } + + [Option('a', "amount", Required = true, HelpText = "Amount to buy or sell.")] + decimal Amount { get; set; } + + [Option("stop-price", HelpText = "The price to trigger a stop.\n" + + "This has to be implemented and supported by the chosen exchange.")] + decimal StopPrice { get; set; } + + [Option("order-type", Required = true, Default = OrderType.Limit, HelpText = + "The type of order.\n" + + "Possible values: 'limit', 'market' and 'stop'" + )] + OrderType OrderType { get; set; } + + [Option("margin", Default = false, HelpText = + "Whether the order is a margin order. Not all exchanges support margin orders, so this parameter may be ignored.\n" + + "You should verify that your exchange supports margin orders before passing this field as true and expecting it to be a margin order.\n" + + "The best way to determine this in code is to call one of the margin account balance methods and see if it fails." + )] + bool IsMargin { get; set; } + + [Option("round", Default = true, HelpText = + "Whether the amount should be rounded.\n" + + "Set to false if you know the exact amount, otherwise leave as true so that the exchange does not reject the order due to too many decimal places." + )] + bool ShouldRoundAmount { get; set; } + + //TODO: Create a better way to describe extra parameters so it is possible to convert them from a dictionary to something that the exchange impl expects +// [Option("extra-params", Required = false, HelpText = +// "Additional order parameters specific to the exchange that don't fit in common order properties. These will be forwarded on to the exchange as key=value pairs.\n" + +// "Not all exchanges will use this dictionary.\n" + +// "These are added after all other parameters and will replace existing properties, such as order type." +// )] +// string ExtraParameters { get; set; } + } +} diff --git a/ExchangeSharpConsole/Options/Interfaces/IOptionWithWait.cs b/ExchangeSharpConsole/Options/Interfaces/IOptionWithWait.cs new file mode 100644 index 00000000..6cf5fb07 --- /dev/null +++ b/ExchangeSharpConsole/Options/Interfaces/IOptionWithWait.cs @@ -0,0 +1,10 @@ +using CommandLine; + +namespace ExchangeSharpConsole.Options.Interfaces +{ + public interface IOptionWithWait + { + [Option('w', "wait", Default = false, HelpText = "Waits interactively.")] + bool Wait { get; set; } + } +} diff --git a/ExchangeSharpConsole/Options/SellOption.cs b/ExchangeSharpConsole/Options/SellOption.cs new file mode 100644 index 00000000..69097bab --- /dev/null +++ b/ExchangeSharpConsole/Options/SellOption.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using CommandLine; + +namespace ExchangeSharpConsole.Options +{ + [Verb("sell", HelpText = "Adds a sell order to a given exchange.\n" + + "This sub-command will perform an action that can lead to loss of funds.\n" + + "Be sure to test it first with a dry-run.")] + public class SellOption : BuyOption + { + public override async Task RunCommand() + { + await AddOrder(isBuyOrder: false); + } + } +} diff --git a/ExchangeSharpConsole/Program.cs b/ExchangeSharpConsole/Program.cs index b0b27400..952e5cd2 100644 --- a/ExchangeSharpConsole/Program.cs +++ b/ExchangeSharpConsole/Program.cs @@ -22,7 +22,7 @@ public Program() c.ParsingCulture = CultureInfo.InvariantCulture; c.HelpWriter = Console.Out; c.EnableDashDash = true; - c.IgnoreUnknownArguments = true; + c.IgnoreUnknownArguments = false; c.CaseInsensitiveEnumValues = true; }); } @@ -36,6 +36,8 @@ public Program() parser .ParseArguments( args, + typeof(BuyOption), + typeof(CancelOrderOption), typeof(CandlesOption), typeof(ConvertOption), typeof(ExampleOption), @@ -45,6 +47,7 @@ public Program() typeof(MarketSymbolsOption), typeof(OrderDetailsOption), typeof(OrderHistoryOption), + typeof(SellOption), typeof(StatsOption), typeof(SupportedExchangesOption), typeof(TestOption), @@ -99,8 +102,7 @@ private async Task Run(List actions) } catch (Exception e) { - Console.Error.WriteLine(e); - + Console.Error.WriteLine(e.Message); Environment.Exit(ExitCodeError); return; } diff --git a/ExchangeSharpConsole/Utilities/ConsoleSessionKeeper.cs b/ExchangeSharpConsole/Utilities/ConsoleSessionKeeper.cs index a3a7e0c6..46ed7f52 100644 --- a/ExchangeSharpConsole/Utilities/ConsoleSessionKeeper.cs +++ b/ExchangeSharpConsole/Utilities/ConsoleSessionKeeper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Threading; namespace ExchangeSharpConsole.Utilities @@ -13,6 +14,8 @@ public ConsoleSessionKeeper(Action callback = null) { this.callback = callback; + Console.WriteLine("Press CTRL-C or Q to quit"); + threadCheckKey = new Thread(CheckKeyCombination) { Name = "console-waiter", @@ -43,13 +46,14 @@ private void CheckKeyCombination() cki = Console.ReadKey(true); } while (!(cki.Key == ConsoleKey.Q || cki.Key == ConsoleKey.Escape)); + Debug.WriteLine("Q pressed."); callback?.Invoke(); Dispose(); } private void OnConsoleOnCancelKeyPress(object sender, ConsoleCancelEventArgs args) { - Console.WriteLine("CTRL-C pressed."); + Debug.WriteLine("CTRL-C pressed."); args.Cancel = true; callback?.Invoke(); Dispose(); diff --git a/ExchangeSharpTests/ExchangeBL3PAPITests.cs b/ExchangeSharpTests/ExchangeBL3PAPITests.cs index 6878c0ea..b87a2e34 100644 --- a/ExchangeSharpTests/ExchangeBL3PAPITests.cs +++ b/ExchangeSharpTests/ExchangeBL3PAPITests.cs @@ -1,9 +1,7 @@ -using System.Linq; -using System.Threading.Tasks; +using System.Threading.Tasks; using ExchangeSharp; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using NSubstitute; namespace ExchangeSharpTests { @@ -25,26 +23,6 @@ private static ExchangeBL3PAPI CreateBL3PAPI(string response = null) return api; } - [TestMethod] - public async Task OnGetMarketSymbolsAsync_ShouldRetrieveInfoFromOnGetMarketSymbolsMetadataAsync() - { - var api = Substitute.ForPartsOf(); - api.OnGetMarketSymbolsMetadataAsync() - .Returns(new[] - { - new ExchangeMarket {MarketSymbol = "test"} - }); - - var result = await api.GetMarketSymbolsAsync(); - - var enumerated = result as string[] ?? result.ToArray(); - - enumerated.Should().NotBeNull(); - enumerated.Should().ContainSingle(em => em.Equals("test")); - - await api.Received(1).OnGetMarketSymbolsMetadataAsync(); - } - [TestMethod] public async Task ShouldParseGetTickerResult() { diff --git a/ExchangeSharpTests/ExchangeTests.cs b/ExchangeSharpTests/ExchangeTests.cs index 110d3823..124f8375 100644 --- a/ExchangeSharpTests/ExchangeTests.cs +++ b/ExchangeSharpTests/ExchangeTests.cs @@ -78,7 +78,7 @@ public async Task GlobalSymbolTest() try { if (api is ExchangeUfoDexAPI || api is ExchangeOKExAPI || api is ExchangeHitBTCAPI || api is ExchangeKuCoinAPI || - api is ExchangeOKCoinAPI || api is ExchangeDigifinexAPI || api is ExchangeNDAXAPI) + api is ExchangeOKCoinAPI || api is ExchangeDigifinexAPI || api is ExchangeNDAXAPI || api is ExchangeBL3PAPI) { // WIP continue;