From ba3d13d56cad8f2c67e1a53cf07bd1e1fd562a92 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 23 May 2018 02:18:38 +0900 Subject: [PATCH] Make sure the rate of the merchant is using the ask of a divided exchange --- BTCPayServer.Tests/BTCPayServerTester.cs | 6 +- BTCPayServer.Tests/RateRulesTest.cs | 24 ++- BTCPayServer.Tests/UnitTest1.cs | 10 +- BTCPayServer/Rating/ExchangeRates.cs | 148 ++++++++++++++++-- BTCPayServer/Rating/RateRules.cs | 55 +++++-- .../Services/Rates/BitpayRateProvider.cs | 2 +- .../Services/Rates/CoinAverageRateProvider.cs | 34 +++- .../Rates/ExchangeSharpRateProvider.cs | 2 +- .../Services/Rates/QuadrigacxRateProvider.cs | 20 ++- 9 files changed, 253 insertions(+), 48 deletions(-) diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 9feaab1f5..3f0b16985 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -129,19 +129,19 @@ namespace BTCPayServer.Tests { Exchange = "coinaverage", CurrencyPair = CurrencyPair.Parse("BTC_USD"), - Value = 5000m + BidAsk = new BidAsk(5000m) }); coinAverageMock.ExchangeRates.Add(new Rating.ExchangeRate() { Exchange = "coinaverage", CurrencyPair = CurrencyPair.Parse("BTC_CAD"), - Value = 4500m + BidAsk = new BidAsk(4500m) }); coinAverageMock.ExchangeRates.Add(new Rating.ExchangeRate() { Exchange = "coinaverage", CurrencyPair = CurrencyPair.Parse("LTC_USD"), - Value = 500m + BidAsk = new BidAsk(500m) }); rateProvider.DirectProviders.Add("coinaverage", coinAverageMock); } diff --git a/BTCPayServer.Tests/RateRulesTest.cs b/BTCPayServer.Tests/RateRulesTest.cs index 09c26fc73..3a62bb448 100644 --- a/BTCPayServer.Tests/RateRulesTest.cs +++ b/BTCPayServer.Tests/RateRulesTest.cs @@ -94,12 +94,12 @@ namespace BTCPayServer.Tests Assert.Equal(test.ExpectedExchangeRates, string.Join(',', rule.ExchangeRates.OfType().ToArray())); } var rule2 = rules.GetRuleFor(CurrencyPair.Parse("DOGE_CAD")); - rule2.ExchangeRates.SetRate("bittrex", CurrencyPair.Parse("DOGE_BTC"), 5000); + rule2.ExchangeRates.SetRate("bittrex", CurrencyPair.Parse("DOGE_BTC"), new BidAsk(5000m)); rule2.Reevaluate(); Assert.True(rule2.HasError); Assert.Equal("5000 * ERR_RATE_UNAVAILABLE(coinbase, BTC_CAD) * 1.1", rule2.ToString(true)); Assert.Equal("bittrex(DOGE_BTC) * coinbase(BTC_CAD) * 1.1", rule2.ToString(false)); - rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), 2000.4m); + rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), new BidAsk(2000.4m)); rule2.Reevaluate(); Assert.False(rule2.HasError); Assert.Equal("5000 * 2000.4 * 1.1", rule2.ToString(true)); @@ -116,7 +116,7 @@ namespace BTCPayServer.Tests rule2 = rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD")); Assert.Equal("(2000 * (-3 + coinbase(BTC_CAD) + 50 - 5)) * 1.1", rule2.ToString()); - rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), 1000m); + rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), new BidAsk(1000m)); Assert.True(rule2.Reevaluate()); Assert.Equal("(2000 * (-3 + 1000 + 50 - 5)) * 1.1", rule2.ToString(true)); Assert.Equal((2000m * (-3m + 1000m + 50m - 5m)) * 1.1m, rule2.Value.Value); @@ -124,7 +124,7 @@ namespace BTCPayServer.Tests // Test inverse rule2 = rules.GetRuleFor(CurrencyPair.Parse("USD_DOGE")); Assert.Equal("(1 / (2000 * (-3 + coinbase(BTC_CAD) + 50 - 5))) * 1.1", rule2.ToString()); - rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), 1000m); + rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), new BidAsk(1000m)); Assert.True(rule2.Reevaluate()); Assert.Equal("(1 / (2000 * (-3 + 1000 + 50 - 5))) * 1.1", rule2.ToString(true)); Assert.Equal(( 1.0m / (2000m * (-3m + 1000m + 50m - 5m))) * 1.1m, rule2.Value.Value); @@ -135,8 +135,22 @@ namespace BTCPayServer.Tests builder.AppendLine("BTC_USD = kraken(BTC_USD)"); Assert.True(RateRules.TryParse(builder.ToString(), out rules)); rule2 = rules.GetRuleFor(CurrencyPair.Parse("BTC_USD")); - rule2.ExchangeRates.SetRate("kraken", CurrencyPair.Parse("BTC_USD"), 1000m); + rule2.ExchangeRates.SetRate("kraken", CurrencyPair.Parse("BTC_USD"), new BidAsk(1000m)); Assert.True(rule2.Reevaluate()); + + // Make sure can handle pairs + builder = new StringBuilder(); + builder.AppendLine("BTC_USD = kraken(BTC_USD)"); + rule2 = rules.GetRuleFor(CurrencyPair.Parse("BTC_USD")); + rule2.ExchangeRates.SetRate("kraken", CurrencyPair.Parse("BTC_USD"), new BidAsk(6000m, 6100m)); + Assert.True(rule2.Reevaluate()); + Assert.Equal("(6000, 6100)", rule2.ToString(true)); + Assert.Equal(6000m, rule2.Value.Value); + rule2 = rules.GetRuleFor(CurrencyPair.Parse("USD_BTC")); + rule2.ExchangeRates.SetRate("kraken", CurrencyPair.Parse("BTC_USD"), new BidAsk(6000m, 6100m)); + Assert.True(rule2.Reevaluate()); + Assert.Equal("1 / (6000, 6100)", rule2.ToString(true)); + Assert.Equal(1m/6100m, rule2.Value.Value); } } } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index b5e3df28a..944edbfc1 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1464,10 +1464,10 @@ namespace BTCPayServer.Tests var quadri = new QuadrigacxRateProvider(); var rates = quadri.GetRatesAsync().GetAwaiter().GetResult(); Assert.NotEmpty(rates); - Assert.NotEqual(0.0m, rates.First().Value); - Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_CAD")).Value); - Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_USD")).Value); - Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_CAD")).Value); + Assert.NotEqual(0.0m, rates.First().BidAsk.Bid); + Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_CAD")).Bid); + Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_USD")).Bid); + Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_CAD")).Bid); Assert.Null(rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_USD"))); } @@ -1492,7 +1492,7 @@ namespace BTCPayServer.Tests e => (e.CurrencyPair == new CurrencyPair("BTC", "USD") || e.CurrencyPair == new CurrencyPair("BTC", "EUR") || e.CurrencyPair == new CurrencyPair("BTC", "USDT")) - && e.Value > 1.0m // 1BTC will always be more than 1USD + && e.BidAsk.Bid > 1.0m // 1BTC will always be more than 1USD ); } } diff --git a/BTCPayServer/Rating/ExchangeRates.cs b/BTCPayServer/Rating/ExchangeRates.cs index 868c70a3b..81eb92e35 100644 --- a/BTCPayServer/Rating/ExchangeRates.cs +++ b/BTCPayServer/Rating/ExchangeRates.cs @@ -41,9 +41,9 @@ namespace BTCPayServer.Rating } else { - if (rate.Value.HasValue) + if (rate.BidAsk != null) { - _AllRates[key].Value = rate.Value; + _AllRates[key].BidAsk = rate.BidAsk; } } } @@ -58,39 +58,167 @@ namespace BTCPayServer.Rating return GetEnumerator(); } - public void SetRate(string exchangeName, CurrencyPair currencyPair, decimal value) + public void SetRate(string exchangeName, CurrencyPair currencyPair, BidAsk bidAsk) { if (ByExchange.TryGetValue(exchangeName, out var rates)) { var rate = rates.FirstOrDefault(r => r.CurrencyPair == currencyPair); if (rate != null) - rate.Value = value; + rate.BidAsk = bidAsk; } } - public decimal? GetRate(string exchangeName, CurrencyPair currencyPair) + public BidAsk GetRate(string exchangeName, CurrencyPair currencyPair) { if (currencyPair.Left == currencyPair.Right) - return 1.0m; + return BidAsk.One; if (ByExchange.TryGetValue(exchangeName, out var rates)) { var rate = rates.FirstOrDefault(r => r.CurrencyPair == currencyPair); if (rate != null) - return rate.Value; + return rate.BidAsk; } return null; } } + public class BidAsk + { + + private readonly static BidAsk _One = new BidAsk(1.0m); + public static BidAsk One + { + get + { + return _One; + } + } + + private readonly static BidAsk _Zero = new BidAsk(0.0m); + public static BidAsk Zero + { + get + { + return _Zero; + } + } + public BidAsk(decimal bid, decimal ask) + { + if (bid > ask) + throw new ArgumentException("the bid should be lower than ask", nameof(bid)); + _Ask = ask; + _Bid = bid; + } + public BidAsk(decimal v) : this(v, v) + { + + } + + private readonly decimal _Bid; + public decimal Bid + { + get + { + return _Bid; + } + } + + + private readonly decimal _Ask; + public decimal Ask + { + get + { + return _Ask; + } + } + public BidAsk Inverse() + { + return new BidAsk(1.0m / Ask, 1.0m / Bid); + } + + public static BidAsk operator+(BidAsk a, BidAsk b) + { + return new BidAsk(a.Bid + b.Bid, a.Ask + b.Ask); + } + + public static BidAsk operator +(BidAsk a) + { + return new BidAsk(a.Bid, a.Ask); + } + + public static BidAsk operator -(BidAsk a) + { + return new BidAsk(-a.Bid, -a.Ask); + } + + public static BidAsk operator *(BidAsk a, BidAsk b) + { + return new BidAsk(a.Bid * b.Bid, a.Ask * b.Ask); + } + + public static BidAsk operator /(BidAsk a, BidAsk b) + { + // This one is tricky. + // BTC_EUR = (6000, 6100) + // Implicit rule give + // EUR_BTC = 1 / BTC_EUR + // Or + // EUR_BTC = (1, 1) / BTC_EUR + // Naive calculation would give us ( 1/6000, 1/6100) = (0.000166, 0.000163) + // However, this is an invalid BidAsk!!! because 0.000166 > 0.000163 + // So instead, we need to calculate (1/6100, 1/6000) + return new BidAsk(a.Bid / b.Ask, a.Ask / b.Bid); + } + + public static BidAsk operator-(BidAsk a, BidAsk b) + { + return new BidAsk(a.Bid - b.Bid, a.Ask - b.Ask); + } + + + public override bool Equals(object obj) + { + BidAsk item = obj as BidAsk; + if (item == null) + return false; + return Bid == item.Bid && Ask == item.Ask; + } + public static bool operator ==(BidAsk a, BidAsk b) + { + if (System.Object.ReferenceEquals(a, b)) + return true; + if (((object)a == null) || ((object)b == null)) + return false; + return a.Bid == b.Bid && a.Ask == b.Ask; + } + + public static bool operator !=(BidAsk a, BidAsk b) + { + return !(a == b); + } + + public override int GetHashCode() + { + return ToString().GetHashCode(StringComparison.InvariantCulture); + } + + public override string ToString() + { + if (Bid == Ask) + return Bid.ToString(CultureInfo.InvariantCulture); + return $"({Bid.ToString(CultureInfo.InvariantCulture)} , {Ask.ToString(CultureInfo.InvariantCulture)})"; + } + } public class ExchangeRate { public string Exchange { get; set; } public CurrencyPair CurrencyPair { get; set; } - public decimal? Value { get; set; } + public BidAsk BidAsk { get; set; } public override string ToString() { - if (Value == null) + if (BidAsk == null) return $"{Exchange}({CurrencyPair})"; - return $"{Exchange}({CurrencyPair}) == {Value.Value.ToString(CultureInfo.InvariantCulture)}"; + return $"{Exchange}({CurrencyPair}) == {BidAsk.ToString()}"; } } } diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs index e7fe02e0d..cc744d376 100644 --- a/BTCPayServer/Rating/RateRules.cs +++ b/BTCPayServer/Rating/RateRules.cs @@ -18,6 +18,7 @@ namespace BTCPayServer.Rating UnsupportedOperator, MissingArgument, DivideByZero, + InvalidNegative, PreprocessError, RateUnavailable, InvalidExchangeName, @@ -139,7 +140,7 @@ namespace BTCPayServer.Rating } return new RateRule(this, currencyPair, candidate); } - + public ExpressionSyntax FindBestCandidate(CurrencyPair p) { var invP = p.Inverse(); @@ -216,8 +217,7 @@ namespace BTCPayServer.Rating } else { - var token = SyntaxFactory.ParseToken(rate.Value.ToString(CultureInfo.InvariantCulture)); - return SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, token); + return RateRules.CreateExpression(rate.ToString()); } } } @@ -225,7 +225,7 @@ namespace BTCPayServer.Rating class CalculateWalker : CSharpSyntaxWalker { - public Stack Values = new Stack(); + public Stack Values = new Stack(); public List Errors = new List(); public override void VisitPrefixUnaryExpression(PrefixUnaryExpressionSyntax node) @@ -254,7 +254,15 @@ namespace BTCPayServer.Rating switch (node.Kind()) { case SyntaxKind.UnaryMinusExpression: - Values.Push(-Values.Pop()); + var v = Values.Pop(); + if(v.Bid == v.Ask) + { + Values.Push(-v); + } + else + { + Errors.Add(RateRulesErrors.InvalidNegative); + } break; case SyntaxKind.UnaryPlusExpression: Values.Push(+Values.Pop()); @@ -299,7 +307,7 @@ namespace BTCPayServer.Rating Values.Push(a * b); break; case SyntaxKind.DivideExpression: - if (b == decimal.Zero) + if (a.Ask == decimal.Zero || b.Ask == decimal.Zero) { Errors.Add(RateRulesErrors.DivideByZero); } @@ -309,19 +317,48 @@ namespace BTCPayServer.Rating } break; case SyntaxKind.SubtractExpression: - Values.Push(a - b); + if (b.Bid == b.Ask) + { + Values.Push(a - b); + } + else + { + Errors.Add(RateRulesErrors.InvalidNegative); + } break; default: throw new NotSupportedException("Should never happen"); } } + Stack _TupleValues = null; + public override void VisitTupleExpression(TupleExpressionSyntax node) + { + _TupleValues = new Stack(); + base.VisitTupleExpression(node); + if(_TupleValues.Count != 2) + { + Errors.Add(RateRulesErrors.MissingArgument); + } + else + { + var ask = _TupleValues.Pop(); + var bid = _TupleValues.Pop(); + Values.Push(new BidAsk(bid, ask)); + } + _TupleValues = null; + } + public override void VisitLiteralExpression(LiteralExpressionSyntax node) { switch (node.Kind()) { case SyntaxKind.NumericLiteralExpression: - Values.Push(decimal.Parse(node.ToString(), CultureInfo.InvariantCulture)); + var v = decimal.Parse(node.ToString(), CultureInfo.InvariantCulture); + if (_TupleValues == null) + Values.Push(new BidAsk(v)); + else + _TupleValues.Push(v); break; } } @@ -487,7 +524,7 @@ namespace BTCPayServer.Rating Errors.AddRange(calculate.Errors); return false; } - _Value = calculate.Values.Pop(); + _Value = calculate.Values.Pop().Bid; _EvaluatedNode = result; return true; } diff --git a/BTCPayServer/Services/Rates/BitpayRateProvider.cs b/BTCPayServer/Services/Rates/BitpayRateProvider.cs index 0898fd883..3571ad1b8 100644 --- a/BTCPayServer/Services/Rates/BitpayRateProvider.cs +++ b/BTCPayServer/Services/Rates/BitpayRateProvider.cs @@ -24,7 +24,7 @@ namespace BTCPayServer.Services.Rates { return new ExchangeRates((await _Bitpay.GetRatesAsync().ConfigureAwait(false)) .AllRates - .Select(r => new ExchangeRate() { Exchange = BitpayName, CurrencyPair = new CurrencyPair("BTC", r.Code), Value = r.Value }) + .Select(r => new ExchangeRate() { Exchange = BitpayName, CurrencyPair = new CurrencyPair("BTC", r.Code), BidAsk = new BidAsk(r.Value) }) .ToList()); } } diff --git a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs index 0ee5c26b1..228b317ae 100644 --- a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs +++ b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs @@ -54,7 +54,7 @@ namespace BTCPayServer.Services.Rates public const string CoinAverageName = "coinaverage"; public CoinAverageRateProvider() { - + } static HttpClient _Client = new HttpClient(); @@ -69,10 +69,30 @@ namespace BTCPayServer.Services.Rates public ICoinAverageAuthenticator Authenticator { get; set; } - private bool TryToDecimal(JProperty p, out decimal v) + private bool TryToBidAsk(JProperty p, out BidAsk bidAsk) { - JToken token = p.Value[Exchange == CoinAverageName ? "last" : "bid"]; - return decimal.TryParse(token.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out v); + bidAsk = null; + if (Exchange == CoinAverageName) + { + JToken last = p.Value["last"]; + if (!decimal.TryParse(last.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var v) || + v <= 0) + return false; + bidAsk = new BidAsk(v); + return true; + } + else + { + JToken bid = p.Value["bid"]; + JToken ask = p.Value["bid"]; + if (!decimal.TryParse(bid.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var v1) || + !decimal.TryParse(ask.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var v2) || + v1 > v2 || + v1 <= 0 || v2 <= 0) + return false; + bidAsk = new BidAsk(v1, v2); + return true; + } } public async Task GetRatesAsync() @@ -108,10 +128,10 @@ namespace BTCPayServer.Services.Rates { ExchangeRate exchangeRate = new ExchangeRate(); exchangeRate.Exchange = Exchange; - if (!TryToDecimal(prop, out decimal value)) + if (!TryToBidAsk(prop, out var value)) continue; - exchangeRate.Value = value; - if(CurrencyPair.TryParse(prop.Name, out var pair)) + exchangeRate.BidAsk = value; + if (CurrencyPair.TryParse(prop.Name, out var pair)) { exchangeRate.CurrencyPair = pair; exchangeRates.Add(exchangeRate); diff --git a/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs b/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs index 9aeaf7fb1..6efdad8d3 100644 --- a/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs +++ b/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs @@ -61,7 +61,7 @@ namespace BTCPayServer.Services.Rates var rate = new ExchangeRate(); rate.CurrencyPair = pair; rate.Exchange = _ExchangeName; - rate.Value = ticker.Value.Bid; + rate.BidAsk = new BidAsk(ticker.Value.Bid, ticker.Value.Ask); return rate; } catch (ArgumentException) diff --git a/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs b/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs index 10fb75189..381392ca9 100644 --- a/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs +++ b/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs @@ -14,13 +14,19 @@ namespace BTCPayServer.Services.Rates public const string QuadrigacxName = "quadrigacx"; static HttpClient _Client = new HttpClient(); - private bool TryToDecimal(JObject p, out decimal v) + private bool TryToBidAsk(JObject p, out BidAsk v) { - v = 0.0m; - JToken token = p.Property("bid")?.Value; - if (token == null) + v = null; + JToken bid = p.Property("bid")?.Value; + JToken ask = p.Property("ask")?.Value; + if (bid == null || ask == null) return false; - return decimal.TryParse(token.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out v); + if (!decimal.TryParse(bid.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var v1) || + !decimal.TryParse(bid.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var v2) || + v1 <= 0m || v2 <= 0m || v1 > v2) + return false; + v = new BidAsk(v1, v2); + return true; } public async Task GetRatesAsync() @@ -37,9 +43,9 @@ namespace BTCPayServer.Services.Rates continue; rate.CurrencyPair = pair; rate.Exchange = QuadrigacxName; - if (!TryToDecimal((JObject)prop.Value, out var v)) + if (!TryToBidAsk((JObject)prop.Value, out var v)) continue; - rate.Value = v; + rate.BidAsk = v; exchangeRates.Add(rate); } return exchangeRates;