├── CryptoTickerBot.Data ├── Domain │ ├── UserRole.cs │ ├── CryptoExchangeId.cs │ ├── TelegramBotUser.cs │ └── CryptoCoin.cs ├── Configs │ ├── IConfig.cs │ └── ConfigManager.cs ├── Extensions │ ├── CryptoCoinExtensions.cs │ ├── ListExtensions.cs │ ├── ObjectExtensions.cs │ └── StringExtensions.cs ├── Converters │ ├── StringDateTimeConverter.cs │ ├── DecimalConverter.cs │ └── UriConverter.cs ├── NLog.config ├── CryptoTickerBot.Data.csproj └── Helpers │ └── PriceChange.cs ├── CryptoTickerBot.Arbitrage ├── Common │ ├── Node.cs │ ├── Edge.cs │ ├── Cycle.cs │ └── CycleMap.cs ├── Interfaces │ ├── IEdge.cs │ ├── ICycle.cs │ ├── INode.cs │ └── IGraph.cs ├── CryptoTickerBot.Arbitrage.csproj ├── Abstractions │ ├── EdgeBase.cs │ ├── CycleBase.cs │ ├── GraphBase.cs │ └── NodeBase.cs ├── Extensions.cs └── IntraExchange │ └── Graph.cs ├── NLog.Targets.Sentry └── NLog.Targets.Sentry.csproj ├── CryptoTickerBot.CUI ├── CryptoTickerBot.CUI.csproj └── ConsolePrintService.cs ├── CryptoTickerBot.Telegram ├── CryptoTickerBot.Telegram.csproj ├── Interfaces │ ├── ITelegramBot.cs │ ├── IPage.cs │ └── IMenu.cs ├── Extensions │ ├── StringExtensions.cs │ ├── MessageExtensions.cs │ ├── EnumerableExtensions.cs │ └── CryptoExchangeExtensions.cs ├── TelegramBotService.cs ├── TelegramBotConfig.cs ├── Menus │ ├── Pages │ │ ├── MainPage.cs │ │ ├── EditSubscriptionPage.cs │ │ ├── SelectionPage.cs │ │ └── ManageSubscriptionsPage.cs │ ├── TelegramMenuManager.cs │ ├── TelegramKeyboardMenu.cs │ └── Abstractions │ │ └── PageBase.cs ├── TelegramBotData.cs ├── TelegramBotBase.cs └── Subscriptions │ └── TelegramPercentChangeSubscription.cs ├── CryptoTickerBot.Core ├── Interfaces │ ├── ICryptoExchangeSubscription.cs │ ├── IBotService.cs │ ├── IBot.cs │ └── ICryptoExchange.cs ├── Helpers │ ├── Utility.cs │ └── FiatConverter.cs ├── Extensions │ └── EnumerableExtensions.cs ├── CryptoTickerBot.Core.csproj ├── Exchanges │ ├── CoinDeltaExchange.cs │ ├── CoinbaseExchange.cs │ ├── ZebpayExchange.cs │ ├── KoinexExchange.cs │ ├── KrakenExchange.cs │ └── BitstampExchange.cs ├── Abstractions │ ├── BotServiceBase.cs │ └── CryptoExchangeSubscriptionBase.cs ├── Markets.cs ├── Subscriptions │ └── PercentChangeSubscription.cs └── CoreConfig.cs ├── CryptoTickerBot.Collections ├── CryptoTickerBot.Collections.csproj └── Persistent │ ├── Base │ ├── IPersistentCollection.cs │ ├── OpenCollections.cs │ └── PersistentCollection.cs │ ├── PersistentList.cs │ ├── PersistentDictionary.cs │ └── PersistentSet.cs ├── CryptoTickerBot.Runner ├── Properties │ └── PublishProfiles │ │ ├── Linux x64.pubxml │ │ └── Win x86.pubxml ├── CryptoTickerBot.Runner.csproj ├── RunnerConfig.cs └── Program.cs ├── CryptoTickerBot.GoogleSheets ├── CryptoTickerBot.GoogleSheets.csproj ├── SheetsConfig.cs ├── Utility.cs └── GoogleSheetsUpdaterService.cs ├── .travis.yml ├── CryptoTickerBot.UnitTests ├── DictionaryTests.cs ├── ArbitrageTests │ ├── EdgeTests.cs │ ├── NodeTests.cs │ └── CycleTests.cs ├── CryptoTickerBot.UnitTests.csproj └── PersistentCollectionsTest.cs ├── .gitattributes ├── CryptoTickerBot.sln.DotSettings ├── CODE_OF_CONDUCT.md ├── .gitignore └── CryptoTickerBot.sln /CryptoTickerBot.Data/Domain/UserRole.cs: -------------------------------------------------------------------------------- 1 | namespace CryptoTickerBot.Data.Domain 2 | { 3 | public enum UserRole 4 | { 5 | Guest = 0, 6 | Registered = 20, 7 | Admin = 80, 8 | Owner = 100 9 | } 10 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Common/Node.cs: -------------------------------------------------------------------------------- 1 | using CryptoTickerBot.Arbitrage.Abstractions; 2 | 3 | namespace CryptoTickerBot.Arbitrage.Common 4 | { 5 | public class Node : NodeBase 6 | { 7 | public Node ( string symbol ) : base ( symbol ) 8 | { 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Interfaces/IEdge.cs: -------------------------------------------------------------------------------- 1 | namespace CryptoTickerBot.Arbitrage.Interfaces 2 | { 3 | public interface IEdge 4 | { 5 | INode From { get; } 6 | INode To { get; } 7 | decimal OriginalCost { get; } 8 | 9 | double Weight { get; } 10 | void CopyFrom ( IEdge edge ); 11 | } 12 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Domain/CryptoExchangeId.cs: -------------------------------------------------------------------------------- 1 | namespace CryptoTickerBot.Data.Domain 2 | { 3 | public enum CryptoExchangeId 4 | { 5 | Koinex = 1, 6 | BitBay, 7 | Binance, 8 | CoinDelta, 9 | Coinbase, 10 | Kraken, 11 | Bitstamp, 12 | Bitfinex, 13 | Poloniex, 14 | Zebpay 15 | } 16 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Common/Edge.cs: -------------------------------------------------------------------------------- 1 | using CryptoTickerBot.Arbitrage.Abstractions; 2 | using CryptoTickerBot.Arbitrage.Interfaces; 3 | 4 | namespace CryptoTickerBot.Arbitrage.Common 5 | { 6 | public class Edge : EdgeBase 7 | { 8 | public Edge ( INode from, 9 | INode to, 10 | decimal cost ) : base ( from, to, cost ) 11 | { 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/CryptoTickerBot.Arbitrage.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Interfaces/ICycle.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | 4 | namespace CryptoTickerBot.Arbitrage.Interfaces 5 | { 6 | public interface ICycle : IEquatable> where TNode : INode 7 | { 8 | ImmutableList Path { get; } 9 | ImmutableList Edges { get; } 10 | 11 | int Length { get; } 12 | double Weight { get; } 13 | 14 | double UpdateWeight ( ); 15 | } 16 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Common/Cycle.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using CryptoTickerBot.Arbitrage.Abstractions; 3 | using CryptoTickerBot.Arbitrage.Interfaces; 4 | 5 | namespace CryptoTickerBot.Arbitrage.Common 6 | { 7 | public class Cycle : CycleBase where TNode : INode 8 | { 9 | public Cycle ( IEnumerable path ) : base ( path ) 10 | { 11 | } 12 | 13 | public Cycle ( params TNode[] path ) : base ( path ) 14 | { 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /NLog.Targets.Sentry/NLog.Targets.Sentry.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Configs/IConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace CryptoTickerBot.Data.Configs 6 | { 7 | public interface IConfig where TConfig : IConfig 8 | { 9 | [JsonIgnore] 10 | string ConfigFileName { get; } 11 | 12 | [JsonIgnore] 13 | string ConfigFolderName { get; } 14 | 15 | bool TryValidate ( out IList exceptions ); 16 | 17 | TConfig RestoreDefaults ( ); 18 | } 19 | } -------------------------------------------------------------------------------- /CryptoTickerBot.CUI/CryptoTickerBot.CUI.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/CryptoTickerBot.Telegram.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Interfaces/INode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace CryptoTickerBot.Arbitrage.Interfaces 5 | { 6 | public interface INode : IEquatable, IComparable 7 | { 8 | string Symbol { get; } 9 | IReadOnlyDictionary EdgeTable { get; } 10 | IEnumerable Edges { get; } 11 | 12 | IEdge this [ string symbol ] { get; } 13 | 14 | bool AddOrUpdateEdge ( IEdge edge ); 15 | bool HasEdge ( string symbol ); 16 | } 17 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Interfaces/ICryptoExchangeSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CryptoTickerBot.Data.Domain; 3 | using Newtonsoft.Json; 4 | 5 | namespace CryptoTickerBot.Core.Interfaces 6 | { 7 | public interface ICryptoExchangeSubscription : 8 | IDisposable, 9 | IObserver, 10 | IEquatable 11 | { 12 | Guid Id { get; } 13 | 14 | [JsonIgnore] 15 | ICryptoExchange Exchange { get; } 16 | 17 | DateTime CreationTime { get; } 18 | 19 | [JsonIgnore] 20 | TimeSpan ActiveSince { get; } 21 | 22 | void Stop ( ); 23 | } 24 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Interfaces/ITelegramBot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Polly; 5 | using Telegram.Bot; 6 | using Telegram.Bot.Types; 7 | 8 | namespace CryptoTickerBot.Telegram.Interfaces 9 | { 10 | public interface ITelegramBot 11 | { 12 | TelegramBotClient Client { get; } 13 | User Self { get; } 14 | TelegramBotConfig Config { get; } 15 | Policy Policy { get; } 16 | DateTime StartTime { get; } 17 | CancellationToken CancellationToken { get; } 18 | Task StartAsync ( ); 19 | void Stop ( ); 20 | } 21 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/CryptoTickerBot.Collections.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Interfaces/IPage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Telegram.Bot.Types; 4 | using Telegram.Bot.Types.ReplyMarkups; 5 | 6 | namespace CryptoTickerBot.Telegram.Interfaces 7 | { 8 | public interface IPage 9 | { 10 | string Title { get; } 11 | IEnumerable> Labels { get; } 12 | InlineKeyboardMarkup Keyboard { get; } 13 | IPage PreviousPage { get; } 14 | IMenu Menu { get; } 15 | Task HandleMessageAsync ( Message message ); 16 | Task HandleQueryAsync ( CallbackQuery query ); 17 | Task WaitForButtonPressAsync ( ); 18 | } 19 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Interfaces/IBotService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CryptoTickerBot.Data.Domain; 4 | 5 | namespace CryptoTickerBot.Core.Interfaces 6 | { 7 | public interface IBotService : IDisposable, IEquatable 8 | { 9 | Guid Guid { get; } 10 | IBot Bot { get; } 11 | bool IsAttached { get; } 12 | 13 | Task AttachToAsync ( IBot bot ); 14 | Task DetachAsync ( ); 15 | 16 | Task OnNextAsync ( ICryptoExchange exchange, 17 | CryptoCoin coin ); 18 | 19 | Task OnChangedAsync ( ICryptoExchange exchange, 20 | CryptoCoin coin ); 21 | } 22 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Extensions/CryptoCoinExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Diagnostics.Contracts; 3 | using CryptoTickerBot.Data.Domain; 4 | 5 | namespace CryptoTickerBot.Data.Extensions 6 | { 7 | public static class CryptoCoinExtensions 8 | { 9 | [DebuggerStepThrough] 10 | [Pure] 11 | public static decimal Buy ( this CryptoCoin coin, 12 | decimal amountInUsd ) => amountInUsd / coin.BuyPrice; 13 | 14 | [DebuggerStepThrough] 15 | [Pure] 16 | public static decimal Sell ( this CryptoCoin coin, 17 | decimal quantity ) => coin.SellPrice * quantity; 18 | } 19 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Runner/Properties/PublishProfiles/Linux x64.pubxml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | FileSystem 8 | Release 9 | Any CPU 10 | netcoreapp3.0 11 | bin\Release\netcoreapp3.0\publish\linux 12 | linux-x64 13 | true 14 | <_IsPortable>false 15 | 16 | -------------------------------------------------------------------------------- /CryptoTickerBot.Runner/Properties/PublishProfiles/Win x86.pubxml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | FileSystem 8 | Release 9 | Any CPU 10 | netcoreapp3.0 11 | bin\Release\netcoreapp3.0\publish\windows 12 | true 13 | <_IsPortable>false 14 | win-x86 15 | 16 | -------------------------------------------------------------------------------- /CryptoTickerBot.GoogleSheets/CryptoTickerBot.GoogleSheets.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /CryptoTickerBot.Runner/CryptoTickerBot.Runner.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp2.2 6 | 7.3 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using Humanizer; 2 | using Telegram.Bot.Types.Enums; 3 | using Telegram.Bot.Types.InlineQueryResults; 4 | using Telegram.Bot.Types.ReplyMarkups; 5 | 6 | namespace CryptoTickerBot.Telegram.Extensions 7 | { 8 | public static class StringExtensions 9 | { 10 | public static string ToMarkdown ( this string str ) => 11 | $"```\n{str.Truncate ( 4000 )}\n```"; 12 | 13 | public static InputTextMessageContent ToMarkdownMessage ( this string str ) => 14 | new InputTextMessageContent ( $"```\n{str.Truncate ( 4000 )}\n```" ) {ParseMode = ParseMode.Markdown}; 15 | 16 | public static InlineKeyboardButton ToKeyboardButton ( this string label ) => 17 | new InlineKeyboardButton 18 | { 19 | Text = label.Titleize ( ), 20 | CallbackData = label 21 | }; 22 | } 23 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Runner/RunnerConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CryptoTickerBot.Data.Configs; 4 | 5 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 6 | 7 | namespace CryptoTickerBot.Runner 8 | { 9 | public class RunnerConfig : IConfig 10 | { 11 | public string ConfigFileName { get; } = "Runner"; 12 | public string ConfigFolderName { get; } = "Configs"; 13 | 14 | public bool EnableConsoleService { get; set; } = false; 15 | public bool EnableGoogleSheetsService { get; set; } = true; 16 | public bool EnableTelegramService { get; set; } = true; 17 | 18 | public bool TryValidate ( out IList exceptions ) 19 | { 20 | exceptions = new List ( ); 21 | return true; 22 | } 23 | 24 | public RunnerConfig RestoreDefaults ( ) => this; 25 | } 26 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Abstractions/EdgeBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CryptoTickerBot.Arbitrage.Interfaces; 3 | 4 | namespace CryptoTickerBot.Arbitrage.Abstractions 5 | { 6 | public abstract class EdgeBase : IEdge 7 | { 8 | public virtual INode From { get; } 9 | public virtual INode To { get; } 10 | public decimal OriginalCost { get; protected set; } 11 | 12 | public virtual double Weight => -Math.Log ( (double) OriginalCost ); 13 | 14 | protected EdgeBase ( INode from, 15 | INode to, 16 | decimal cost ) 17 | { 18 | From = from; 19 | To = to; 20 | OriginalCost = cost; 21 | } 22 | 23 | public virtual void CopyFrom ( IEdge edge ) 24 | { 25 | OriginalCost = edge.OriginalCost; 26 | } 27 | 28 | public override string ToString ( ) => $"{From.Symbol} -> {To.Symbol} {OriginalCost} {Weight}"; 29 | } 30 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/TelegramBotService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CryptoTickerBot.Core.Abstractions; 3 | 4 | namespace CryptoTickerBot.Telegram 5 | { 6 | public class TelegramBotService : BotServiceBase 7 | { 8 | public TelegramBot TelegramBot { get; set; } 9 | public TelegramBotConfig TelegramBotConfig { get; set; } 10 | 11 | public TelegramBotService ( TelegramBotConfig telegramBotConfig ) 12 | { 13 | TelegramBotConfig = telegramBotConfig; 14 | } 15 | 16 | public override Task StartAsync ( ) 17 | { 18 | TelegramBot = new TelegramBot ( TelegramBotConfig, Bot ); 19 | 20 | TelegramBot.Ctb.Start += async bot => 21 | await TelegramBot.StartAsync ( ).ConfigureAwait ( false ); 22 | 23 | return Task.CompletedTask; 24 | } 25 | 26 | public override Task StopAsync ( ) 27 | { 28 | TelegramBot.Stop ( ); 29 | return Task.CompletedTask; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Domain/TelegramBotUser.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace CryptoTickerBot.Data.Domain 4 | { 5 | public class TelegramBotUser 6 | { 7 | [JsonProperty ( Required = Required.Always )] 8 | public int Id { get; set; } 9 | 10 | [JsonProperty ( Required = Required.Always )] 11 | public bool IsBot { get; set; } 12 | 13 | [JsonProperty ( Required = Required.Always )] 14 | public string FirstName { get; set; } 15 | 16 | [JsonProperty ( DefaultValueHandling = DefaultValueHandling.Ignore )] 17 | public string LastName { get; set; } 18 | 19 | [JsonProperty ( DefaultValueHandling = DefaultValueHandling.Ignore )] 20 | public string Username { get; set; } 21 | 22 | [JsonProperty ( DefaultValueHandling = DefaultValueHandling.Ignore )] 23 | public string LanguageCode { get; set; } 24 | 25 | [JsonProperty ( Required = Required.Always )] 26 | public UserRole Role { get; set; } 27 | } 28 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Extensions/MessageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Telegram.Bot.Types; 5 | 6 | namespace CryptoTickerBot.Telegram.Extensions 7 | { 8 | public static class MessageExtensions 9 | { 10 | public static (string command, List @params ) ExtractCommand ( this Message message, 11 | User self ) 12 | { 13 | var text = message.Text; 14 | var command = text.Split ( ' ' ).First ( ); 15 | 16 | var index = command.IndexOf ( $"@{self.Username}", StringComparison.OrdinalIgnoreCase ); 17 | if ( index != -1 ) 18 | command = command.Substring ( 0, index ); 19 | 20 | var @params = text 21 | .Split ( new[] {' '}, StringSplitOptions.RemoveEmptyEntries ) 22 | .Skip ( 1 ) 23 | .ToList ( ); 24 | 25 | return ( command, @params ); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Helpers/Utility.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Net; 3 | using System.Threading.Tasks; 4 | 5 | namespace CryptoTickerBot.Core.Helpers 6 | { 7 | public static class Utility 8 | { 9 | static Utility ( ) 10 | { 11 | ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; 12 | } 13 | 14 | public static async Task DownloadWebPageAsync ( string url ) 15 | { 16 | var client = new WebClient ( ); 17 | 18 | client.Headers.Add ( "user-agent", 19 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36Mozilla/5.0" ); 20 | 21 | var data = await client.OpenReadTaskAsync ( url ).ConfigureAwait ( false ); 22 | 23 | if ( data == null ) return null; 24 | 25 | var reader = new StreamReader ( data ); 26 | var s = await reader.ReadToEndAsync ( ).ConfigureAwait ( false ); 27 | return s; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: csharp 2 | mono: none 3 | sudo: required 4 | dist: xenial 5 | dotnet: 2.2 6 | script: 7 | - dotnet restore 8 | - dotnet build 9 | - dotnet test CryptoTickerBot.UnitTests 10 | global: 11 | - DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true 12 | - DOTNET_CLI_TELEMETRY_OPTOUT=1 13 | notifications: 14 | slack: 15 | secure: Nd4AAHKwcxeF7T8ieUYzZ4dWyv9gSYeC4XPc8ajrZEWy7v73L15QSoOjELXgGUpnzXjs8ptTT+k61TADiT6UlV/iE5gY/S2tp1tZwDR3oWMZT7BMBRMNWFUakA8tQc4T9u+35isGC60Kc3Qb3fZt3JsABOmAbacHAt58FK6oGmZecMVzTZj62XrYpG6izornhEX891knNetnGI3tcZnaKaQSBM01WeSkzJOkRT2EypFfiNBzHXiwSuXL7rv6enR9gtJBPWlZyjKLgqs/8BoS0TrTbMZ6E7lWBe0WytMx62/ORo1RMh+smNOPOs8sl6xwmr+TWm1oDGix95x+AQ21tLdXX3paxnixpsRueLRTtN1xe6OqrAHXOsjh550CAedqlGGbdDZIwelZNw8kwaxBqGhUsr3P30BuC130JxFkvQxW7fidINVWtpbuHa30qzfXYNxr0fSS7Fr2VOYMpxrhclejTbxSu3V10oDO7S28/ovgtvWyRfZM9AfsGIDW+g33/GuIml2IrmPfME6tsx0fT+Ct3zNP3wJOvyoMkNLvC2tzwjQj2PaYW8lD+xmOXai7foPckoatRos1dY3VXp/Rbual5sWB/5sGL2jgwnlvo2xEzZP0kzbmRIWA4fejxGPmHdf2cCeFufQyGF7x8J0jiDxCqOTd11n575T/AAoCRv8= 16 | -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using MoreLinq; 4 | using Telegram.Bot.Types.ReplyMarkups; 5 | 6 | namespace CryptoTickerBot.Telegram.Extensions 7 | { 8 | public static class EnumerableExtensions 9 | { 10 | public static InlineKeyboardMarkup ToInlineKeyboardMarkup ( this IEnumerable labels ) => 11 | new InlineKeyboardMarkup ( labels.Select ( x => x.ToKeyboardButton ( ) ) ); 12 | 13 | public static InlineKeyboardMarkup ToInlineKeyboardMarkup ( this IEnumerable labels, 14 | int batchSize ) => 15 | new InlineKeyboardMarkup ( labels.Select ( x => x.ToKeyboardButton ( ) ).Batch ( batchSize ) ); 16 | 17 | public static InlineKeyboardMarkup ToInlineKeyboardMarkup ( this IEnumerable> labels ) => 18 | new InlineKeyboardMarkup ( labels.Select ( x => x.Select ( y => y.ToKeyboardButton ( ) ) ).ToArray ( ) ); 19 | } 20 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/Persistent/Base/IPersistentCollection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace CryptoTickerBot.Collections.Persistent.Base 6 | { 7 | public interface IPersistentCollection : IDisposable 8 | { 9 | string FileName { get; } 10 | JsonSerializerSettings SerializerSettings { get; set; } 11 | TimeSpan FlushInterval { get; } 12 | 13 | event SaveDelegate OnSave; 14 | event LoadDelegate OnLoad; 15 | event ErrorDelegate OnError; 16 | 17 | void ForceSave ( ); 18 | void Save ( ); 19 | bool Load ( ); 20 | } 21 | 22 | public interface IPersistentCollection : IPersistentCollection, ICollection 23 | { 24 | } 25 | 26 | public delegate void SaveDelegate ( IPersistentCollection collection ); 27 | 28 | public delegate void LoadDelegate ( IPersistentCollection collection ); 29 | 30 | public delegate void ErrorDelegate ( IPersistentCollection collection, 31 | Exception exception ); 32 | } -------------------------------------------------------------------------------- /CryptoTickerBot.UnitTests/DictionaryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Newtonsoft.Json; 5 | using NUnit.Framework; 6 | 7 | namespace CryptoTickerBot.UnitTests 8 | { 9 | [TestFixture] 10 | public class DictionaryTests 11 | { 12 | [Test] 13 | public void DictionaryRetainsInsertionOrder ( ) 14 | { 15 | var random = new Random ( 42 ); 16 | var normal = new Dictionary ( ); 17 | var keys = new List ( 10000 ); 18 | foreach ( var _ in Enumerable.Range ( 1, 10000 ) ) 19 | { 20 | var value = random.Next ( 100, int.MaxValue ); 21 | var key = $"{value}"; 22 | keys.Add ( key ); 23 | normal[key] = value; 24 | } 25 | 26 | var json = JsonConvert.SerializeObject ( normal ); 27 | var rebuilt = JsonConvert.DeserializeObject> ( json ); 28 | Assert.True ( new Dictionary ( normal ).Keys.SequenceEqual ( rebuilt.Keys ) ); 29 | Assert.True ( keys.SequenceEqual ( rebuilt.Keys ) ); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /CryptoTickerBot.UnitTests/ArbitrageTests/EdgeTests.cs: -------------------------------------------------------------------------------- 1 | using CryptoTickerBot.Arbitrage.Common; 2 | using NUnit.Framework; 3 | 4 | namespace CryptoTickerBot.UnitTests.ArbitrageTests 5 | { 6 | [TestFixture] 7 | public class EdgeTests 8 | { 9 | [Test] 10 | public void EdgeCopyFromShouldNotChangeNodeReferences ( ) 11 | { 12 | var nodeA = new Node ( "A" ); 13 | var nodeB = new Node ( "B" ); 14 | var nodeC = new Node ( "C" ); 15 | var nodeD = new Node ( "D" ); 16 | var edgeAb = new Edge ( nodeA, nodeB, 42m ); 17 | var edgeCd = new Edge ( nodeC, nodeD, 0m ); 18 | 19 | Assert.AreSame ( edgeCd.From, nodeC ); 20 | Assert.AreSame ( edgeCd.To, nodeD ); 21 | Assert.AreNotEqual ( edgeAb.To, edgeCd.To ); 22 | Assert.AreEqual ( edgeAb.OriginalCost, 42m ); 23 | Assert.AreEqual ( edgeCd.OriginalCost, 0m ); 24 | 25 | edgeCd.CopyFrom ( edgeAb ); 26 | Assert.AreSame ( edgeCd.From, nodeC ); 27 | Assert.AreSame ( edgeCd.To, nodeD ); 28 | Assert.AreEqual ( edgeAb.OriginalCost, 42m ); 29 | Assert.AreEqual ( edgeCd.OriginalCost, 42m ); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/TelegramBotConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using CryptoTickerBot.Data.Configs; 5 | 6 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 7 | 8 | namespace CryptoTickerBot.Telegram 9 | { 10 | public class TelegramBotConfig : IConfig 11 | { 12 | public string ConfigFileName { get; } = "TelegramBot"; 13 | public string ConfigFolderName { get; } = "Configs"; 14 | 15 | public string BotToken { get; set; } 16 | public int RetryLimit { get; set; } = 5; 17 | public TimeSpan RetryInterval { get; set; } = TimeSpan.FromSeconds ( 5 ); 18 | 19 | public TelegramBotConfig RestoreDefaults ( ) => 20 | new TelegramBotConfig 21 | { 22 | BotToken = BotToken 23 | }; 24 | 25 | public bool TryValidate ( out IList exceptions ) 26 | { 27 | exceptions = new List ( ); 28 | 29 | if ( string.IsNullOrEmpty ( BotToken ) ) 30 | exceptions.Add ( new ArgumentException ( "Bot Token missing", nameof ( BotToken ) ) ); 31 | 32 | return !exceptions.Any ( ); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /CryptoTickerBot.CUI/ConsolePrintService.cs: -------------------------------------------------------------------------------- 1 | using System.Drawing; 2 | using System.Threading.Tasks; 3 | using Colorful; 4 | using CryptoTickerBot.Core.Abstractions; 5 | using CryptoTickerBot.Core.Interfaces; 6 | using CryptoTickerBot.Data.Domain; 7 | 8 | namespace CryptoTickerBot.CUI 9 | { 10 | public class ConsolePrintService : BotServiceBase 11 | { 12 | public StyleSheet StyleSheet { get; } 13 | private readonly object consoleLock = new object ( ); 14 | 15 | public ConsolePrintService ( ) 16 | { 17 | StyleSheet = new StyleSheet ( Color.White ); 18 | StyleSheet.AddStyle ( @"Highest Bid = (0|[1-9][\d,]*)?(\.\d+)?(?<=\d)", Color.GreenYellow ); 19 | StyleSheet.AddStyle ( @"Lowest Ask = (0|[1-9][\d,]*)?(\.\d+)?(?<=\d)", Color.OrangeRed ); 20 | } 21 | 22 | public override Task OnChangedAsync ( ICryptoExchange exchange, 23 | CryptoCoin coin ) 24 | { 25 | lock ( consoleLock ) 26 | { 27 | Console.Write ( $"{exchange.Name,-12}", Color.DodgerBlue ); 28 | Console.WriteLineStyled ( $" | {coin}", StyleSheet ); 29 | } 30 | 31 | return Task.CompletedTask; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.Contracts; 3 | using System.Linq; 4 | using CryptoTickerBot.Core.Interfaces; 5 | 6 | namespace CryptoTickerBot.Core.Extensions 7 | { 8 | public static class EnumerableExtensions 9 | { 10 | [Pure] 11 | public static decimal Product ( this IEnumerable enumerable ) => 12 | enumerable.Aggregate ( 1m, ( cur, 13 | next ) => cur * next ); 14 | 15 | [Pure] 16 | public static double Product ( this IEnumerable enumerable ) => 17 | enumerable.Aggregate ( 1d, ( cur, 18 | next ) => cur * next ); 19 | 20 | [Pure] 21 | public static int Product ( this IEnumerable enumerable ) => 22 | enumerable.Aggregate ( 1, ( cur, 23 | next ) => cur * next ); 24 | 25 | [Pure] 26 | public static IEnumerable ToTables ( 27 | this IEnumerable exchanges, 28 | string fiat = "USD" 29 | ) => 30 | exchanges.Select ( exchange => $"{exchange.Name}\n{exchange.ToTable ( fiat )}" ); 31 | } 32 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/Persistent/Base/OpenCollections.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | 3 | namespace CryptoTickerBot.Collections.Persistent.Base 4 | { 5 | internal static class OpenCollections 6 | { 7 | public static ImmutableDictionary Data { get; private set; } = 8 | ImmutableDictionary.Empty; 9 | 10 | public static bool Add ( IPersistentCollection collection ) 11 | { 12 | if ( Data.ContainsKey ( collection.FileName ) ) 13 | return false; 14 | 15 | Data = Data.Add ( collection.FileName, collection ); 16 | return true; 17 | } 18 | 19 | public static bool TryOpen ( string fileName, 20 | out IPersistentCollection opened ) 21 | { 22 | if ( Data.TryGetValue ( fileName, out var existing ) ) 23 | { 24 | opened = existing; 25 | return true; 26 | } 27 | 28 | opened = null; 29 | return false; 30 | } 31 | 32 | public static bool Remove ( string fileName ) 33 | { 34 | if ( !Data.ContainsKey ( fileName ) ) 35 | return false; 36 | 37 | Data = Data.Remove ( fileName ); 38 | return true; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Converters/StringDateTimeConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace CryptoTickerBot.Data.Converters 5 | { 6 | public class StringDateTimeConverter : JsonConverter 7 | { 8 | public override bool CanConvert ( Type t ) => t == typeof ( DateTime ) || t == typeof ( DateTime? ); 9 | 10 | public override object ReadJson ( JsonReader reader, 11 | Type t, 12 | object existingValue, 13 | JsonSerializer serializer ) 14 | { 15 | if ( reader.TokenType == JsonToken.Null ) return null; 16 | var value = serializer.Deserialize ( reader ); 17 | 18 | if ( long.TryParse ( value, out var l ) ) 19 | return DateTimeOffset 20 | .FromUnixTimeSeconds ( l ) 21 | .UtcDateTime; 22 | 23 | throw new ArgumentException ( "Cannot un-marshall type long" ); 24 | } 25 | 26 | public override void WriteJson ( JsonWriter writer, 27 | object untypedValue, 28 | JsonSerializer serializer ) 29 | { 30 | // Not implemented 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/NLog.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Extensions/ListExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics; 3 | using System.Diagnostics.Contracts; 4 | using System.Reflection; 5 | 6 | namespace CryptoTickerBot.Data.Extensions 7 | { 8 | public static class ListExtensions 9 | { 10 | [DebuggerStepThrough] 11 | [Pure] 12 | public static string Join ( this IEnumerable enumerable, 13 | string delimiter ) => 14 | string.Join ( delimiter, enumerable ); 15 | 16 | [DebuggerStepThrough] 17 | [Pure] 18 | public static T GetFieldValue ( this object obj, 19 | string name ) 20 | { 21 | var field = obj.GetType ( ) 22 | .GetField ( name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance ); 23 | return (T) field?.GetValue ( obj ); 24 | } 25 | 26 | [DebuggerStepThrough] 27 | [Pure] 28 | public static object GetFieldValue ( this object obj, 29 | string name ) 30 | { 31 | var field = obj.GetType ( ) 32 | .GetField ( name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance ); 33 | return field?.GetValue ( obj ); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Interfaces/IGraph.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | 3 | namespace CryptoTickerBot.Arbitrage.Interfaces 4 | { 5 | public interface IGraph where TNode : INode 6 | { 7 | ImmutableDictionary Nodes { get; } 8 | 9 | TNode this [ string symbol ] { get; } 10 | NodeBuilderDelegate NodeBuilder { get; } 11 | EdgeBuilderDelegate DefaultEdgeBuilder { get; } 12 | 13 | bool ContainsNode ( string symbol ); 14 | TNode AddNode ( string symbol ); 15 | 16 | IEdge UpsertEdge ( string from, 17 | string to, 18 | decimal cost ); 19 | 20 | TEdge UpsertEdge ( string from, 21 | string to, 22 | decimal cost, 23 | EdgeBuilderDelegate edgeBuilder ) where TEdge : class, IEdge; 24 | } 25 | 26 | public delegate TNode NodeBuilderDelegate ( string symbol ); 27 | 28 | public delegate TEdge EdgeBuilderDelegate ( TNode from, 29 | TNode to, 30 | decimal cost ); 31 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/CryptoTickerBot.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | AnyCPU 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Interfaces/IMenu.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Telegram.Bot.Types; 3 | using Telegram.Bot.Types.ReplyMarkups; 4 | 5 | namespace CryptoTickerBot.Telegram.Interfaces 6 | { 7 | public interface IMenu 8 | { 9 | User User { get; } 10 | Chat Chat { get; } 11 | TelegramBot TelegramBot { get; } 12 | IPage CurrentPage { get; } 13 | Message LastMessage { get; } 14 | bool IsOpen { get; } 15 | 16 | Task DeleteAsync ( ); 17 | Task DisplayAsync ( IPage page ); 18 | 19 | Task SwitchPageAsync ( IPage page, 20 | bool replaceOld = false ); 21 | 22 | Task HandleMessageAsync ( Message message ); 23 | Task HandleQueryAsync ( CallbackQuery query ); 24 | 25 | Task SendTextBlockAsync ( 26 | string text, 27 | int replyToMessageId = 0, 28 | bool disableWebPagePreview = false, 29 | bool disableNotification = true, 30 | IReplyMarkup replyMarkup = null 31 | ); 32 | 33 | Task EditTextBlockAsync ( 34 | int messageId, 35 | string text, 36 | InlineKeyboardMarkup markup = null 37 | ); 38 | 39 | Task RequestReplyAsync ( 40 | string text, 41 | bool disableWebPagePreview = false, 42 | bool disableNotification = true 43 | ); 44 | 45 | Task WaitForMessageAsync ( ); 46 | } 47 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Extensions/CryptoExchangeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using CryptoTickerBot.Core.Interfaces; 3 | using Humanizer; 4 | using Humanizer.Localisation; 5 | using Telegram.Bot.Types.InlineQueryResults; 6 | 7 | namespace CryptoTickerBot.Telegram.Extensions 8 | { 9 | public static class CryptoExchangeExtensions 10 | { 11 | public static InlineQueryResultArticle ToInlineQueryResult ( 12 | this ICryptoExchange exchange, 13 | params string[] symbols 14 | ) => 15 | new InlineQueryResultArticle ( 16 | exchange.Name, exchange.Name, 17 | $"{exchange.Name}\n{exchange.ToTable ( symbols )}".ToMarkdownMessage ( ) 18 | ); 19 | 20 | public static string GetSummary ( this ICryptoExchange exchange ) 21 | { 22 | var sb = new StringBuilder ( ); 23 | 24 | sb.AppendLine ( $"Name: {exchange.Name}" ); 25 | sb.AppendLine ( $"Is Running: {exchange.IsStarted}" ); 26 | sb.AppendLine ( $"Url: {exchange.Url}" ); 27 | sb.AppendLine ( $"Up Time: {exchange.UpTime.Humanize ( 2, minUnit: TimeUnit.Second )}" ); 28 | sb.AppendLine ( $"Last Change: {exchange.LastChangeDuration.Humanize ( 2 )}" ); 29 | sb.AppendLine ( $"Base Symbols: {exchange.BaseSymbols.Humanize ( )}" ); 30 | sb.AppendLine ( $"Total Pairs: {exchange.Count}" ); 31 | 32 | return sb.ToString ( ); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Extensions/ObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace CryptoTickerBot.Data.Extensions 4 | { 5 | public static class ObjectExtensions 6 | { 7 | public static string ToJson ( this T value ) => 8 | JsonConvert.SerializeObject ( value ); 9 | 10 | public static string ToJson ( this T value, 11 | Formatting formatting, 12 | JsonSerializerSettings settings ) => 13 | JsonConvert.SerializeObject ( value, formatting, settings ); 14 | 15 | public static string ToJson ( this T value, 16 | Formatting formatting, 17 | params JsonConverter[] converters ) => 18 | JsonConvert.SerializeObject ( value, formatting, converters ); 19 | 20 | public static string ToJson ( this T value, 21 | params JsonConverter[] converters ) => 22 | JsonConvert.SerializeObject ( value, converters ); 23 | 24 | public static string ToJson ( this T value, 25 | Formatting formatting ) => 26 | JsonConvert.SerializeObject ( value, formatting ); 27 | 28 | public static string ToJson ( this T value, 29 | JsonSerializerSettings settings ) => 30 | JsonConvert.SerializeObject ( value, settings ); 31 | } 32 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/CryptoTickerBot.Data.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 7.3 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Always 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Converters/DecimalConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Newtonsoft.Json; 4 | using Newtonsoft.Json.Linq; 5 | 6 | namespace CryptoTickerBot.Data.Converters 7 | { 8 | public class DecimalConverter : JsonConverter 9 | { 10 | public override bool CanConvert ( Type objectType ) => 11 | objectType == typeof ( decimal ) || objectType == typeof ( decimal? ); 12 | 13 | public override object ReadJson ( JsonReader reader, 14 | Type objectType, 15 | object existingValue, 16 | JsonSerializer serializer ) 17 | { 18 | var token = JToken.Load ( reader ); 19 | 20 | if ( token.Type == JTokenType.Float || token.Type == JTokenType.Integer ) 21 | return token.ToObject ( ); 22 | 23 | if ( token.Type == JTokenType.String ) 24 | return decimal.Parse ( token.ToString ( ), NumberStyles.Number | NumberStyles.AllowExponent ); 25 | 26 | if ( token.Type == JTokenType.Null && objectType == typeof ( decimal? ) ) 27 | return null; 28 | 29 | throw new JsonSerializationException ( $"Unexpected token type: {token.Type}" ); 30 | } 31 | 32 | public override void WriteJson ( JsonWriter writer, 33 | object value, 34 | JsonSerializer serializer ) 35 | { 36 | throw new NotImplementedException ( ); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /CryptoTickerBot.UnitTests/CryptoTickerBot.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.2 5 | 6 | false 7 | 8 | 7.3 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/Persistent/PersistentList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CryptoTickerBot.Collections.Persistent.Base; 4 | 5 | namespace CryptoTickerBot.Collections.Persistent 6 | { 7 | public sealed class PersistentList : PersistentCollection>, IList 8 | { 9 | public T this [ int index ] 10 | { 11 | get => Collection[index]; 12 | set 13 | { 14 | Collection[index] = value; 15 | Save ( ); 16 | } 17 | } 18 | 19 | private PersistentList ( string fileName, 20 | TimeSpan flushInterval ) 21 | : base ( fileName, DefaultSerializerSettings, flushInterval ) 22 | { 23 | } 24 | 25 | public int IndexOf ( T item ) => Collection.IndexOf ( item ); 26 | 27 | public void Insert ( int index, 28 | T item ) 29 | { 30 | Collection.Insert ( index, item ); 31 | Save ( ); 32 | } 33 | 34 | public void RemoveAt ( int index ) 35 | { 36 | Collection.RemoveAt ( index ); 37 | Save ( ); 38 | } 39 | 40 | public static PersistentList Build ( string fileName ) => 41 | Build ( fileName, DefaultFlushInterval ); 42 | 43 | public static PersistentList Build ( string fileName, 44 | TimeSpan flushInterval ) 45 | { 46 | var collection = GetOpenCollection> ( fileName ); 47 | 48 | return collection ?? new PersistentList ( fileName, flushInterval ); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Converters/UriConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace CryptoTickerBot.Data.Converters 5 | { 6 | public class UriConverter : JsonConverter 7 | { 8 | public override bool CanConvert ( Type objectType ) => 9 | objectType == typeof ( Uri ); 10 | 11 | public override object ReadJson ( JsonReader reader, 12 | Type objectType, 13 | object existingValue, 14 | JsonSerializer serializer ) 15 | { 16 | if ( reader.TokenType == JsonToken.String ) 17 | return new Uri ( (string) reader.Value ); 18 | 19 | if ( reader.TokenType == JsonToken.Null ) 20 | return null; 21 | 22 | throw new InvalidOperationException ( 23 | "Unhandled case for UriConverter. Check to see if this converter has been applied to the wrong serialization type." ); 24 | } 25 | 26 | public override void WriteJson ( JsonWriter writer, 27 | object value, 28 | JsonSerializer serializer ) 29 | { 30 | if ( null == value ) 31 | { 32 | writer.WriteNull ( ); 33 | return; 34 | } 35 | 36 | if ( value is Uri uri ) 37 | { 38 | writer.WriteValue ( uri.OriginalString ); 39 | return; 40 | } 41 | 42 | throw new InvalidOperationException ( 43 | "Unhandled case for UriConverter. Check to see if this converter has been applied to the wrong serialization type." ); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Interfaces/IBot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CryptoTickerBot.Data.Domain; 6 | 7 | namespace CryptoTickerBot.Core.Interfaces 8 | { 9 | public interface IBot : IDisposable 10 | { 11 | ICryptoExchange this [ CryptoExchangeId index ] { get; } 12 | 13 | CancellationTokenSource Cts { get; } 14 | ImmutableDictionary Exchanges { get; } 15 | bool IsInitialized { get; } 16 | bool IsRunning { get; } 17 | ImmutableHashSet Services { get; } 18 | DateTime StartTime { get; } 19 | 20 | event OnUpdateDelegate Changed; 21 | event OnUpdateDelegate Next; 22 | event TerminateDelegate Terminate; 23 | event StartDelegate Start; 24 | 25 | Task StartAsync ( CancellationTokenSource cts = null ); 26 | 27 | Task StartAsync ( CancellationTokenSource cts = null, 28 | params CryptoExchangeId[] exchangeIds ); 29 | 30 | Task StopAsync ( ); 31 | void RestartExchangeMonitors ( ); 32 | 33 | bool ContainsService ( IBotService service ); 34 | Task AttachAsync ( IBotService service ); 35 | Task DetachAsync ( IBotService service ); 36 | Task DetachAllAsync ( ) where T : IBotService; 37 | 38 | bool TryGetExchange ( CryptoExchangeId exchangeId, 39 | out ICryptoExchange exchange ); 40 | 41 | bool TryGetExchange ( string exchangeId, 42 | out ICryptoExchange exchange ); 43 | } 44 | 45 | public delegate void TerminateDelegate ( Bot bot ); 46 | 47 | public delegate Task StartDelegate ( Bot bot ); 48 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Exchanges/CoinDeltaExchange.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using CryptoTickerBot.Core.Abstractions; 5 | using CryptoTickerBot.Data.Domain; 6 | using Flurl.Http; 7 | using Newtonsoft.Json; 8 | 9 | namespace CryptoTickerBot.Core.Exchanges 10 | { 11 | public class CoinDeltaExchange : CryptoExchangeBase 12 | { 13 | public CoinDeltaExchange ( ) : base ( CryptoExchangeId.CoinDelta ) 14 | { 15 | } 16 | 17 | protected override async Task GetExchangeDataAsync ( CancellationToken ct ) 18 | { 19 | while ( !ct.IsCancellationRequested ) 20 | { 21 | var data = await TickerUrl.GetJsonAsync> ( ct ).ConfigureAwait ( false ); 22 | 23 | foreach ( var datum in data ) 24 | { 25 | var market = datum.MarketName.Replace ( "-", "" ); 26 | Update ( datum, market.ToUpper ( ) ); 27 | } 28 | 29 | await Task.Delay ( PollingRate, ct ).ConfigureAwait ( false ); 30 | } 31 | } 32 | 33 | protected override void DeserializeData ( CoinDeltaCoin data, 34 | string id ) 35 | { 36 | ExchangeData[id].LowestAsk = data.Ask; 37 | ExchangeData[id].HighestBid = data.Bid; 38 | ExchangeData[id].Rate = data.Last; 39 | } 40 | 41 | public class CoinDeltaCoin 42 | { 43 | [JsonProperty ( "Ask" )] 44 | public decimal Ask { get; set; } 45 | 46 | [JsonProperty ( "Bid" )] 47 | public decimal Bid { get; set; } 48 | 49 | [JsonProperty ( "MarketName" )] 50 | public string MarketName { get; set; } 51 | 52 | [JsonProperty ( "Last" )] 53 | public decimal Last { get; set; } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace CryptoTickerBot.Data.Extensions 6 | { 7 | public static class StringExtensions 8 | { 9 | public static int CaseInsensitiveHashCode ( this string str ) => 10 | StringComparer.OrdinalIgnoreCase.GetHashCode ( str ); 11 | 12 | public static T ToObject ( this string json ) => 13 | JsonConvert.DeserializeObject ( json ); 14 | 15 | public static T ToObject ( this string json, 16 | params JsonConverter[] converters ) => 17 | JsonConvert.DeserializeObject ( json, converters ); 18 | 19 | public static T ToObject ( this string json, 20 | JsonSerializerSettings settings ) => 21 | JsonConvert.DeserializeObject ( json, settings ); 22 | 23 | public static IEnumerable SplitOnLength ( this string input, 24 | int length ) 25 | { 26 | var index = 0; 27 | while ( index < input.Length ) 28 | { 29 | if ( index + length < input.Length ) 30 | yield return input.Substring ( index, length ); 31 | else 32 | yield return input.Substring ( index ); 33 | 34 | index += length; 35 | } 36 | } 37 | 38 | public static string ReplaceLastOccurrence ( this string source, 39 | string find, 40 | string replace, 41 | StringComparison comparison = StringComparison.OrdinalIgnoreCase ) 42 | { 43 | var place = source.LastIndexOf ( find, comparison ); 44 | 45 | return place == -1 ? source : source.Remove ( place, find.Length ).Insert ( place, replace ); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Abstractions/CycleBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Linq; 5 | using CryptoTickerBot.Arbitrage.Interfaces; 6 | using MoreLinq; 7 | 8 | namespace CryptoTickerBot.Arbitrage.Abstractions 9 | { 10 | public abstract class CycleBase : ICycle where TNode : INode 11 | { 12 | public ImmutableList Path { get; } 13 | public ImmutableList Edges { get; } 14 | public int Length => Path.Count - 1; 15 | public double Weight { get; protected set; } = double.PositiveInfinity; 16 | 17 | protected CycleBase ( IEnumerable path ) 18 | { 19 | Path = ImmutableList.Empty.AddRange ( path ); 20 | Edges = ImmutableList.Empty.AddRange ( Path.Window ( 2 ).Select ( x => x[0][x[1].Symbol] ) ); 21 | 22 | if ( Edges.Any ( x => x is null ) ) 23 | throw new ArgumentOutOfRangeException ( ); 24 | } 25 | 26 | public virtual double UpdateWeight ( ) => 27 | Weight = Edges.Sum ( x => x.Weight ); 28 | 29 | public override string ToString ( ) => 30 | string.Join ( " -> ", Path.Select ( x => x.Symbol ) ); 31 | 32 | #region Equality Comparers 33 | 34 | bool IEquatable>.Equals ( ICycle other ) => 35 | other != null && other.Path.IsCyclicEquivalent ( Path ); 36 | 37 | public override bool Equals ( object obj ) 38 | { 39 | if ( obj is null ) return false; 40 | if ( ReferenceEquals ( this, obj ) ) return true; 41 | 42 | return obj is ICycle cycle && Equals ( cycle ); 43 | } 44 | 45 | public override int GetHashCode ( ) 46 | { 47 | if ( Path is null ) 48 | return 0; 49 | 50 | return Path 51 | .Skip ( 1 ) 52 | .Aggregate ( 0, ( current, 53 | node ) => current ^ node.GetHashCode ( ) ); 54 | } 55 | 56 | #endregion 57 | } 58 | } -------------------------------------------------------------------------------- /CryptoTickerBot.GoogleSheets/SheetsConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using CryptoTickerBot.Data.Configs; 5 | 6 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 7 | 8 | namespace CryptoTickerBot.GoogleSheets 9 | { 10 | public class SheetsConfig : IConfig 11 | { 12 | public string ConfigFileName { get; } = "Sheets"; 13 | public string ConfigFolderName { get; } = "Configs"; 14 | 15 | public string SpreadSheetId { get; set; } 16 | public string SheetName { get; set; } 17 | public int SheetId { get; set; } 18 | public string ApplicationName { get; set; } 19 | public TimeSpan UpdateFrequency { get; set; } = TimeSpan.FromSeconds ( 6 ); 20 | public TimeSpan CooldownPeriod { get; set; } = TimeSpan.FromSeconds ( 60 ); 21 | 22 | public int StartingRow { get; set; } = 5; 23 | public char StartingColumn { get; set; } = 'A'; 24 | public int ExchangeRowGap { get; set; } = 3; 25 | 26 | public SheetsConfig RestoreDefaults ( ) => 27 | new SheetsConfig 28 | { 29 | SpreadSheetId = SpreadSheetId, 30 | SheetName = SheetName, 31 | SheetId = SheetId, 32 | ApplicationName = ApplicationName 33 | }; 34 | 35 | public bool TryValidate ( out IList exceptions ) 36 | { 37 | exceptions = new List ( ); 38 | 39 | if ( string.IsNullOrEmpty ( SpreadSheetId ) ) 40 | exceptions.Add ( new ArgumentException ( "SpreadSheet ID missing", nameof ( SpreadSheetId ) ) ); 41 | if ( string.IsNullOrEmpty ( SheetName ) ) 42 | exceptions.Add ( new ArgumentException ( "SheetName missing", nameof ( SheetName ) ) ); 43 | if ( string.IsNullOrEmpty ( ApplicationName ) ) 44 | exceptions.Add ( new ArgumentException ( "Application Name missing", nameof ( ApplicationName ) ) ); 45 | 46 | return !exceptions.Any ( ); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/Pages/MainPage.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using CryptoTickerBot.Telegram.Extensions; 4 | using CryptoTickerBot.Telegram.Interfaces; 5 | using CryptoTickerBot.Telegram.Menus.Abstractions; 6 | using MoreLinq; 7 | using Telegram.Bot.Types; 8 | 9 | #pragma warning disable 1998 10 | 11 | namespace CryptoTickerBot.Telegram.Menus.Pages 12 | { 13 | internal class MainPage : PageBase 14 | { 15 | public MainPage ( IMenu menu ) : 16 | base ( "Main Menu", menu ) 17 | { 18 | Labels = new[] {"status", "exchange info", "manage subscriptions"} 19 | .Batch ( 2 ) 20 | .ToList ( ); 21 | AddWideLabel ( "exit" ); 22 | 23 | BuildKeyboard ( ); 24 | AddHandlers ( ); 25 | } 26 | 27 | private void AddHandlers ( ) 28 | { 29 | AddHandler ( "status", StatusHandlerAsync ); 30 | AddHandler ( "exchange info", ExchangeInfoHandlerAsync ); 31 | AddHandler ( "manage subscriptions", ManageSubscriptionsHandlerAsync ); 32 | AddHandler ( "exit", ExitHandler, "Cya!" ); 33 | } 34 | 35 | private async Task StatusHandlerAsync ( CallbackQuery query ) 36 | { 37 | await Menu.SendTextBlockAsync ( TelegramBot.GetStatusString ( ) ).ConfigureAwait ( false ); 38 | await RedrawAsync ( ).ConfigureAwait ( false ); 39 | } 40 | 41 | private async Task ExchangeInfoHandlerAsync ( CallbackQuery query ) 42 | { 43 | var id = await RunExchangeSelectionPageAsync ( ).ConfigureAwait ( false ); 44 | 45 | if ( id ) 46 | { 47 | Ctb.TryGetExchange ( id, out var exchange ); 48 | 49 | await Menu.SendTextBlockAsync ( exchange.GetSummary ( ) ) 50 | .ConfigureAwait ( false ); 51 | await RedrawAsync ( ).ConfigureAwait ( false ); 52 | } 53 | } 54 | 55 | private async Task ManageSubscriptionsHandlerAsync ( CallbackQuery query ) 56 | { 57 | await Menu.SwitchPageAsync ( new ManageSubscriptionsPage ( Menu, this ) ).ConfigureAwait ( false ); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Helpers/PriceChange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CryptoTickerBot.Data.Domain; 3 | 4 | namespace CryptoTickerBot.Data.Helpers 5 | { 6 | public struct PriceChange : IComparable, IComparable 7 | { 8 | public decimal Value { get; private set; } 9 | public decimal Percentage { get; private set; } 10 | public TimeSpan TimeDiff { get; private set; } 11 | public DateTime AbsoluteTime { get; private set; } 12 | 13 | public static PriceChange Difference ( CryptoCoin newCoin, 14 | CryptoCoin oldCoin ) 15 | => new PriceChange 16 | { 17 | Value = newCoin.Rate - oldCoin.Rate, 18 | Percentage = ( newCoin.Rate - oldCoin.Rate ) / 19 | ( newCoin.Rate + oldCoin.Rate ) * 20 | 2m, 21 | TimeDiff = newCoin.Time - oldCoin.Time, 22 | AbsoluteTime = newCoin.Time 23 | }; 24 | 25 | public override string ToString ( ) => $"{Value:N} {Percentage:P}"; 26 | 27 | public int CompareTo ( PriceChange other ) => Value.CompareTo ( other.Value ); 28 | 29 | public int CompareTo ( object obj ) 30 | { 31 | if ( obj is null ) return 1; 32 | return obj is PriceChange other 33 | ? CompareTo ( other ) 34 | : throw new ArgumentException ( $"Object must be of type {nameof ( PriceChange )}" ); 35 | } 36 | 37 | public static bool operator < ( PriceChange left, 38 | PriceChange right ) => 39 | left.CompareTo ( right ) < 0; 40 | 41 | public static bool operator > ( PriceChange left, 42 | PriceChange right ) => 43 | left.CompareTo ( right ) > 0; 44 | 45 | public static bool operator <= ( PriceChange left, 46 | PriceChange right ) => 47 | left.CompareTo ( right ) <= 0; 48 | 49 | public static bool operator >= ( PriceChange left, 50 | PriceChange right ) => 51 | left.CompareTo ( right ) >= 0; 52 | } 53 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/Persistent/PersistentDictionary.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CryptoTickerBot.Collections.Persistent.Base; 4 | 5 | namespace CryptoTickerBot.Collections.Persistent 6 | { 7 | public sealed class PersistentDictionary : 8 | PersistentCollection, Dictionary>, 9 | IDictionary 10 | { 11 | public TValue this [ TKey key ] 12 | { 13 | get => Collection[key]; 14 | set 15 | { 16 | Collection[key] = value; 17 | Save ( ); 18 | } 19 | } 20 | 21 | public ICollection Keys => Collection.Keys; 22 | 23 | public ICollection Values => Collection.Values; 24 | 25 | private PersistentDictionary ( string fileName, 26 | TimeSpan flushInterval ) 27 | : base ( fileName, DefaultSerializerSettings, flushInterval ) 28 | { 29 | } 30 | 31 | public void Add ( TKey key, 32 | TValue value ) 33 | { 34 | Collection.Add ( key, value ); 35 | Save ( ); 36 | } 37 | 38 | public bool ContainsKey ( TKey key ) => Collection.ContainsKey ( key ); 39 | 40 | public bool Remove ( TKey key ) 41 | { 42 | var result = Collection.Remove ( key ); 43 | Save ( ); 44 | 45 | return result; 46 | } 47 | 48 | public bool TryGetValue ( TKey key, 49 | out TValue value ) => 50 | Collection.TryGetValue ( key, out value ); 51 | 52 | public static PersistentDictionary Build ( string fileName ) => 53 | Build ( fileName, DefaultFlushInterval ); 54 | 55 | public static PersistentDictionary Build ( string fileName, 56 | TimeSpan flushInterval ) 57 | { 58 | var collection = GetOpenCollection> ( fileName ); 59 | 60 | return collection ?? new PersistentDictionary ( fileName, flushInterval ); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/TelegramMenuManager.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using CryptoTickerBot.Telegram.Interfaces; 5 | using Telegram.Bot.Types; 6 | 7 | namespace CryptoTickerBot.Telegram.Menus 8 | { 9 | internal class TelegramMenuManager 10 | { 11 | protected ConcurrentDictionary> Menus { get; } 12 | 13 | public IMenu this [ User user, 14 | long chatId ] 15 | { 16 | get 17 | { 18 | if ( Menus.TryGetValue ( user, out var userMenus ) ) 19 | if ( userMenus.TryGetValue ( chatId, out var menu ) ) 20 | return menu; 21 | 22 | return null; 23 | } 24 | } 25 | 26 | public IMenu this [ User user, 27 | Chat chat ] => 28 | this[user, chat.Id]; 29 | 30 | public IMenu this [ CallbackQuery query ] => 31 | this[query.From, query.Message.Chat.Id]; 32 | 33 | public IList this [ long chatId ] => 34 | Menus.Values.Where ( x => x.ContainsKey ( chatId ) ).SelectMany ( x => x.Values ).ToList ( ); 35 | 36 | public IList this [ Chat chat ] => this[chat.Id]; 37 | 38 | public TelegramMenuManager ( ) 39 | { 40 | Menus = new ConcurrentDictionary> ( ); 41 | } 42 | 43 | public bool TryGetMenu ( User user, 44 | long chatId, 45 | out IMenu menu ) 46 | { 47 | menu = null; 48 | return Menus.TryGetValue ( user, out var menus ) && menus.TryGetValue ( chatId, out menu ); 49 | } 50 | 51 | public bool Remove ( User user, 52 | long chatId ) => 53 | Menus.TryGetValue ( user, out var menus ) && menus.TryRemove ( chatId, out _ ); 54 | 55 | public void AddOrUpdateMenu ( IMenu menu ) 56 | { 57 | if ( Menus.TryGetValue ( menu.User, out var userMenus ) ) 58 | userMenus[menu.Chat.Id] = menu; 59 | else 60 | Menus[menu.User] = new ConcurrentDictionary 61 | { 62 | [menu.Chat.Id] = menu 63 | }; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Abstractions/BotServiceBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CryptoTickerBot.Core.Interfaces; 4 | using CryptoTickerBot.Data.Domain; 5 | 6 | namespace CryptoTickerBot.Core.Abstractions 7 | { 8 | public abstract class BotServiceBase : IBotService 9 | { 10 | public Guid Guid { get; } = Guid.NewGuid ( ); 11 | public IBot Bot { get; protected set; } 12 | public bool IsAttached { get; protected set; } 13 | 14 | public virtual async Task AttachToAsync ( IBot bot ) 15 | { 16 | if ( !bot.ContainsService ( this ) ) 17 | { 18 | await bot.AttachAsync ( this ).ConfigureAwait ( false ); 19 | return; 20 | } 21 | 22 | Bot = bot; 23 | IsAttached = true; 24 | 25 | await StartAsync ( ).ConfigureAwait ( false ); 26 | } 27 | 28 | public virtual async Task DetachAsync ( ) 29 | { 30 | if ( Bot.ContainsService ( this ) ) 31 | { 32 | await Bot.DetachAsync ( this ).ConfigureAwait ( false ); 33 | return; 34 | } 35 | 36 | IsAttached = false; 37 | 38 | await StopAsync ( ).ConfigureAwait ( false ); 39 | } 40 | 41 | public virtual Task OnNextAsync ( ICryptoExchange exchange, 42 | CryptoCoin coin ) => 43 | Task.CompletedTask; 44 | 45 | public virtual Task OnChangedAsync ( ICryptoExchange exchange, 46 | CryptoCoin coin ) => 47 | Task.CompletedTask; 48 | 49 | public virtual void Dispose ( ) 50 | { 51 | } 52 | 53 | public bool Equals ( IBotService other ) => 54 | Guid.Equals ( other?.Guid ); 55 | 56 | public virtual Task StartAsync ( ) => 57 | Task.CompletedTask; 58 | 59 | public virtual Task StopAsync ( ) => 60 | Task.CompletedTask; 61 | 62 | public override bool Equals ( object obj ) 63 | { 64 | if ( obj is null ) 65 | return false; 66 | if ( ReferenceEquals ( this, obj ) ) 67 | return true; 68 | if ( obj is IBotService service ) 69 | return Equals ( service ); 70 | return false; 71 | } 72 | 73 | public override int GetHashCode ( ) => Guid.GetHashCode ( ); 74 | } 75 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Interfaces/ICryptoExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Diagnostics.Contracts; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using CryptoTickerBot.Data.Domain; 8 | 9 | // ReSharper disable UnusedMemberInSuper.Global 10 | 11 | namespace CryptoTickerBot.Core.Interfaces 12 | { 13 | public delegate Task OnUpdateDelegate ( ICryptoExchange exchange, 14 | CryptoCoin coin ); 15 | 16 | public interface ICryptoExchange : IObservable, IDisposable 17 | { 18 | CryptoExchangeId Id { get; } 19 | string Name { get; } 20 | string Url { get; } 21 | string TickerUrl { get; } 22 | Dictionary SymbolMappings { get; } 23 | decimal BuyFees { get; } 24 | decimal SellFees { get; } 25 | TimeSpan PollingRate { get; } 26 | TimeSpan CooldownPeriod { get; } 27 | bool IsStarted { get; } 28 | DateTime StartTime { get; } 29 | TimeSpan UpTime { get; } 30 | DateTime LastUpdate { get; } 31 | TimeSpan LastUpdateDuration { get; } 32 | DateTime LastChange { get; } 33 | TimeSpan LastChangeDuration { get; } 34 | int Count { get; } 35 | ImmutableHashSet BaseSymbols { get; } 36 | Markets Markets { get; } 37 | IDictionary ExchangeData { get; } 38 | IDictionary DepositFees { get; } 39 | IDictionary WithdrawalFees { get; } 40 | ImmutableHashSet> Observers { get; } 41 | 42 | [Pure] 43 | CryptoCoin this [ string symbol ] { get; set; } 44 | 45 | [Pure] 46 | CryptoCoin this [ string baseSymbol, 47 | string symbol ] { get; } 48 | 49 | event OnUpdateDelegate Changed; 50 | event OnUpdateDelegate Next; 51 | 52 | Task StartReceivingAsync ( CancellationToken? ct = null ); 53 | Task StopReceivingAsync ( ); 54 | 55 | void Unsubscribe ( IObserver subscription ); 56 | 57 | [Pure] 58 | CryptoCoin GetWithFees ( string symbol ); 59 | 60 | [Pure] 61 | string ToTable ( params string[] symbols ); 62 | } 63 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using CryptoTickerBot.Arbitrage.Common; 4 | using CryptoTickerBot.Arbitrage.Interfaces; 5 | using MoreLinq; 6 | 7 | namespace CryptoTickerBot.Arbitrage 8 | { 9 | public static class Extensions 10 | { 11 | public static bool IsCyclicEquivalent ( this IReadOnlyList cycle1, 12 | IReadOnlyList cycle2 ) 13 | { 14 | return cycle1 15 | .Skip ( 1 ) 16 | .Concat ( cycle1.Skip ( 1 ) ) 17 | .Window ( cycle2.Count - 1 ) 18 | .Any ( x => x.SequenceEqual ( cycle2.Skip ( 1 ) ) ); 19 | } 20 | 21 | public static bool IsCyclicEquivalent ( this ICycle cycle1, 22 | ICycle cycle2 ) where TNode : INode => 23 | IsCyclicEquivalent ( cycle1.Path, cycle2.Path ); 24 | 25 | public static IEnumerable> GetTriangularCycles ( this TNode from, 26 | TNode to ) 27 | where TNode : INode => 28 | GetTriangularCycles ( ( from, to ) ); 29 | 30 | public static IEnumerable> GetTriangularCycles ( this (TNode, TNode) pair ) 31 | where TNode : INode 32 | { 33 | var (to, from) = pair; 34 | 35 | if ( !to.HasEdge ( from.Symbol ) ) 36 | yield break; 37 | 38 | var nodes = from.Edges 39 | .Where ( x => x.To.EdgeTable.ContainsKey ( to.Symbol ) ) 40 | .Select ( x => x.To ) 41 | .OfType ( ); 42 | 43 | foreach ( var node in nodes ) 44 | yield return new Cycle ( from, node, to, from ); 45 | } 46 | 47 | 48 | public static List> GetAllTriangularCycles ( this IGraph graph ) 49 | where TNode : INode 50 | { 51 | var cycles = new List> ( ); 52 | 53 | var pairs = graph.Nodes.Values 54 | .Subsets ( 2 ) 55 | .SelectMany ( x => new[] {( x[0], x[1] ), ( x[1], x[0] )} ) 56 | .Where ( x => x.Item2.HasEdge ( x.Item1.Symbol ) ) 57 | .ToList ( ); 58 | 59 | foreach ( var pair in pairs ) 60 | cycles.AddRange ( GetTriangularCycles ( pair ) ); 61 | 62 | return cycles.Distinct ( ).ToList ( ); 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Abstractions/GraphBase.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using CryptoTickerBot.Arbitrage.Interfaces; 3 | 4 | namespace CryptoTickerBot.Arbitrage.Abstractions 5 | { 6 | public abstract class GraphBase : IGraph where TNode : class, INode 7 | { 8 | public ImmutableDictionary Nodes { get; protected set; } = 9 | ImmutableDictionary.Empty; 10 | 11 | public abstract EdgeBuilderDelegate DefaultEdgeBuilder { get; protected set; } 12 | 13 | public TNode this [ string symbol ] => 14 | Nodes.TryGetValue ( symbol, out var value ) ? value : null; 15 | 16 | public NodeBuilderDelegate NodeBuilder { get; } 17 | 18 | protected GraphBase ( NodeBuilderDelegate nodeBuilder ) 19 | { 20 | NodeBuilder = nodeBuilder; 21 | } 22 | 23 | public bool ContainsNode ( string symbol ) => 24 | Nodes.ContainsKey ( symbol ); 25 | 26 | public virtual TNode AddNode ( string symbol ) 27 | { 28 | if ( Nodes.TryGetValue ( symbol, out var node ) ) 29 | return node; 30 | 31 | node = NodeBuilder ( symbol ); 32 | Nodes = Nodes.SetItem ( symbol, node ); 33 | 34 | return node; 35 | } 36 | 37 | public virtual IEdge UpsertEdge ( string from, 38 | string to, 39 | decimal cost ) => 40 | UpsertEdge ( from, to, cost, DefaultEdgeBuilder ); 41 | 42 | public virtual TEdge UpsertEdge ( string from, 43 | string to, 44 | decimal cost, 45 | EdgeBuilderDelegate edgeBuilder ) 46 | where TEdge : class, IEdge 47 | { 48 | var nodeFrom = AddNode ( from ); 49 | var nodeTo = AddNode ( to ); 50 | 51 | var edge = edgeBuilder ( nodeFrom, nodeTo, cost ); 52 | if ( nodeFrom.AddOrUpdateEdge ( edge ) ) 53 | OnEdgeInsert ( nodeFrom, nodeTo ); 54 | else 55 | OnEdgeUpdate ( nodeFrom, nodeTo ); 56 | 57 | return nodeFrom[to] as TEdge; 58 | } 59 | 60 | protected abstract void OnEdgeInsert ( TNode from, 61 | TNode to ); 62 | 63 | protected abstract void OnEdgeUpdate ( TNode from, 64 | TNode to ); 65 | } 66 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/Persistent/PersistentSet.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CryptoTickerBot.Collections.Persistent.Base; 4 | 5 | namespace CryptoTickerBot.Collections.Persistent 6 | { 7 | public sealed class PersistentSet : PersistentCollection>, ISet 8 | { 9 | private PersistentSet ( string fileName, 10 | TimeSpan flushInterval ) 11 | : base ( fileName, DefaultSerializerSettings, flushInterval ) 12 | { 13 | } 14 | 15 | bool ISet.Add ( T item ) 16 | { 17 | var result = Collection.Add ( item ); 18 | Save ( ); 19 | 20 | return result; 21 | } 22 | 23 | public void ExceptWith ( IEnumerable other ) 24 | { 25 | Collection.ExceptWith ( other ); 26 | } 27 | 28 | public void IntersectWith ( IEnumerable other ) 29 | { 30 | Collection.IntersectWith ( other ); 31 | } 32 | 33 | public bool IsProperSubsetOf ( IEnumerable other ) => Collection.IsProperSubsetOf ( other ); 34 | 35 | public bool IsProperSupersetOf ( IEnumerable other ) => Collection.IsProperSupersetOf ( other ); 36 | 37 | public bool IsSubsetOf ( IEnumerable other ) => Collection.IsSubsetOf ( other ); 38 | 39 | public bool IsSupersetOf ( IEnumerable other ) => Collection.IsSupersetOf ( other ); 40 | 41 | public bool Overlaps ( IEnumerable other ) => Collection.Overlaps ( other ); 42 | 43 | public bool SetEquals ( IEnumerable other ) => Collection.SetEquals ( other ); 44 | 45 | public void SymmetricExceptWith ( IEnumerable other ) 46 | { 47 | Collection.SymmetricExceptWith ( other ); 48 | } 49 | 50 | public void UnionWith ( IEnumerable other ) 51 | { 52 | Collection.UnionWith ( other ); 53 | } 54 | 55 | public static PersistentSet Build ( string fileName ) => 56 | Build ( fileName, DefaultFlushInterval ); 57 | 58 | public static PersistentSet Build ( string fileName, 59 | TimeSpan flushInterval ) 60 | { 61 | var collection = GetOpenCollection> ( fileName ); 62 | 63 | return collection ?? new PersistentSet ( fileName, flushInterval ); 64 | } 65 | 66 | public bool AddOrUpdate ( T item ) 67 | { 68 | var result = Collection.Remove ( item ); 69 | Collection.Add ( item ); 70 | Save ( ); 71 | 72 | return !result; 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Common/CycleMap.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using CryptoTickerBot.Arbitrage.Interfaces; 5 | 6 | namespace CryptoTickerBot.Arbitrage.Common 7 | { 8 | using static ImmutableHashSet; 9 | 10 | public class CycleMap where TNode : INode 11 | { 12 | public ImmutableHashSet> this [ string from, 13 | string to ] 14 | { 15 | get 16 | { 17 | if ( !data.TryGetValue ( from, out var dict ) ) 18 | return null; 19 | return dict.TryGetValue ( to, out var set ) ? set : null; 20 | } 21 | } 22 | 23 | public ImmutableHashSet> this [ INode from, 24 | INode to ] => 25 | this[from.Symbol, to.Symbol]; 26 | 27 | private readonly ConcurrentDictionary>>> data 30 | = 31 | new ConcurrentDictionary>>> ( ); 34 | 35 | public bool AddCycle ( string from, 36 | string to, 37 | ICycle cycle ) 38 | { 39 | if ( !data.TryGetValue ( from, out var dict ) ) 40 | { 41 | data[from] = new ConcurrentDictionary>> 42 | {[to] = ImmutableHashSet>.Empty.Add ( cycle )}; 43 | return true; 44 | } 45 | 46 | if ( dict.TryGetValue ( to, out var storedCycles ) ) 47 | { 48 | var builder = storedCycles.ToBuilder ( ); 49 | var result = builder.Add ( cycle ); 50 | dict[to] = builder.ToImmutable ( ); 51 | return result; 52 | } 53 | 54 | dict[to] = ImmutableHashSet>.Empty.Add ( cycle ); 55 | return true; 56 | } 57 | 58 | public void AddCycles ( string from, 59 | string to, 60 | IEnumerable> cycles ) 61 | { 62 | if ( !data.TryGetValue ( from, out var dict ) ) 63 | { 64 | data[from] = new ConcurrentDictionary>> 65 | {[to] = cycles.ToImmutableHashSet ( )}; 66 | return; 67 | } 68 | 69 | if ( dict.TryGetValue ( to, out var storedCycles ) ) 70 | { 71 | var builder = storedCycles.ToBuilder ( ); 72 | builder.UnionWith ( cycles ); 73 | dict[to] = builder.ToImmutable ( ); 74 | return; 75 | } 76 | 77 | dict[to] = cycles.ToImmutableHashSet ( ); 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /CryptoTickerBot.GoogleSheets/Utility.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using CryptoTickerBot.Core.Interfaces; 7 | using CryptoTickerBot.Data.Domain; 8 | using Google.Apis.Auth.OAuth2; 9 | using Google.Apis.Sheets.v4; 10 | using Google.Apis.Util.Store; 11 | using NLog; 12 | 13 | namespace CryptoTickerBot.GoogleSheets 14 | { 15 | internal static class Utility 16 | { 17 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 18 | 19 | public static UserCredential GetCredentials ( string clientSecretPath, 20 | string credentialsPath ) 21 | { 22 | using ( var stream = 23 | new FileStream ( clientSecretPath, FileMode.Open, FileAccess.Read ) ) 24 | { 25 | var fullPath = Path.GetFullPath ( credentialsPath ); 26 | 27 | var credential = GoogleWebAuthorizationBroker.AuthorizeAsync ( 28 | GoogleClientSecrets.Load ( stream ).Secrets, 29 | new[] {SheetsService.Scope.Spreadsheets}, 30 | "user", 31 | CancellationToken.None, 32 | new FileDataStore ( fullPath, true ) ).Result; 33 | Logger.Info ( "Credential file saved to: " + fullPath ); 34 | 35 | return credential; 36 | } 37 | } 38 | 39 | public static IList> ToSheetsRows ( this ICryptoExchange exchange ) 40 | { 41 | return ToSheetsRows ( exchange, 42 | coin => new object[] 43 | { 44 | coin.LowestAsk, 45 | coin.HighestBid, 46 | coin.Rate, 47 | $"{coin.Time:G}", 48 | coin.Spread, 49 | coin.SpreadPercentage 50 | } ); 51 | } 52 | 53 | public static IList> ToSheetsRows ( this ICryptoExchange exchange, 54 | Func> selector ) 55 | { 56 | return exchange 57 | .Markets 58 | .BaseSymbols 59 | .OrderBy ( x => x ) 60 | .SelectMany ( x => exchange 61 | .Markets 62 | .Data[x] 63 | .OrderBy ( y => y.Key ) 64 | .Select ( y => (IList) selector ( y.Value ) 65 | .Prepend ( x ) 66 | .Prepend ( y.Key ) 67 | .ToList ( ) ) ) 68 | .Prepend ( new List ( ) ) 69 | .Prepend ( new List {$"{exchange.Name} ({exchange.Count})"} ) 70 | .ToList ( ); 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/IntraExchange/Graph.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using CryptoTickerBot.Arbitrage.Abstractions; 5 | using CryptoTickerBot.Arbitrage.Common; 6 | using CryptoTickerBot.Arbitrage.Interfaces; 7 | using CryptoTickerBot.Data.Domain; 8 | using MoreLinq; 9 | 10 | namespace CryptoTickerBot.Arbitrage.IntraExchange 11 | { 12 | public class Graph : GraphBase 13 | { 14 | public CryptoExchangeId ExchangeId { get; } 15 | 16 | public IList> AllCycles 17 | { 18 | get 19 | { 20 | lock ( cycleLock ) 21 | return allCycles.ToList ( ); 22 | } 23 | } 24 | 25 | public CycleMap CycleMap { get; } = new CycleMap ( ); 26 | 27 | public override EdgeBuilderDelegate DefaultEdgeBuilder { get; protected set; } = 28 | ( from, 29 | to, 30 | cost ) => 31 | new Edge ( from, to, cost ); 32 | 33 | private readonly object cycleLock = new object ( ); 34 | private readonly HashSet> allCycles = new HashSet> ( ); 35 | 36 | public Graph ( CryptoExchangeId exchangeId ) : 37 | base ( symbol => new Node ( symbol ) ) 38 | { 39 | ExchangeId = exchangeId; 40 | } 41 | 42 | public event NegativeCycleFoundDelegate NegativeCycleFound; 43 | 44 | private void UpdateCycleMap ( IEnumerable> cycles ) 45 | { 46 | foreach ( var cycle in cycles ) 47 | foreach ( var pair in cycle.Path.Window ( 2 ) ) 48 | CycleMap.AddCycle ( pair[0].Symbol, pair[1].Symbol, cycle ); 49 | } 50 | 51 | protected override void OnEdgeInsert ( Node from, 52 | Node to ) 53 | { 54 | lock ( cycleLock ) 55 | { 56 | var cycles = ( from, to ).GetTriangularCycles ( ).ToList ( ); 57 | allCycles.UnionWith ( cycles ); 58 | UpdateCycleMap ( cycles ); 59 | 60 | foreach ( var cycle in cycles ) 61 | if ( cycle.UpdateWeight ( ) < 0 ) 62 | NegativeCycleFound?.Invoke ( this, cycle ); 63 | } 64 | } 65 | 66 | protected override void OnEdgeUpdate ( Node from, 67 | Node to ) 68 | { 69 | lock ( cycleLock ) 70 | { 71 | var cycles = CycleMap[from, to]; 72 | if ( cycles is null ) 73 | return; 74 | foreach ( var cycle in cycles ) 75 | if ( cycle.UpdateWeight ( ) < 0 ) 76 | NegativeCycleFound?.Invoke ( this, cycle ); 77 | } 78 | } 79 | 80 | public override string ToString ( ) => $"{ExchangeId}"; 81 | } 82 | 83 | public delegate Task NegativeCycleFoundDelegate ( Graph graph, 84 | ICycle cycle ); 85 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Abstractions/CryptoExchangeSubscriptionBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CryptoTickerBot.Core.Interfaces; 3 | using CryptoTickerBot.Data.Domain; 4 | using Humanizer; 5 | using Humanizer.Localisation; 6 | 7 | namespace CryptoTickerBot.Core.Abstractions 8 | { 9 | public abstract class CryptoExchangeSubscriptionBase : 10 | ICryptoExchangeSubscription, 11 | IEquatable 12 | { 13 | public Guid Id { get; } = Guid.NewGuid ( ); 14 | 15 | public ICryptoExchange Exchange { get; protected set; } 16 | 17 | public DateTime CreationTime { get; } 18 | 19 | public TimeSpan ActiveSince => DateTime.UtcNow - CreationTime; 20 | 21 | protected CryptoExchangeSubscriptionBase ( ) 22 | { 23 | CreationTime = DateTime.UtcNow; 24 | } 25 | 26 | public virtual void Stop ( ) 27 | { 28 | Exchange.Unsubscribe ( this ); 29 | } 30 | 31 | public void Dispose ( ) => Stop ( ); 32 | 33 | public virtual void OnError ( Exception error ) 34 | { 35 | } 36 | 37 | public abstract void OnNext ( CryptoCoin coin ); 38 | 39 | public virtual void OnCompleted ( ) 40 | { 41 | } 42 | 43 | public override string ToString ( ) => 44 | $"{nameof ( Id )}: {Id}," + 45 | $" {nameof ( Exchange )}: {Exchange}," + 46 | $" {nameof ( ActiveSince )}: {ActiveSince.Humanize ( 4, minUnit: TimeUnit.Second )}"; 47 | 48 | protected virtual void Start ( ICryptoExchange exchange ) 49 | { 50 | if ( exchange is null ) 51 | return; 52 | 53 | Exchange = exchange; 54 | Exchange.Subscribe ( this ); 55 | } 56 | 57 | #region Equality Members 58 | 59 | public bool Equals ( ICryptoExchangeSubscription other ) 60 | { 61 | if ( other is null ) return false; 62 | if ( ReferenceEquals ( this, other ) ) return true; 63 | return Id.Equals ( other.Id ); 64 | } 65 | 66 | public bool Equals ( CryptoExchangeSubscriptionBase other ) 67 | { 68 | if ( other is null ) return false; 69 | if ( ReferenceEquals ( this, other ) ) return true; 70 | return Id.Equals ( other.Id ); 71 | } 72 | 73 | public override bool Equals ( object obj ) 74 | { 75 | if ( obj is null ) return false; 76 | if ( ReferenceEquals ( this, obj ) ) return true; 77 | return obj is CryptoExchangeSubscriptionBase other && Equals ( other ); 78 | } 79 | 80 | public override int GetHashCode ( ) => Id.GetHashCode ( ); 81 | 82 | public static bool operator == ( CryptoExchangeSubscriptionBase left, 83 | CryptoExchangeSubscriptionBase right ) => Equals ( left, right ); 84 | 85 | public static bool operator != ( CryptoExchangeSubscriptionBase left, 86 | CryptoExchangeSubscriptionBase right ) => !Equals ( left, right ); 87 | 88 | #endregion 89 | } 90 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Exchanges/CoinbaseExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CoinbasePro; 7 | using CoinbasePro.Shared.Types; 8 | using CoinbasePro.WebSocket.Models.Response; 9 | using CoinbasePro.WebSocket.Types; 10 | using CryptoTickerBot.Core.Abstractions; 11 | using CryptoTickerBot.Data.Domain; 12 | using EnumsNET; 13 | using NLog; 14 | using WebSocket4Net; 15 | 16 | namespace CryptoTickerBot.Core.Exchanges 17 | { 18 | public class CoinbaseExchange : CryptoExchangeBase 19 | { 20 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 21 | 22 | public CoinbaseProClient Client { get; private set; } 23 | 24 | public CoinbaseExchange ( ) : base ( CryptoExchangeId.Coinbase ) 25 | { 26 | } 27 | 28 | protected override async Task GetExchangeDataAsync ( CancellationToken ct ) 29 | { 30 | var closed = false; 31 | Client = new CoinbaseProClient ( ); 32 | 33 | Client.WebSocket.OnWebSocketError += ( sender, 34 | args ) => 35 | { 36 | }; 37 | Client.WebSocket.OnErrorReceived += ( sender, 38 | args ) => 39 | { 40 | Logger.Error ( args.LastOrder.Reason ); 41 | closed = true; 42 | }; 43 | 44 | StartWebSocket ( ); 45 | 46 | while ( Client.WebSocket.State != WebSocketState.Closed ) 47 | { 48 | if ( UpTime > LastUpdateDuration && 49 | LastUpdateDuration > TimeSpan.FromHours ( 1 ) || 50 | closed ) 51 | { 52 | Client.WebSocket.Stop ( ); 53 | break; 54 | } 55 | 56 | await Task.Delay ( PollingRate, ct ).ConfigureAwait ( false ); 57 | } 58 | } 59 | 60 | private void StartWebSocket ( ) 61 | { 62 | var products = Enums.GetValues ( ).Except ( new[] {ProductType.Unknown} ).ToList ( ); 63 | var channels = new List {ChannelType.Ticker}; 64 | 65 | Client.WebSocket.OnTickerReceived += ( sender, 66 | args ) => 67 | { 68 | var ticker = args.LastOrder; 69 | Update ( ticker, ticker.ProductId ); 70 | }; 71 | Client.WebSocket.Start ( products, channels ); 72 | } 73 | 74 | protected override void DeserializeData ( Ticker data, 75 | string id ) 76 | { 77 | ExchangeData[id].LowestAsk = data.BestAsk; 78 | ExchangeData[id].HighestBid = data.BestBid; 79 | ExchangeData[id].Rate = data.Price; 80 | } 81 | 82 | public override Task StopReceivingAsync ( ) 83 | { 84 | if ( Client.WebSocket.State != WebSocketState.Closed ) 85 | Client.WebSocket.Stop ( ); 86 | 87 | return base.StopReceivingAsync ( ); 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Exchanges/ZebpayExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CryptoTickerBot.Core.Abstractions; 6 | using CryptoTickerBot.Data.Domain; 7 | using Flurl.Http; 8 | using Newtonsoft.Json; 9 | 10 | namespace CryptoTickerBot.Core.Exchanges 11 | { 12 | public class ZebpayExchange : CryptoExchangeBase 13 | { 14 | public static readonly ImmutableList Symbols = 15 | ImmutableList.Empty.AddRange ( 16 | new[] 17 | { 18 | "BTC", "TUSD", "ETH", "BCH", "LTC", "XRP", "EOS", "OMG", 19 | "TRX", "GNT", "ZRX", "REP", "KNC", "BAT", "AE", "ZIL", 20 | "CMT", "NCASH", "BTG" 21 | } 22 | ); 23 | 24 | public ZebpayExchange ( ) : base ( CryptoExchangeId.Zebpay ) 25 | { 26 | } 27 | 28 | protected override async Task GetExchangeDataAsync ( CancellationToken ct ) 29 | { 30 | await FetchAllAsync ( TimeSpan.FromSeconds ( 2 ), ct ).ConfigureAwait ( false ); 31 | 32 | while ( !ct.IsCancellationRequested ) 33 | await FetchAllAsync ( PollingRate, ct ).ConfigureAwait ( false ); 34 | } 35 | 36 | private async Task FetchAllAsync ( TimeSpan frequency, 37 | CancellationToken ct ) 38 | { 39 | foreach ( var symbol in Symbols ) 40 | { 41 | var url = $"{TickerUrl}{symbol}/inr/"; 42 | var data = await url.GetJsonAsync ( ct ).ConfigureAwait ( false ); 43 | Update ( data, $"{symbol}INR" ); 44 | await Task.Delay ( frequency, ct ).ConfigureAwait ( false ); 45 | } 46 | } 47 | 48 | protected override void DeserializeData ( TickerDatum data, 49 | string id ) 50 | { 51 | ExchangeData[id].LowestAsk = data.Buy; 52 | ExchangeData[id].HighestBid = data.Sell; 53 | ExchangeData[id].Rate = data.Last; 54 | } 55 | 56 | public class TickerDatum 57 | { 58 | [JsonProperty ( "pricechange" )] 59 | public decimal PriceChange { get; set; } 60 | 61 | [JsonProperty ( "volume" )] 62 | public decimal Volume { get; set; } 63 | 64 | [JsonProperty ( "24hoursHigh" )] 65 | public decimal High { get; set; } 66 | 67 | [JsonProperty ( "24hoursLow" )] 68 | public decimal Low { get; set; } 69 | 70 | [JsonProperty ( "market" )] 71 | public decimal Last { get; set; } 72 | 73 | [JsonProperty ( "buy" )] 74 | public decimal Buy { get; set; } 75 | 76 | [JsonProperty ( "sell" )] 77 | public decimal Sell { get; set; } 78 | 79 | [JsonProperty ( "pair" )] 80 | public string Pair { get; set; } 81 | 82 | [JsonProperty ( "virtualCurrency" )] 83 | public string VirtualCurrency { get; set; } 84 | 85 | [JsonProperty ( "currency" )] 86 | public string Currency { get; set; } 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Domain/CryptoCoin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Diagnostics.Contracts; 4 | using CryptoTickerBot.Data.Extensions; 5 | using Newtonsoft.Json; 6 | 7 | namespace CryptoTickerBot.Data.Domain 8 | { 9 | public class CryptoCoin : IEquatable 10 | { 11 | public Guid Id { get; } = Guid.NewGuid ( ); 12 | 13 | public string Symbol { get; } 14 | public decimal HighestBid { get; set; } 15 | public decimal LowestAsk { get; set; } 16 | public decimal Rate { get; set; } 17 | public DateTime Time { get; set; } 18 | 19 | [JsonIgnore] 20 | public decimal SellPrice => HighestBid; 21 | 22 | [JsonIgnore] 23 | public decimal BuyPrice => LowestAsk; 24 | 25 | [JsonIgnore] 26 | public decimal Average => ( BuyPrice + SellPrice ) / 2; 27 | 28 | [JsonIgnore] 29 | public decimal Spread => LowestAsk - HighestBid; 30 | 31 | [JsonIgnore] 32 | public decimal SpreadPercentage => Average != 0 ? Spread / Average : 0; 33 | 34 | public CryptoCoin ( 35 | string symbol, 36 | decimal highestBid = 0m, 37 | decimal lowestAsk = 0m, 38 | decimal rate = 0m, 39 | DateTime? time = null 40 | ) 41 | { 42 | Symbol = symbol; 43 | HighestBid = highestBid; 44 | LowestAsk = lowestAsk; 45 | Rate = rate; 46 | Time = time ?? DateTime.UtcNow; 47 | } 48 | 49 | public virtual bool HasSameValues ( CryptoCoin coin ) => 50 | coin != null && Symbol == coin.Symbol && 51 | HighestBid == coin.HighestBid && LowestAsk == coin.LowestAsk; 52 | 53 | [DebuggerStepThrough] 54 | [Pure] 55 | public CryptoCoin Clone ( ) => 56 | new CryptoCoin ( Symbol, HighestBid, LowestAsk, Rate, Time ); 57 | 58 | [Pure] 59 | public override string ToString ( ) => 60 | $"{Symbol,-12}: Highest Bid = {HighestBid,-10:N} Lowest Ask = {LowestAsk,-10:N}"; 61 | 62 | #region Equality Members 63 | 64 | public bool Equals ( CryptoCoin other ) 65 | { 66 | if ( other is null ) return false; 67 | if ( ReferenceEquals ( this, other ) ) return true; 68 | return string.Equals ( Symbol, other.Symbol, StringComparison.OrdinalIgnoreCase ) && 69 | Time.Equals ( other.Time ); 70 | } 71 | 72 | public override bool Equals ( object obj ) 73 | { 74 | if ( obj is null ) return false; 75 | if ( ReferenceEquals ( this, obj ) ) return true; 76 | return obj.GetType ( ) == GetType ( ) && Equals ( (CryptoCoin) obj ); 77 | } 78 | 79 | public override int GetHashCode ( ) => 80 | Symbol?.CaseInsensitiveHashCode ( ) ?? 0; 81 | 82 | public static bool operator == ( CryptoCoin left, 83 | CryptoCoin right ) => Equals ( left, right ); 84 | 85 | public static bool operator != ( CryptoCoin left, 86 | CryptoCoin right ) => !Equals ( left, right ); 87 | 88 | #endregion 89 | } 90 | } -------------------------------------------------------------------------------- /CryptoTickerBot.UnitTests/ArbitrageTests/NodeTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using CryptoTickerBot.Arbitrage.Common; 4 | using NUnit.Framework; 5 | using NUnit.Framework.Internal; 6 | 7 | namespace CryptoTickerBot.UnitTests.ArbitrageTests 8 | { 9 | [TestFixture] 10 | public class NodeTests 11 | { 12 | [Test] 13 | public void NodeAddOrUpdateEdgeShouldNotOverwriteAnExistingEdgeReference ( ) 14 | { 15 | var nodeA = new Node ( "A" ); 16 | var nodeB = new Node ( "B" ); 17 | var edgeAb = new Edge ( nodeA, nodeB, 42m ); 18 | var edgeAbDup = new Edge ( nodeA, nodeB, 0m ); 19 | 20 | Assert.True ( nodeA.AddOrUpdateEdge ( edgeAb ) ); 21 | Assert.True ( nodeA.HasEdge ( "B" ) ); 22 | Assert.AreSame ( nodeA["B"], edgeAb ); 23 | Assert.AreEqual ( nodeA["B"].OriginalCost, 42m ); 24 | Assert.False ( nodeA.AddOrUpdateEdge ( edgeAbDup ) ); 25 | Assert.AreSame ( nodeA["B"], edgeAb ); 26 | Assert.AreEqual ( nodeA["B"].OriginalCost, edgeAbDup.OriginalCost ); 27 | Assert.AreEqual ( nodeA.Edges.Count ( ), 1 ); 28 | } 29 | 30 | [Test] 31 | public void NodeAddOrUpdateEdgeShouldRejectEdgesFromOtherNodes ( ) 32 | { 33 | var nodeA = new Node ( "A" ); 34 | var nodeB = new Node ( "B" ); 35 | var nodeC = new Node ( "C" ); 36 | var nodeD = new Node ( "D" ); 37 | var edgeAb = new Edge ( nodeA, nodeB, 42m ); 38 | var edgeCd = new Edge ( nodeC, nodeD, 0m ); 39 | 40 | Assert.True ( nodeA.AddOrUpdateEdge ( edgeAb ) ); 41 | Assert.False ( nodeB.AddOrUpdateEdge ( edgeAb ) ); 42 | Assert.False ( nodeA.AddOrUpdateEdge ( edgeCd ) ); 43 | Assert.False ( nodeC.AddOrUpdateEdge ( edgeAb ) ); 44 | Assert.True ( nodeC.AddOrUpdateEdge ( edgeCd ) ); 45 | Assert.AreEqual ( nodeA.Edges.Count ( ), 1 ); 46 | Assert.True ( nodeA.EdgeTable.Keys.Single ( ) == "B" ); 47 | } 48 | 49 | [Test] 50 | public void NodesShouldBeOrderedBySymbol ( ) 51 | { 52 | var randomizer = Randomizer.CreateRandomizer ( ); 53 | var symbols = Enumerable.Range ( 0, 10000 ).Select ( x => randomizer.GetString ( 20 ) ).ToList ( ); 54 | var nodes = symbols.Select ( x => new Node ( x ) ).ToList ( ); 55 | 56 | var result = symbols.OrderBy ( x => x, StringComparer.OrdinalIgnoreCase ) 57 | .SequenceEqual ( nodes.OrderBy ( x => x ).Select ( x => x.Symbol ) ); 58 | 59 | Assert.True ( result ); 60 | } 61 | 62 | [Test] 63 | public void NodesWithSameSymbolShouldBeEqual ( ) 64 | { 65 | var nodeA = new Node ( "A" ); 66 | var nodeB = new Node ( "B" ); 67 | var nodeDupA = new Node ( "A" ); 68 | 69 | Assert.AreEqual ( nodeA, nodeA ); 70 | Assert.AreNotEqual ( nodeA, nodeB ); 71 | Assert.AreEqual ( nodeA, nodeDupA ); 72 | Assert.AreNotEqual ( nodeA, nodeB ); 73 | Assert.AreNotEqual ( nodeB, nodeDupA ); 74 | Assert.AreEqual ( nodeA.GetHashCode ( ), nodeDupA.GetHashCode ( ) ); 75 | Assert.AreEqual ( nodeA, nodeDupA ); 76 | Assert.AreNotSame ( nodeA, nodeDupA ); 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/TelegramBotData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using CryptoTickerBot.Collections.Persistent; 6 | using CryptoTickerBot.Collections.Persistent.Base; 7 | using CryptoTickerBot.Data.Domain; 8 | using CryptoTickerBot.Telegram.Subscriptions; 9 | using NLog; 10 | using Telegram.Bot.Types; 11 | 12 | namespace CryptoTickerBot.Telegram 13 | { 14 | public class TelegramBotData 15 | { 16 | public const string FolderName = "Data"; 17 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 18 | 19 | public PersistentSet Users { get; } 20 | public PersistentDictionary UserRoles { get; } 21 | public PersistentSet PercentChangeSubscriptions { get; } 22 | 23 | public User this [ int id ] => 24 | Users.FirstOrDefault ( x => x.Id == id ); 25 | 26 | public List this [ UserRole role ] => 27 | Users 28 | .Where ( x => UserRoles.TryGetValue ( x.Id, out var r ) && r == role ) 29 | .ToList ( ); 30 | 31 | private readonly List collections; 32 | 33 | public TelegramBotData ( ) 34 | { 35 | Users = PersistentSet.Build ( 36 | Path.Combine ( FolderName, "TelegramBotUsers.json" ) ); 37 | UserRoles = PersistentDictionary.Build ( 38 | Path.Combine ( FolderName, "TelegramUserRoles.json" ) ); 39 | PercentChangeSubscriptions = 40 | PersistentSet.Build ( 41 | Path.Combine ( FolderName, "TelegramPercentChangeSubscriptions.json" ) ); 42 | 43 | collections = new List 44 | { 45 | Users, 46 | UserRoles, 47 | PercentChangeSubscriptions 48 | }; 49 | 50 | foreach ( var collection in collections ) 51 | collection.OnError += OnError; 52 | } 53 | 54 | public void Save ( ) 55 | { 56 | foreach ( var collection in collections ) 57 | collection.Save ( ); 58 | } 59 | 60 | public List GetPercentChangeSubscriptions ( 61 | Func predicate ) => 62 | PercentChangeSubscriptions 63 | .Where ( predicate ) 64 | .ToList ( ); 65 | 66 | public bool AddOrUpdate ( TelegramPercentChangeSubscription subscription ) => 67 | PercentChangeSubscriptions.AddOrUpdate ( subscription ); 68 | 69 | public bool AddOrUpdate ( User user, 70 | UserRole role ) 71 | { 72 | var result = Users.AddOrUpdate ( user ); 73 | UserRoles[user.Id] = role; 74 | 75 | return result; 76 | } 77 | 78 | private void OnError ( IPersistentCollection collection, 79 | Exception exception ) 80 | { 81 | Logger.Error ( exception, collection.FileName ); 82 | 83 | Error?.Invoke ( exception ); 84 | } 85 | 86 | public event ErrorDelegate Error; 87 | } 88 | 89 | public delegate void ErrorDelegate ( Exception exception ); 90 | } -------------------------------------------------------------------------------- /CryptoTickerBot.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True 3 | True 4 | True 5 | True 6 | True 7 | True 8 | True 9 | True 10 | True 11 | 12 | True 13 | True 14 | True 15 | True 16 | True 17 | True 18 | True 19 | True 20 | True 21 | True 22 | True 23 | True 24 | True 25 | True 26 | True 27 | True 28 | True 29 | True 30 | True -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Markets.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Collections.Immutable; 5 | using CryptoTickerBot.Arbitrage.IntraExchange; 6 | using CryptoTickerBot.Data.Domain; 7 | using CryptoTickerBot.Data.Extensions; 8 | 9 | namespace CryptoTickerBot.Core 10 | { 11 | public class Markets 12 | { 13 | public CoreConfig.CryptoExchangeApiInfo Exchange { get; } 14 | public ImmutableHashSet BaseSymbols { get; } 15 | public ImmutableDictionary> Data { get; } 16 | 17 | public IReadOnlyDictionary this [ string baseSymbol ] => 18 | (IReadOnlyDictionary) ( Data.TryGetValue ( baseSymbol, out var dict ) 19 | ? dict 20 | : null ); 21 | 22 | public Graph Graph { get; } 23 | 24 | public CryptoCoin this [ string baseSymbol, 25 | string symbol ] 26 | { 27 | get 28 | { 29 | if ( !Data.TryGetValue ( baseSymbol, out var dict ) ) 30 | return null; 31 | return dict.TryGetValue ( symbol, out var coin ) ? coin : null; 32 | } 33 | private set 34 | { 35 | if ( !Data.TryGetValue ( baseSymbol, out var dict ) ) 36 | return; 37 | dict[symbol] = value; 38 | } 39 | } 40 | 41 | public Markets ( CoreConfig.CryptoExchangeApiInfo exchange ) 42 | { 43 | Exchange = exchange; 44 | BaseSymbols = ImmutableHashSet.Empty; 45 | Data = ImmutableDictionary>.Empty; 46 | 47 | foreach ( var baseSymbol in exchange.BaseSymbols ) 48 | { 49 | BaseSymbols = BaseSymbols.Add ( baseSymbol ); 50 | Data = Data.Add ( baseSymbol, new ConcurrentDictionary ( ) ); 51 | } 52 | 53 | Graph = new Graph ( exchange.Id ); 54 | } 55 | 56 | private decimal GetAdjustedSellPrice ( CryptoCoin coin ) => 57 | coin.SellPrice * ( 1m - Exchange.SellFees / 100m ); 58 | 59 | private decimal GetAdjustedBuyPrice ( CryptoCoin coin ) => 60 | coin.BuyPrice * ( 1m + Exchange.BuyFees / 100m ); 61 | 62 | public bool AddOrUpdate ( CryptoCoin coin ) 63 | { 64 | if ( coin.Spread < 0 ) 65 | return false; 66 | 67 | foreach ( var baseSymbol in BaseSymbols ) 68 | { 69 | if ( !coin.Symbol.EndsWith ( baseSymbol, StringComparison.OrdinalIgnoreCase ) ) 70 | continue; 71 | 72 | var symbol = coin.Symbol.ReplaceLastOccurrence ( baseSymbol, "" ); 73 | this[baseSymbol, symbol] = coin; 74 | 75 | UpdateGraph ( coin, symbol, baseSymbol ); 76 | 77 | return true; 78 | } 79 | 80 | return false; 81 | } 82 | 83 | private void UpdateGraph ( CryptoCoin coin, 84 | string symbol, 85 | string baseSymbol ) 86 | { 87 | var price = GetAdjustedSellPrice ( coin ); 88 | if ( price != 0m ) 89 | Graph.UpsertEdge ( symbol, baseSymbol, 90 | price ); 91 | price = GetAdjustedBuyPrice ( coin ); 92 | if ( price != 0 ) 93 | Graph.UpsertEdge ( baseSymbol, symbol, 94 | 1m / price ); 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /CryptoTickerBot.UnitTests/PersistentCollectionsTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using CryptoTickerBot.Collections.Persistent; 4 | using NUnit.Framework; 5 | 6 | namespace CryptoTickerBot.UnitTests 7 | { 8 | [TestFixture] 9 | public class PersistentCollectionsTest 10 | { 11 | [SetUp] 12 | public void Setup ( ) 13 | { 14 | foreach ( var fileName in new[] {ListFileName, SetFileName, DictionaryFileName} ) 15 | if ( File.Exists ( fileName ) ) 16 | File.Delete ( fileName ); 17 | } 18 | 19 | private const string ListFileName = "PersistentList.json"; 20 | private const string SetFileName = "PersistentSet.json"; 21 | private const string DictionaryFileName = "PersistentDictionary.json"; 22 | 23 | private static PersistentList MakeList ( ) => 24 | PersistentList.Build ( ListFileName ); 25 | 26 | private static PersistentSet MakeSet ( ) => 27 | PersistentSet.Build ( SetFileName ); 28 | 29 | private static PersistentDictionary MakeDictionary ( ) => 30 | PersistentDictionary.Build ( DictionaryFileName ); 31 | 32 | [Test] 33 | public void PersistentCollectionsShouldBeUniquelyIdentifiedByFileName ( ) 34 | { 35 | using ( var first = MakeList ( ) ) 36 | using ( var second = MakeList ( ) ) 37 | { 38 | Assert.AreSame ( first, second ); 39 | Assert.Throws ( ( ) => MakeList ( ) ); 40 | Assert.Throws ( ( ) => PersistentSet.Build ( ListFileName ) ); 41 | Assert.DoesNotThrow ( ( ) => MakeSet ( )?.Dispose ( ) ); 42 | Assert.DoesNotThrow ( ( ) => MakeDictionary ( )?.Dispose ( ) ); 43 | } 44 | } 45 | 46 | [Test] 47 | public void PersistentListShouldCreateFileWithCorrectName ( ) 48 | { 49 | using ( var list = MakeList ( ) ) 50 | { 51 | list.ForceSave ( ); 52 | Assert.True ( File.Exists ( list.FileName ) ); 53 | } 54 | } 55 | 56 | [Test] 57 | public void PersistentListShouldPersistDataInMemory ( ) 58 | { 59 | using ( var list = MakeList ( ) ) 60 | { 61 | Assert.IsEmpty ( list ); 62 | list.AddWithoutSaving ( 1 ); 63 | Assert.That ( list.Count, Is.EqualTo ( 1 ) ); 64 | list.AddWithoutSaving ( 1 ); 65 | Assert.That ( list.Count, Is.EqualTo ( 2 ) ); 66 | } 67 | } 68 | 69 | [Test] 70 | public void PersistentListShouldPersistDataOnDisk ( ) 71 | { 72 | using ( var list = MakeList ( ) ) 73 | { 74 | Assert.IsEmpty ( list ); 75 | list.Add ( 1 ); 76 | Assert.That ( list.Count, Is.EqualTo ( 1 ) ); 77 | } 78 | 79 | using ( var list = MakeList ( ) ) 80 | { 81 | Assert.That ( list.Count, Is.EqualTo ( 1 ) ); 82 | list.Add ( 2 ); 83 | Assert.That ( list.Count, Is.EqualTo ( 2 ) ); 84 | Assert.That ( list[1], Is.EqualTo ( 2 ) ); 85 | } 86 | } 87 | 88 | [Test] 89 | public void PersistentSetShouldFollowSetLogic ( ) 90 | { 91 | using ( var set = MakeSet ( ) ) 92 | { 93 | Assert.IsEmpty ( set ); 94 | Assert.True ( set.AddOrUpdate ( 1 ) ); 95 | Assert.That ( set.Count, Is.EqualTo ( 1 ) ); 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at dagaddevil@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CryptoTickerBot.Arbitrage/Abstractions/NodeBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CryptoTickerBot.Arbitrage.Interfaces; 4 | 5 | namespace CryptoTickerBot.Arbitrage.Abstractions 6 | { 7 | public abstract class NodeBase : INode 8 | { 9 | public string Symbol { get; } 10 | 11 | protected virtual IDictionary EdgeTableImpl { get; } = 12 | new Dictionary ( ); 13 | 14 | public IReadOnlyDictionary EdgeTable => 15 | EdgeTableImpl as IReadOnlyDictionary; 16 | 17 | public IEnumerable Edges => EdgeTableImpl.Values; 18 | 19 | public IEdge this [ string symbol ] => 20 | EdgeTableImpl.TryGetValue ( symbol, out var value ) ? value : null; 21 | 22 | protected NodeBase ( string symbol ) 23 | { 24 | Symbol = symbol; 25 | } 26 | 27 | public bool AddOrUpdateEdge ( IEdge edge ) 28 | { 29 | if ( !Equals ( edge.From ) ) 30 | return false; 31 | 32 | if ( EdgeTableImpl.TryGetValue ( edge.To.Symbol, out var existing ) ) 33 | { 34 | existing.CopyFrom ( edge ); 35 | return false; 36 | } 37 | 38 | EdgeTableImpl[edge.To.Symbol] = edge; 39 | return true; 40 | } 41 | 42 | public bool HasEdge ( string symbol ) => 43 | EdgeTableImpl.ContainsKey ( symbol ); 44 | 45 | #region Auto Generated 46 | 47 | public bool Equals ( INode other ) 48 | { 49 | if ( other is null ) return false; 50 | if ( ReferenceEquals ( this, other ) ) return true; 51 | return string.Equals ( Symbol, other.Symbol ); 52 | } 53 | 54 | public int CompareTo ( INode other ) 55 | { 56 | if ( ReferenceEquals ( this, other ) ) return 0; 57 | if ( other is null ) return 1; 58 | return string.Compare ( Symbol, other.Symbol, StringComparison.OrdinalIgnoreCase ); 59 | } 60 | 61 | public int CompareTo ( NodeBase other ) => 62 | CompareTo ( other as INode ); 63 | 64 | public int CompareTo ( object obj ) 65 | { 66 | if ( obj is null ) return 1; 67 | if ( ReferenceEquals ( this, obj ) ) return 0; 68 | return obj is INode other 69 | ? CompareTo ( other ) 70 | : throw new ArgumentException ( $"Object must be of type {nameof ( NodeBase )}" ); 71 | } 72 | 73 | public static bool operator < ( NodeBase left, 74 | NodeBase right ) => 75 | Comparer.Default.Compare ( left, right ) < 0; 76 | 77 | public static bool operator > ( NodeBase left, 78 | NodeBase right ) => 79 | Comparer.Default.Compare ( left, right ) > 0; 80 | 81 | public static bool operator <= ( NodeBase left, 82 | NodeBase right ) => 83 | Comparer.Default.Compare ( left, right ) <= 0; 84 | 85 | public static bool operator >= ( NodeBase left, 86 | NodeBase right ) => 87 | Comparer.Default.Compare ( left, right ) >= 0; 88 | 89 | public override bool Equals ( object obj ) 90 | { 91 | if ( obj is null ) return false; 92 | if ( ReferenceEquals ( this, obj ) ) return true; 93 | if ( obj.GetType ( ) != GetType ( ) ) return false; 94 | return Equals ( (NodeBase) obj ); 95 | } 96 | 97 | public override int GetHashCode ( ) => 98 | Symbol != null ? Symbol.GetHashCode ( ) : 0; 99 | 100 | public override string ToString ( ) => 101 | $"{Symbol,-6} {EdgeTableImpl.Count}"; 102 | 103 | #endregion 104 | } 105 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/Pages/EditSubscriptionPage.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using CryptoTickerBot.Telegram.Interfaces; 4 | using CryptoTickerBot.Telegram.Menus.Abstractions; 5 | using CryptoTickerBot.Telegram.Subscriptions; 6 | using MoreLinq.Extensions; 7 | using Telegram.Bot.Types; 8 | 9 | namespace CryptoTickerBot.Telegram.Menus.Pages 10 | { 11 | internal class EditSubscriptionPage : PageBase 12 | { 13 | public TelegramPercentChangeSubscription Subscription { get; } 14 | 15 | public EditSubscriptionPage ( IMenu menu, 16 | TelegramPercentChangeSubscription subscription, 17 | IPage previousPage ) : 18 | base ( "Edit Subscription", menu, previousPage: previousPage ) 19 | { 20 | Subscription = subscription; 21 | Title = $"Edit Subscription:\n{subscription.Summary ( )}"; 22 | 23 | Labels = new[] {"change silence mode", "change threshold", "add symbols", "remove symbols"} 24 | .Batch ( 2 ) 25 | .ToList ( ); 26 | AddWideLabel ( "delete" ); 27 | AddWideLabel ( "back" ); 28 | 29 | BuildKeyboard ( ); 30 | AddHandlers ( ); 31 | } 32 | 33 | private void AddHandlers ( ) 34 | { 35 | Handlers["change silence mode"] = ChangeSilenceModeHandlerAsync; 36 | Handlers["change threshold"] = ChangeThresholdHandlerAsync; 37 | Handlers["add symbols"] = AddSymbolsHandlerAsync; 38 | Handlers["remove symbols"] = RemoveSymbolsHandlerAsync; 39 | Handlers["delete"] = DeleteHandlerAsync; 40 | Handlers["back"] = BackHandler; 41 | } 42 | 43 | private async Task UpdateSubscriptionAsync ( ) 44 | { 45 | TelegramBot.Data.AddOrUpdate ( Subscription ); 46 | await Menu.SendTextBlockAsync ( Subscription.Summary ( ) ).ConfigureAwait ( false ); 47 | } 48 | 49 | private async Task ChangeSilenceModeHandlerAsync ( CallbackQuery query ) 50 | { 51 | var isSilent = await RunSelectionPageAsync ( new[] {"Yes", "No"}.Batch ( 2 ), "Keep Silent?" ) 52 | .ConfigureAwait ( false ); 53 | if ( !isSilent ) 54 | return; 55 | 56 | Subscription.IsSilent = isSilent.Result == "Yes"; 57 | 58 | await UpdateSubscriptionAsync ( ).ConfigureAwait ( false ); 59 | } 60 | 61 | private async Task ChangeThresholdHandlerAsync ( CallbackQuery query ) 62 | { 63 | await Menu.RequestReplyAsync ( "Enter the threshold%" ).ConfigureAwait ( false ); 64 | var threshold = await ReadPercentageAsync ( ).ConfigureAwait ( false ); 65 | if ( threshold is null ) 66 | return; 67 | 68 | Subscription.Threshold = threshold.Value; 69 | await UpdateSubscriptionAsync ( ).ConfigureAwait ( false ); 70 | } 71 | 72 | private async Task AddSymbolsHandlerAsync ( CallbackQuery query ) 73 | { 74 | var symbols = await ReadSymbolsAsync ( ).ConfigureAwait ( false ); 75 | Subscription.AddSymbols ( symbols ); 76 | 77 | await UpdateSubscriptionAsync ( ).ConfigureAwait ( false ); 78 | } 79 | 80 | private async Task RemoveSymbolsHandlerAsync ( CallbackQuery query ) 81 | { 82 | var symbols = await ReadSymbolsAsync ( ).ConfigureAwait ( false ); 83 | Subscription.RemoveSymbols ( symbols ); 84 | 85 | await UpdateSubscriptionAsync ( ).ConfigureAwait ( false ); 86 | } 87 | 88 | private async Task DeleteHandlerAsync ( CallbackQuery query ) 89 | { 90 | Subscription.Stop ( ); 91 | TelegramBot.Data.PercentChangeSubscriptions.Remove ( Subscription ); 92 | 93 | await Menu.SendTextBlockAsync ( $"Removed :\n\n{Subscription.Summary ( )}" ).ConfigureAwait ( false ); 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Runner/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Colorful; 5 | using CryptoTickerBot.Core; 6 | using CryptoTickerBot.Core.Interfaces; 7 | using CryptoTickerBot.CUI; 8 | using CryptoTickerBot.Data.Configs; 9 | using CryptoTickerBot.GoogleSheets; 10 | using CryptoTickerBot.Telegram; 11 | using NLog; 12 | 13 | namespace CryptoTickerBot.Runner 14 | { 15 | public class Program 16 | { 17 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 18 | private static readonly ManualResetEvent QuitEvent = new ManualResetEvent ( false ); 19 | 20 | private static RunnerConfig RunnerConfig => ConfigManager.Instance; 21 | 22 | private static bool HasExceptions ( ) where TConfig : IConfig, new ( ) 23 | { 24 | if ( ConfigManager.TryValidate ( out var exceptions ) ) 25 | return false; 26 | 27 | foreach ( var exception in exceptions ) 28 | Logger.Error ( exception ); 29 | 30 | return true; 31 | } 32 | 33 | private static bool ValidateConfigs ( ) 34 | { 35 | if ( HasExceptions ( ) ) 36 | return false; 37 | 38 | if ( HasExceptions ( ) ) 39 | return false; 40 | 41 | if ( RunnerConfig.EnableGoogleSheetsService && HasExceptions ( ) ) 42 | return false; 43 | 44 | if ( RunnerConfig.EnableTelegramService && HasExceptions ( ) ) 45 | return false; 46 | 47 | return true; 48 | } 49 | 50 | [SuppressMessage ( "ReSharper", "AsyncConverter.AsyncMethodNamingHighlighting" )] 51 | public static async Task Main ( ) 52 | { 53 | Console.CancelKeyPress += ( sender, 54 | eArgs ) => 55 | { 56 | QuitEvent.Set ( ); 57 | eArgs.Cancel = true; 58 | }; 59 | 60 | if ( !ValidateConfigs ( ) ) 61 | return; 62 | 63 | var bot = new Bot ( ); 64 | 65 | await AttachServicesAsync ( bot ).ConfigureAwait ( false ); 66 | 67 | await bot.StartAsync ( ).ConfigureAwait ( false ); 68 | 69 | QuitEvent.WaitOne ( ); 70 | } 71 | 72 | private static async Task AttachServicesAsync ( IBot bot ) 73 | { 74 | if ( RunnerConfig.EnableGoogleSheetsService ) 75 | await AttachGoogleSheetsServiceAsync ( bot ).ConfigureAwait ( false ); 76 | 77 | if ( RunnerConfig.EnableConsoleService ) 78 | await AttachConsoleServiceAsync ( bot ).ConfigureAwait ( false ); 79 | 80 | if ( RunnerConfig.EnableTelegramService ) 81 | await AttachTelegramServiceAsync ( bot ).ConfigureAwait ( false ); 82 | } 83 | 84 | private static async Task AttachTelegramServiceAsync ( IBot bot ) 85 | { 86 | var teleService = new TelegramBotService ( ConfigManager.Instance ); 87 | await bot.AttachAsync ( teleService ).ConfigureAwait ( false ); 88 | } 89 | 90 | private static async Task AttachConsoleServiceAsync ( IBot bot ) 91 | { 92 | await bot.AttachAsync ( new ConsolePrintService ( ) ).ConfigureAwait ( false ); 93 | } 94 | 95 | private static async Task AttachGoogleSheetsServiceAsync ( IBot bot ) 96 | { 97 | var config = ConfigManager.Instance; 98 | 99 | var service = new GoogleSheetsUpdaterService ( config ); 100 | 101 | service.Update += updaterService => 102 | { 103 | Logger.Debug ( $"Sheets Updated @ {service.LastUpdate}" ); 104 | return Task.CompletedTask; 105 | }; 106 | 107 | await bot.AttachAsync ( service ).ConfigureAwait ( false ); 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/Pages/SelectionPage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using CryptoTickerBot.Telegram.Interfaces; 5 | using CryptoTickerBot.Telegram.Menus.Abstractions; 6 | using Telegram.Bot.Types; 7 | using Telegram.Bot.Types.ReplyMarkups; 8 | 9 | #pragma warning disable 1998 10 | 11 | namespace CryptoTickerBot.Telegram.Menus.Pages 12 | { 13 | internal class SelectionPage : PageBase 14 | { 15 | public T Result { get; private set; } 16 | public bool UserChoseBack { get; private set; } 17 | public Func ToStringConverter { get; } 18 | private readonly Dictionary selectionMap = new Dictionary ( ); 19 | 20 | public SelectionPage ( IMenu menu, 21 | IEnumerable> rows, 22 | IPage previousPage = null, 23 | string title = "Choose", 24 | Func toStringConverter = null ) : 25 | base ( title, menu, previousPage: previousPage ) 26 | { 27 | ToStringConverter = toStringConverter ?? ( x => x.ToString ( ) ); 28 | ExtractLabels ( rows ); 29 | 30 | foreach ( var label in selectionMap.Keys ) 31 | AddHandler ( label, QueryHandlerAsync ); 32 | 33 | AddWideLabel ( "Back" ); 34 | AddHandler ( "Back", async q => UserChoseBack = true ); 35 | 36 | BuildSpecialKeyboard ( ); 37 | } 38 | 39 | private void BuildSpecialKeyboard ( ) 40 | { 41 | var keyboard = new List> ( ); 42 | 43 | foreach ( var row in Labels ) 44 | { 45 | var buttonRow = new List ( ); 46 | foreach ( var label in row ) 47 | { 48 | var button = new InlineKeyboardButton 49 | { 50 | CallbackData = label, 51 | Text = selectionMap.TryGetValue ( label, out var item ) 52 | ? ToStringConverter ( item ) 53 | : label 54 | }; 55 | buttonRow.Add ( button ); 56 | } 57 | 58 | keyboard.Add ( buttonRow ); 59 | } 60 | 61 | Keyboard = new InlineKeyboardMarkup ( keyboard ); 62 | } 63 | 64 | private void ExtractLabels ( IEnumerable> rows ) 65 | { 66 | var labels = new List> ( ); 67 | 68 | foreach ( var row in rows ) 69 | { 70 | var stringRow = new List ( ); 71 | foreach ( var item in row ) 72 | { 73 | var label = item.ToString ( ); 74 | stringRow.Add ( label ); 75 | selectionMap[label] = item; 76 | } 77 | 78 | labels.Add ( stringRow ); 79 | } 80 | 81 | Labels = labels; 82 | } 83 | 84 | private async Task QueryHandlerAsync ( CallbackQuery query ) 85 | { 86 | var label = query.Data; 87 | 88 | if ( selectionMap.TryGetValue ( label, out var result ) ) 89 | Result = result; 90 | } 91 | 92 | public async Task DisplayAndWaitAsync ( ) 93 | { 94 | await Menu.SwitchPageAsync ( this ).ConfigureAwait ( false ); 95 | await WaitForButtonPressAsync ( ).ConfigureAwait ( false ); 96 | 97 | return new SelectionResult ( !UserChoseBack, Result ); 98 | } 99 | 100 | public struct SelectionResult 101 | { 102 | public SelectionResult ( bool hasResult, 103 | T result ) 104 | { 105 | HasResult = hasResult; 106 | Result = result; 107 | } 108 | 109 | public bool HasResult { get; } 110 | public T Result { get; } 111 | 112 | public static implicit operator bool ( SelectionResult selection ) => selection.HasResult; 113 | public static implicit operator T ( SelectionResult selection ) => selection.Result; 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/Pages/ManageSubscriptionsPage.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using CryptoTickerBot.Telegram.Interfaces; 4 | using CryptoTickerBot.Telegram.Menus.Abstractions; 5 | using CryptoTickerBot.Telegram.Subscriptions; 6 | using MoreLinq; 7 | using Telegram.Bot.Types; 8 | 9 | #pragma warning disable 1998 10 | 11 | namespace CryptoTickerBot.Telegram.Menus.Pages 12 | { 13 | internal class ManageSubscriptionsPage : PageBase 14 | { 15 | public ManageSubscriptionsPage ( IMenu menu, 16 | IPage previousPage ) : 17 | base ( "Manage Subscriptions", menu, previousPage: previousPage ) 18 | { 19 | Labels = new[] {"add subscription", "edit subscription"} 20 | .Batch ( 1 ) 21 | .ToList ( ); 22 | AddWideLabel ( "back" ); 23 | 24 | BuildKeyboard ( ); 25 | AddHandlers ( ); 26 | } 27 | 28 | private void AddHandlers ( ) 29 | { 30 | AddHandler ( "add subscription", AddSubscriptionHandlerAsync ); 31 | AddHandler ( "edit subscription", EditSubscriptionHandlerAsync ); 32 | AddHandler ( "back", BackHandler ); 33 | } 34 | 35 | private async Task AddSubscriptionHandlerAsync ( CallbackQuery query ) 36 | { 37 | var exchangeId = await RunExchangeSelectionPageAsync ( ).ConfigureAwait ( false ); 38 | if ( !exchangeId ) 39 | return; 40 | 41 | await Menu.RequestReplyAsync ( "Enter the threshold%" ).ConfigureAwait ( false ); 42 | var threshold = await ReadPercentageAsync ( ).ConfigureAwait ( false ); 43 | if ( threshold is null ) 44 | return; 45 | 46 | var isSilent = await RunSelectionPageAsync ( new[] {"yes", "no"}.Batch ( 2 ), "Keep Silent?" ) 47 | .ConfigureAwait ( false ); 48 | if ( !isSilent ) 49 | return; 50 | 51 | var symbols = await ReadSymbolsAsync ( ).ConfigureAwait ( false ); 52 | 53 | var subscription = new TelegramPercentChangeSubscription ( 54 | Chat, 55 | User, 56 | exchangeId.Result, 57 | threshold.Value, 58 | isSilent.Result == "yes", 59 | symbols 60 | ); 61 | 62 | await TelegramBot.AddOrUpdateSubscriptionAsync ( subscription ).ConfigureAwait ( false ); 63 | await RedrawAsync ( ).ConfigureAwait ( false ); 64 | } 65 | 66 | private async Task EditSubscriptionHandlerAsync ( CallbackQuery query ) 67 | { 68 | var subscriptions = TelegramBot.Data.PercentChangeSubscriptions 69 | .Where ( x => x.ChatId.Identifier == Chat.Id && x.User == User ) 70 | .ToList ( ); 71 | 72 | if ( !subscriptions.Any ( ) ) 73 | { 74 | await Menu.SendTextBlockAsync ( "There are no subscriptions to edit" ).ConfigureAwait ( false ); 75 | return; 76 | } 77 | 78 | var exchangeId = await RunExchangeSelectionPageAsync ( ).ConfigureAwait ( false ); 79 | if ( !exchangeId ) 80 | return; 81 | 82 | subscriptions = subscriptions.Where ( x => x.ExchangeId == exchangeId ).ToList ( ); 83 | 84 | if ( subscriptions.Count == 1 ) 85 | { 86 | await Menu.SwitchPageAsync ( new EditSubscriptionPage ( Menu, subscriptions[0], this ) ) 87 | .ConfigureAwait ( false ); 88 | return; 89 | } 90 | 91 | var threshold = 92 | await RunSelectionPageAsync ( subscriptions.Select ( x => x.Threshold ).Batch ( 2 ), 93 | "Select Threshold:", 94 | p => $"{p:P}" ) 95 | .ConfigureAwait ( false ); 96 | if ( !threshold ) 97 | return; 98 | 99 | var subscription = subscriptions.SingleOrDefault ( x => x.Threshold == threshold ); 100 | if ( subscription is null ) 101 | return; 102 | 103 | await Menu.SwitchPageAsync ( new EditSubscriptionPage ( Menu, subscription, this ) ) 104 | .ConfigureAwait ( false ); 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Data/Configs/ConfigManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.IO; 5 | using JetBrains.Annotations; 6 | using Newtonsoft.Json; 7 | using NLog; 8 | 9 | // ReSharper disable InconsistentlySynchronizedField 10 | 11 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 12 | 13 | // ReSharper disable StaticMemberInGenericType 14 | 15 | namespace CryptoTickerBot.Data.Configs 16 | { 17 | public static class ConfigManager 18 | { 19 | public static JsonSerializerSettings SerializerSettings { get; set; } = new JsonSerializerSettings 20 | { 21 | Formatting = Formatting.Indented, 22 | NullValueHandling = NullValueHandling.Ignore, 23 | ObjectCreationHandling = ObjectCreationHandling.Replace 24 | }; 25 | 26 | public static ImmutableHashSet InitializedConfigs { get; private set; } = ImmutableHashSet.Empty; 27 | 28 | public static ImmutableHashSet SetInitialized ( ) 29 | where TConfig : IConfig => 30 | InitializedConfigs = InitializedConfigs.Add ( typeof ( TConfig ) ); 31 | } 32 | 33 | public static class ConfigManager where TConfig : IConfig, new ( ) 34 | { 35 | [UsedImplicitly] private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 36 | private static readonly object FileLock; 37 | private static TConfig instance; 38 | 39 | public static ref TConfig Instance => ref instance; 40 | 41 | public static Exception LastError { get; private set; } 42 | 43 | public static string FileName => 44 | Path.Combine ( instance.ConfigFolderName ?? "Configs", $"{instance.ConfigFileName}.json" ); 45 | 46 | public static string Serialized => 47 | JsonConvert.SerializeObject ( instance, ConfigManager.SerializerSettings ); 48 | 49 | static ConfigManager ( ) 50 | { 51 | FileLock = new object ( ); 52 | instance = new TConfig ( ); 53 | Load ( ); 54 | Save ( ); 55 | 56 | ConfigManager.SetInitialized ( ); 57 | } 58 | 59 | public static void ClearLastError ( ) => 60 | LastError = null; 61 | 62 | public static bool TryValidate ( out IList exceptions ) => 63 | instance.TryValidate ( out exceptions ); 64 | 65 | public static void Save ( ) 66 | { 67 | try 68 | { 69 | lock ( FileLock ) 70 | { 71 | if ( !Directory.Exists ( instance.ConfigFolderName ) ) 72 | Directory.CreateDirectory ( instance.ConfigFolderName ); 73 | File.WriteAllText ( FileName, Serialized ); 74 | } 75 | } 76 | catch ( Exception e ) 77 | { 78 | LastError = e; 79 | Logger.Error ( e ); 80 | } 81 | } 82 | 83 | public static void Load ( ) 84 | { 85 | try 86 | { 87 | lock ( FileLock ) 88 | { 89 | if ( File.Exists ( FileName ) ) 90 | instance = JsonConvert.DeserializeObject ( File.ReadAllText ( FileName ), 91 | ConfigManager.SerializerSettings ); 92 | } 93 | } 94 | catch ( Exception e ) 95 | { 96 | LastError = e; 97 | Logger.Error ( e ); 98 | } 99 | } 100 | 101 | public static void RestoreDefaults ( ) 102 | { 103 | try 104 | { 105 | instance = instance.RestoreDefaults ( ); 106 | lock ( FileLock ) 107 | { 108 | if ( !Directory.Exists ( instance.ConfigFolderName ) ) 109 | Directory.CreateDirectory ( instance.ConfigFolderName ); 110 | File.WriteAllText ( FileName, Serialized ); 111 | } 112 | } 113 | catch ( Exception e ) 114 | { 115 | LastError = e; 116 | Logger.Error ( e ); 117 | } 118 | } 119 | 120 | public static void Reset ( ) 121 | { 122 | try 123 | { 124 | lock ( FileLock ) 125 | { 126 | if ( File.Exists ( FileName ) ) 127 | File.Delete ( FileName ); 128 | 129 | instance = new TConfig ( ); 130 | 131 | if ( !Directory.Exists ( instance.ConfigFolderName ) ) 132 | Directory.CreateDirectory ( instance.ConfigFolderName ); 133 | File.WriteAllText ( FileName, Serialized ); 134 | } 135 | } 136 | catch ( Exception e ) 137 | { 138 | LastError = e; 139 | Logger.Error ( e ); 140 | } 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Exchanges/KoinexExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CryptoTickerBot.Core.Abstractions; 6 | using CryptoTickerBot.Core.Helpers; 7 | using CryptoTickerBot.Data.Converters; 8 | using CryptoTickerBot.Data.Domain; 9 | using Newtonsoft.Json; 10 | using NLog; 11 | 12 | namespace CryptoTickerBot.Core.Exchanges 13 | { 14 | public class KoinexExchange : CryptoExchangeBase 15 | { 16 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 17 | 18 | public KoinexExchange ( ) : base ( CryptoExchangeId.Koinex ) 19 | { 20 | } 21 | 22 | protected override async Task GetExchangeDataAsync ( CancellationToken ct ) 23 | { 24 | while ( !ct.IsCancellationRequested ) 25 | { 26 | try 27 | { 28 | var json = await Utility.DownloadWebPageAsync ( TickerUrl ).ConfigureAwait ( false ); 29 | var data = JsonConvert.DeserializeObject ( json ); 30 | 31 | foreach ( var kp in data.Stats.Inr ) 32 | Update ( kp.Value, $"{kp.Key}INR" ); 33 | } 34 | catch ( Exception e ) 35 | { 36 | Logger.Error ( e ); 37 | } 38 | 39 | await Task.Delay ( PollingRate, ct ).ConfigureAwait ( false ); 40 | } 41 | } 42 | 43 | protected override void DeserializeData ( KoinexCoin data, 44 | string id ) 45 | { 46 | ExchangeData[id].LowestAsk = data.LowestAsk; 47 | ExchangeData[id].HighestBid = data.HighestBid; 48 | ExchangeData[id].Rate = data.LastTradedPrice; 49 | } 50 | 51 | #region JSON Classes 52 | 53 | public class KoinexTicker 54 | { 55 | [JsonProperty ( "prices" )] 56 | public Prices Prices { get; set; } 57 | 58 | [JsonProperty ( "stats" )] 59 | public Stats Stats { get; set; } 60 | } 61 | 62 | public class Prices 63 | { 64 | [JsonProperty ( "inr" )] 65 | public Dictionary Inr { get; set; } 66 | 67 | [JsonProperty ( "bitcoin" )] 68 | public Dictionary Bitcoin { get; set; } 69 | 70 | [JsonProperty ( "ether" )] 71 | public Dictionary Ether { get; set; } 72 | 73 | [JsonProperty ( "ripple" )] 74 | public Dictionary Ripple { get; set; } 75 | } 76 | 77 | public class Stats 78 | { 79 | [JsonProperty ( "inr" )] 80 | public Dictionary Inr { get; set; } 81 | 82 | [JsonProperty ( "bitcoin" )] 83 | public Dictionary Bitcoin { get; set; } 84 | 85 | [JsonProperty ( "ether" )] 86 | public Dictionary Ether { get; set; } 87 | 88 | [JsonProperty ( "ripple" )] 89 | public Dictionary Ripple { get; set; } 90 | } 91 | 92 | public class KoinexCoin 93 | { 94 | [JsonProperty ( "highest_bid" )] 95 | [JsonConverter ( typeof ( DecimalConverter ) )] 96 | public decimal HighestBid { get; set; } 97 | 98 | [JsonProperty ( "lowest_ask" )] 99 | [JsonConverter ( typeof ( DecimalConverter ) )] 100 | public decimal LowestAsk { get; set; } 101 | 102 | [JsonProperty ( "last_traded_price" )] 103 | [JsonConverter ( typeof ( DecimalConverter ) )] 104 | public decimal LastTradedPrice { get; set; } 105 | 106 | [JsonProperty ( "min_24hrs" )] 107 | [JsonConverter ( typeof ( DecimalConverter ) )] 108 | public decimal Min24Hrs { get; set; } 109 | 110 | [JsonProperty ( "max_24hrs" )] 111 | [JsonConverter ( typeof ( DecimalConverter ) )] 112 | public decimal Max24Hrs { get; set; } 113 | 114 | [JsonProperty ( "vol_24hrs" )] 115 | [JsonConverter ( typeof ( DecimalConverter ) )] 116 | public decimal Vol24Hrs { get; set; } 117 | 118 | [JsonProperty ( "currency_full_form" )] 119 | public string CurrencyFullForm { get; set; } 120 | 121 | [JsonProperty ( "currency_short_form" )] 122 | public string CurrencyShortForm { get; set; } 123 | 124 | [JsonProperty ( "per_change" )] 125 | [JsonConverter ( typeof ( DecimalConverter ) )] 126 | public decimal PerChange { get; set; } 127 | 128 | [JsonProperty ( "trade_volume" )] 129 | [JsonConverter ( typeof ( DecimalConverter ) )] 130 | public decimal TradeVolume { get; set; } 131 | } 132 | 133 | #endregion 134 | } 135 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Subscriptions/PercentChangeSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Collections.Immutable; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using CryptoTickerBot.Core.Abstractions; 8 | using CryptoTickerBot.Core.Interfaces; 9 | using CryptoTickerBot.Data.Domain; 10 | using CryptoTickerBot.Data.Extensions; 11 | using CryptoTickerBot.Data.Helpers; 12 | using Newtonsoft.Json; 13 | using Newtonsoft.Json.Converters; 14 | 15 | namespace CryptoTickerBot.Core.Subscriptions 16 | { 17 | public class PercentChangeSubscription : 18 | CryptoExchangeSubscriptionBase, 19 | IEquatable 20 | { 21 | [JsonConverter ( typeof ( StringEnumConverter ) )] 22 | public CryptoExchangeId ExchangeId { get; } 23 | 24 | public decimal Threshold { get; set; } 25 | public IDictionary LastSignificantPrice { get; protected set; } 26 | public ImmutableHashSet Symbols { get; protected set; } 27 | 28 | public PercentChangeSubscription ( CryptoExchangeId exchangeId, 29 | decimal threshold, 30 | IEnumerable symbols ) 31 | { 32 | ExchangeId = exchangeId; 33 | Threshold = threshold; 34 | Symbols = ImmutableHashSet.Empty.Union ( symbols.Select ( x => x.ToUpper ( ) ) ); 35 | } 36 | 37 | public event TriggerDelegate Trigger; 38 | 39 | public override string ToString ( ) => 40 | $" {nameof ( Exchange )}: {ExchangeId}," + 41 | $" {nameof ( Threshold )}: {Threshold:P}," + 42 | $" {nameof ( Symbols )}: {Symbols.Join ( ", " )}"; 43 | 44 | public ImmutableHashSet AddSymbols ( IEnumerable symbols ) => 45 | Symbols = Symbols.Union ( symbols.Select ( x => x.ToUpper ( ) ) ); 46 | 47 | public ImmutableHashSet RemoveSymbols ( IEnumerable symbols ) => 48 | Symbols = Symbols.Except ( symbols.Select ( x => x.ToUpper ( ) ) ); 49 | 50 | protected override void Start ( ICryptoExchange exchange ) 51 | { 52 | if ( exchange is null ) 53 | return; 54 | 55 | base.Start ( exchange ); 56 | 57 | if ( LastSignificantPrice is null ) 58 | LastSignificantPrice = new ConcurrentDictionary ( 59 | exchange.ExchangeData 60 | .Where ( x => Symbols.Contains ( x.Key ) ) 61 | ); 62 | } 63 | 64 | public override async void OnNext ( CryptoCoin coin ) 65 | { 66 | if ( !Symbols.Contains ( coin.Symbol ) ) 67 | return; 68 | 69 | if ( !LastSignificantPrice.ContainsKey ( coin.Symbol ) ) 70 | LastSignificantPrice[coin.Symbol] = coin; 71 | 72 | var change = PriceChange.Difference ( coin, LastSignificantPrice[coin.Symbol] ); 73 | var percentage = Math.Abs ( change.Percentage ); 74 | 75 | if ( percentage >= Threshold ) 76 | { 77 | var previous = LastSignificantPrice[coin.Symbol].Clone ( ); 78 | LastSignificantPrice[coin.Symbol] = coin.Clone ( ); 79 | 80 | await OnTriggerAsync ( previous.Clone ( ), coin.Clone ( ) ).ConfigureAwait ( false ); 81 | Trigger?.Invoke ( this, previous.Clone ( ), coin.Clone ( ) ); 82 | } 83 | } 84 | 85 | protected virtual Task OnTriggerAsync ( CryptoCoin old, 86 | CryptoCoin current ) => 87 | Task.CompletedTask; 88 | 89 | #region Equality Members 90 | 91 | public bool Equals ( PercentChangeSubscription other ) 92 | { 93 | if ( other is null ) return false; 94 | return ReferenceEquals ( this, other ) || Id.Equals ( other.Id ); 95 | } 96 | 97 | public override bool Equals ( object obj ) 98 | { 99 | if ( obj is null ) return false; 100 | if ( ReferenceEquals ( this, obj ) ) return true; 101 | return obj.GetType ( ) == GetType ( ) && Equals ( (PercentChangeSubscription) obj ); 102 | } 103 | 104 | public override int GetHashCode ( ) => Id.GetHashCode ( ); 105 | 106 | public static bool operator == ( PercentChangeSubscription left, 107 | PercentChangeSubscription right ) => Equals ( left, right ); 108 | 109 | public static bool operator != ( PercentChangeSubscription left, 110 | PercentChangeSubscription right ) => !Equals ( left, right ); 111 | 112 | #endregion 113 | } 114 | 115 | public delegate Task TriggerDelegate ( PercentChangeSubscription subscription, 116 | CryptoCoin old, 117 | CryptoCoin current ); 118 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Helpers/FiatConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Diagnostics.Contracts; 5 | using System.Globalization; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using System.Timers; 9 | using CryptoTickerBot.Data.Configs; 10 | using CryptoTickerBot.Data.Extensions; 11 | using Flurl.Http; 12 | using Newtonsoft.Json; 13 | using NLog; 14 | using Polly; 15 | 16 | namespace CryptoTickerBot.Core.Helpers 17 | { 18 | public static class FiatConverter 19 | { 20 | public static readonly string TickerUrl = "http://data.fixer.io/api/latest"; 21 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 22 | 23 | private static readonly IDictionary Map; 24 | 25 | public static Dictionary UsdTo { get; private set; } = 26 | new Dictionary ( ); 27 | 28 | public static bool IsRunning { get; private set; } 29 | public static Timer Timer { get; private set; } 30 | public static Policy Policy { get; set; } 31 | 32 | static FiatConverter ( ) 33 | { 34 | Map = CultureInfo 35 | .GetCultures ( CultureTypes.AllCultures ) 36 | .Where ( c => !c.IsNeutralCulture ) 37 | .Select ( culture => 38 | { 39 | try 40 | { 41 | return new RegionInfo ( culture.LCID ); 42 | } 43 | catch 44 | { 45 | return null; 46 | } 47 | } ) 48 | .Where ( 49 | ri => ri != null ) 50 | .GroupBy ( ri => ri.ISOCurrencySymbol ) 51 | .OrderBy ( x => x.Key ) 52 | .ToDictionary ( 53 | x => x.Key, 54 | x => x.First ( ) 55 | ); 56 | 57 | TickerUrl += $"?access_key={ConfigManager.Instance.FixerApiKey}"; 58 | TickerUrl += "&symbols=" + Map.Keys.Join ( "," ); 59 | 60 | Policy = Policy 61 | .Handle ( ) 62 | .WaitAndRetryAsync ( 5, 63 | x => TimeSpan.FromSeconds ( 1 << x ), 64 | ( exception, 65 | span, 66 | retryCount, 67 | ctx ) => Logger.Error ( exception, $"Retry attemp #{retryCount}" ) ); 68 | } 69 | 70 | public static async Task StartMonitorAsync ( ) 71 | { 72 | if ( IsRunning ) 73 | return Timer; 74 | IsRunning = true; 75 | 76 | Timer = new Timer ( TimeSpan.FromDays ( 1 ).TotalMilliseconds ); 77 | Timer.Disposed += ( sender, 78 | args ) => IsRunning = false; 79 | 80 | await TryFetchRatesAsync ( ).ConfigureAwait ( false ); 81 | 82 | Timer.Elapsed += ( sender, 83 | args ) => 84 | Task.Run ( async ( ) => await TryFetchRatesAsync ( ).ConfigureAwait ( false ) ); 85 | Timer.Start ( ); 86 | 87 | return Timer; 88 | } 89 | 90 | public static void StopMonitor ( ) 91 | { 92 | Timer?.Stop ( ); 93 | Timer?.Dispose ( ); 94 | } 95 | 96 | public static async Task TryFetchRatesAsync ( ) 97 | { 98 | try 99 | { 100 | await Policy.ExecuteAsync ( FetchRatesAsync ).ConfigureAwait ( false ); 101 | } 102 | catch ( Exception e ) 103 | { 104 | Logger.Error ( e ); 105 | throw; 106 | } 107 | } 108 | 109 | private static async Task FetchRatesAsync ( ) 110 | { 111 | var json = await TickerUrl.GetStringAsync ( ).ConfigureAwait ( false ); 112 | var data = JsonConvert.DeserializeObject ( json ); 113 | UsdTo = 114 | JsonConvert.DeserializeObject> ( data.rates.ToString ( ) ); 115 | 116 | var factor = UsdTo["USD"]; 117 | var symbols = UsdTo.Keys.ToList ( ); 118 | foreach ( var fiat in symbols ) 119 | UsdTo[fiat] = Math.Round ( UsdTo[fiat] / factor, 2 ); 120 | UsdTo["USD"] = 1m; 121 | 122 | Logger.Info ( $"Fetched Fiat currency rates for {UsdTo.Count} symbols." ); 123 | } 124 | 125 | public static Dictionary GetSymbols ( ) => 126 | new Dictionary ( Map ); 127 | 128 | [DebuggerStepThrough] 129 | [Pure] 130 | public static decimal Convert ( decimal amount, 131 | string from, 132 | string to ) => 133 | amount * UsdTo[to] / UsdTo[from]; 134 | 135 | [DebuggerStepThrough] 136 | [Pure] 137 | public static string ToString ( decimal amount, 138 | string from, 139 | string to ) 140 | { 141 | var result = Convert ( amount, from, to ); 142 | var symbol = Map[to]; 143 | 144 | return $"{symbol.CurrencySymbol}{result:N}"; 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /CryptoTickerBot.UnitTests/ArbitrageTests/CycleTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using CryptoTickerBot.Arbitrage; 5 | using CryptoTickerBot.Arbitrage.Common; 6 | using CryptoTickerBot.Arbitrage.Interfaces; 7 | using NUnit.Framework; 8 | 9 | namespace CryptoTickerBot.UnitTests.ArbitrageTests 10 | { 11 | [TestFixture] 12 | public class CycleTests 13 | { 14 | public static Dictionary MakeNodes ( IEnumerable symbols ) => 15 | symbols.ToDictionary ( x => x, x => new Node ( x ) ); 16 | 17 | public static Dictionary MakeNodes ( params string[] symbols ) => 18 | MakeNodes ( symbols.AsEnumerable ( ) ); 19 | 20 | public static IEdge ConnectNodes ( Node node1, 21 | Node node2, 22 | decimal cost ) 23 | { 24 | var edge = new Edge ( node1, node2, cost ); 25 | node1.AddOrUpdateEdge ( edge ); 26 | 27 | return edge; 28 | } 29 | 30 | public static void ConnectNodes ( Dictionary nodeMap, 31 | params (string from, string to, decimal cost)[] pairs ) 32 | { 33 | foreach ( var (from, to, cost) in pairs ) 34 | ConnectNodes ( nodeMap[from], nodeMap[to], cost ); 35 | } 36 | 37 | public static Cycle GetCycle ( Dictionary nodeMap, 38 | params string[] symbols ) 39 | { 40 | var nodes = new List ( symbols.Length ); 41 | foreach ( var symbol in symbols ) 42 | if ( nodeMap.TryGetValue ( symbol, out var node ) ) 43 | nodes.Add ( node ); 44 | 45 | return new Cycle ( nodes ); 46 | } 47 | 48 | [Test] 49 | public void CycleConstructorShouldThrowOnBrokenPath ( ) 50 | { 51 | var nodeMap = MakeNodes ( "A", "B", "C", "D" ); 52 | ConnectNodes ( nodeMap, 53 | ( "A", "B", 1m ), 54 | ( "B", "C", 1m ), 55 | ( "C", "D", 1m ), 56 | ( "D", "A", 1m ) 57 | ); 58 | 59 | Assert.Throws ( ( ) => GetCycle ( nodeMap, "A", "B", "C", "A" ) ); 60 | Assert.DoesNotThrow ( ( ) => GetCycle ( nodeMap, "A", "B", "C", "D", "A" ) ); 61 | } 62 | 63 | [Test] 64 | public void CycleShouldHaveCorrectEdgeList ( ) 65 | { 66 | var nodeMap = MakeNodes ( "A", "B", "C", "D" ); 67 | ConnectNodes ( nodeMap, 68 | ( "A", "B", 1m ), 69 | ( "B", "C", 1m ), 70 | ( "C", "D", 1m ), 71 | ( "D", "A", 1m ) 72 | ); 73 | 74 | var cycle = GetCycle ( nodeMap, "A", "B", "C", "D", "A" ); 75 | Assert.AreEqual ( cycle.Length, 4 ); 76 | Assert.True ( cycle.Path.Select ( x => x.Symbol ).SequenceEqual ( new[] {"A", "B", "C", "D", "A"} ) ); 77 | Assert.AreEqual ( cycle.Edges.Count, 4 ); 78 | 79 | INode cur = nodeMap["A"]; 80 | foreach ( var edge in cycle.Edges ) 81 | { 82 | Assert.AreSame ( cur, edge.From ); 83 | cur = edge.To; 84 | } 85 | 86 | Assert.AreSame ( cur, nodeMap["A"] ); 87 | } 88 | 89 | [Test] 90 | public void CycleUpdateWeightShouldRecomputeWeightCorrectly ( ) 91 | { 92 | var nodeMap = MakeNodes ( "A", "B", "C", "D" ); 93 | ConnectNodes ( nodeMap, 94 | ( "A", "B", 1m ), 95 | ( "B", "C", 1m ), 96 | ( "C", "D", 1m ), 97 | ( "D", "A", 1m ) 98 | ); 99 | 100 | var cycle = GetCycle ( nodeMap, "A", "B", "C", "D", "A" ); 101 | var weight = cycle.UpdateWeight ( ); 102 | Assert.That ( weight, Is.EqualTo ( 0 ).Within ( 0.000001 ) ); 103 | Assert.AreEqual ( weight, cycle.UpdateWeight ( ) ); 104 | Assert.AreEqual ( weight, cycle.Weight ); 105 | 106 | ConnectNodes ( nodeMap, ( "D", "A", 1.5m ) ); 107 | Assert.That ( cycle.UpdateWeight ( ), Is.LessThan ( 0 ) ); 108 | 109 | ConnectNodes ( nodeMap, ( "D", "A", 0.5m ) ); 110 | Assert.That ( cycle.UpdateWeight ( ), Is.GreaterThan ( 0 ) ); 111 | } 112 | 113 | [Test] 114 | public void RotatedCyclesShouldBeEqual ( ) 115 | { 116 | var nodeMap = MakeNodes ( "A", "B", "C", "D" ); 117 | ConnectNodes ( nodeMap, 118 | ( "A", "B", 1m ), 119 | ( "B", "C", 1m ), 120 | ( "C", "D", 1m ), 121 | ( "D", "A", 1m ) 122 | ); 123 | 124 | var cycleA = GetCycle ( nodeMap, "A", "B", "C", "D", "A" ); 125 | var cycleB = GetCycle ( nodeMap, "B", "C", "D", "A", "B" ); 126 | 127 | Assert.AreEqual ( cycleA, cycleA ); 128 | Assert.AreNotSame ( cycleA, cycleB ); 129 | Assert.AreEqual ( cycleA, cycleB ); 130 | Assert.AreEqual ( cycleA.GetHashCode ( ), cycleB.GetHashCode ( ) ); 131 | Assert.True ( cycleA.IsCyclicEquivalent ( cycleB ) ); 132 | } 133 | } 134 | } -------------------------------------------------------------------------------- /CryptoTickerBot.GoogleSheets/GoogleSheetsUpdaterService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using CryptoTickerBot.Core.Abstractions; 7 | using CryptoTickerBot.Core.Interfaces; 8 | using CryptoTickerBot.Data.Domain; 9 | using Google; 10 | using Google.Apis.Services; 11 | using Google.Apis.Sheets.v4; 12 | using Google.Apis.Sheets.v4.Data; 13 | using JetBrains.Annotations; 14 | using NLog; 15 | using static Google.Apis.Sheets.v4.SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum; 16 | 17 | namespace CryptoTickerBot.GoogleSheets 18 | { 19 | public class GoogleSheetsUpdaterService : BotServiceBase 20 | { 21 | public const string FolderName = "GoogleApi"; 22 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 23 | 24 | public SheetsConfig Config { get; } 25 | 26 | public SheetsService Service { get; } 27 | 28 | public DateTime LastUpdate { get; private set; } = DateTime.UtcNow; 29 | 30 | private int previousCount; 31 | 32 | public GoogleSheetsUpdaterService ( SheetsConfig config ) 33 | { 34 | Config = config; 35 | 36 | try 37 | { 38 | var credential = Utility.GetCredentials ( 39 | Path.Combine ( FolderName, "ClientSecret.json" ), 40 | Path.Combine ( FolderName, "Credentials" ) 41 | ); 42 | 43 | Service = new SheetsService ( new BaseClientService.Initializer 44 | { 45 | HttpClientInitializer = credential, 46 | ApplicationName = config.ApplicationName 47 | } ); 48 | } 49 | catch ( Exception e ) 50 | { 51 | Logger.Error ( e ); 52 | } 53 | } 54 | 55 | [UsedImplicitly] 56 | public event UpdateDelegate Update; 57 | 58 | public override async Task OnChangedAsync ( ICryptoExchange exchange, 59 | CryptoCoin coin ) 60 | { 61 | if ( DateTime.UtcNow - LastUpdate < Config.UpdateFrequency ) 62 | return; 63 | LastUpdate = DateTime.UtcNow; 64 | 65 | try 66 | { 67 | var valueRanges = GetValueRangeToUpdate ( ); 68 | await UpdateSheetAsync ( valueRanges ).ConfigureAwait ( false ); 69 | } 70 | catch ( Exception e ) 71 | { 72 | Logger.Error ( e ); 73 | } 74 | } 75 | 76 | private async Task ClearSheetAsync ( ) 77 | { 78 | var requestBody = new BatchUpdateSpreadsheetRequest 79 | { 80 | Requests = new List 81 | { 82 | new Request 83 | { 84 | UpdateCells = new UpdateCellsRequest 85 | { 86 | Range = new GridRange {SheetId = Config.SheetId}, 87 | Fields = "userEnteredValue" 88 | } 89 | } 90 | } 91 | }; 92 | 93 | var request = Service.Spreadsheets.BatchUpdate ( requestBody, Config.SpreadSheetId ); 94 | 95 | await request.ExecuteAsync ( Bot.Cts.Token ).ConfigureAwait ( false ); 96 | } 97 | 98 | private async Task UpdateSheetAsync ( ValueRange valueRange ) 99 | { 100 | try 101 | { 102 | if ( valueRange.Values.Count != previousCount ) 103 | await ClearSheetAsync ( ).ConfigureAwait ( false ); 104 | previousCount = valueRange.Values.Count; 105 | 106 | var request = Service.Spreadsheets.Values.Update ( valueRange, Config.SpreadSheetId, valueRange.Range ); 107 | request.ValueInputOption = USERENTERED; 108 | 109 | await request.ExecuteAsync ( Bot.Cts.Token ).ConfigureAwait ( false ); 110 | 111 | Update?.Invoke ( this ); 112 | } 113 | catch ( Exception e ) 114 | { 115 | await HandleUpdateExceptionAsync ( e ).ConfigureAwait ( false ); 116 | } 117 | } 118 | 119 | private async Task HandleUpdateExceptionAsync ( Exception e ) 120 | { 121 | if ( e is GoogleApiException gae && gae.Error?.Code == 429 ) 122 | { 123 | Logger.Warn ( gae, "Too many Google Api requests. Cooling down." ); 124 | await Task.Delay ( Config.CooldownPeriod, Bot.Cts.Token ).ConfigureAwait ( false ); 125 | } 126 | else if ( !Bot.Cts.IsCancellationRequested && 127 | ( e is TaskCanceledException || 128 | e is OperationCanceledException ) ) 129 | { 130 | Logger.Warn ( e ); 131 | } 132 | else 133 | { 134 | Logger.Error ( e ); 135 | } 136 | } 137 | 138 | [Pure] 139 | private ValueRange GetValueRangeToUpdate ( ) 140 | { 141 | var start = Config.StartingRow; 142 | var firstColumn = Config.StartingColumn; 143 | var lastColumn = (char) ( Config.StartingColumn + 8 ); 144 | 145 | var rows = new List> ( ); 146 | foreach ( var exchange in Bot.Exchanges.Values.OrderBy ( x => x.Name ) ) 147 | { 148 | rows.AddRange ( exchange.ToSheetsRows ( ) ); 149 | for ( var i = 0; i < Config.ExchangeRowGap; i++ ) 150 | rows.Add ( new List ( ) ); 151 | } 152 | 153 | return new ValueRange 154 | { 155 | Values = rows, 156 | Range = $"{Config.SheetName}!{firstColumn}{start}:{lastColumn}{start + rows.Count}" 157 | }; 158 | } 159 | 160 | public override void Dispose ( ) => Service?.Dispose ( ); 161 | } 162 | 163 | public delegate Task UpdateDelegate ( GoogleSheetsUpdaterService service ); 164 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Exchanges/KrakenExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CryptoTickerBot.Core.Abstractions; 6 | using CryptoTickerBot.Data.Domain; 7 | using Flurl.Http; 8 | using Newtonsoft.Json; 9 | using NLog; 10 | using Polly; 11 | 12 | namespace CryptoTickerBot.Core.Exchanges 13 | { 14 | public class KrakenExchange : CryptoExchangeBase 15 | { 16 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 17 | 18 | public KrakenAssetPairs Assets { get; private set; } 19 | public string TradableAssetPairsEndpoint { get; } 20 | public string TickerEndpoint { get; } 21 | 22 | protected readonly Policy RetryPolicy; 23 | 24 | public KrakenExchange ( ) : base ( CryptoExchangeId.Kraken ) 25 | { 26 | TradableAssetPairsEndpoint = $"{TickerUrl}0/public/AssetPairs"; 27 | TickerEndpoint = $"{TickerUrl}0/public/Ticker"; 28 | 29 | RetryPolicy = Policy 30 | .Handle ( ) 31 | .WaitAndRetryAsync ( 5, i => CooldownPeriod ); 32 | } 33 | 34 | protected override async Task GetExchangeDataAsync ( CancellationToken ct ) 35 | { 36 | Assets = await TradableAssetPairsEndpoint 37 | .GetJsonAsync ( ct ) 38 | .ConfigureAwait ( false ); 39 | var tickerUrlWithPairs = $"{TickerEndpoint}?pair={string.Join ( ",", Assets.Result.Keys )}"; 40 | 41 | while ( !ct.IsCancellationRequested ) 42 | { 43 | try 44 | { 45 | var data = await RetryPolicy 46 | .ExecuteAsync ( async ( ) => 47 | await tickerUrlWithPairs 48 | .GetJsonAsync ( ct ) 49 | .ConfigureAwait ( false ) 50 | ).ConfigureAwait ( false ); 51 | 52 | foreach ( var kp in data.Results ) 53 | Update ( kp.Value, kp.Key ); 54 | } 55 | catch ( Exception e ) 56 | { 57 | Logger.Error ( e ); 58 | } 59 | 60 | await Task.Delay ( PollingRate, ct ).ConfigureAwait ( false ); 61 | } 62 | } 63 | 64 | protected override void DeserializeData ( KrakenCoinInfo data, 65 | string id ) 66 | { 67 | ExchangeData[id].LowestAsk = data.Ask[0]; 68 | ExchangeData[id].HighestBid = data.Bid[0]; 69 | ExchangeData[id].Rate = data.LastTrade[0]; 70 | } 71 | 72 | #region JSON Structure 73 | 74 | public class KrakenAssetPairs 75 | { 76 | [JsonProperty ( "error" )] 77 | public object[] Error { get; set; } 78 | 79 | [JsonProperty ( "result" )] 80 | public Dictionary Result { get; set; } 81 | } 82 | 83 | public class KrakenAssetPair 84 | { 85 | [JsonProperty ( "altname" )] 86 | public string AltName { get; set; } 87 | 88 | [JsonProperty ( "aclass_base" )] 89 | public string BaseAssetClass { get; set; } 90 | 91 | [JsonProperty ( "base" )] 92 | public string Base { get; set; } 93 | 94 | [JsonProperty ( "aclass_quote" )] 95 | public string QuoteAssetClass { get; set; } 96 | 97 | [JsonProperty ( "quote" )] 98 | public string Quote { get; set; } 99 | 100 | [JsonProperty ( "lot" )] 101 | public string Lot { get; set; } 102 | 103 | [JsonProperty ( "pair_decimals" )] 104 | public long PairDecimals { get; set; } 105 | 106 | [JsonProperty ( "lot_decimals" )] 107 | public long LotDecimals { get; set; } 108 | 109 | [JsonProperty ( "lot_multiplier" )] 110 | public long LotMultiplier { get; set; } 111 | 112 | [JsonProperty ( "leverage_buy" )] 113 | public long[] LeverageBuy { get; set; } 114 | 115 | [JsonProperty ( "leverage_sell" )] 116 | public long[] LeverageSell { get; set; } 117 | 118 | [JsonProperty ( "fees" )] 119 | public decimal[][] Fees { get; set; } 120 | 121 | [JsonProperty ( "fees_maker", NullValueHandling = NullValueHandling.Ignore )] 122 | public decimal[][] FeesMaker { get; set; } 123 | 124 | [JsonProperty ( "fee_volume_currency" )] 125 | public string FeeVolumeCurrency { get; set; } 126 | 127 | [JsonProperty ( "margin_call" )] 128 | public long MarginCall { get; set; } 129 | 130 | [JsonProperty ( "margin_stop" )] 131 | public long MarginStop { get; set; } 132 | } 133 | 134 | private class Root 135 | { 136 | [JsonProperty ( "error" )] 137 | public List Error { get; set; } 138 | 139 | [JsonProperty ( "result" )] 140 | public Dictionary Results { get; set; } 141 | } 142 | 143 | public class KrakenCoinInfo 144 | { 145 | [JsonProperty ( "a" )] 146 | public List Ask { get; set; } 147 | 148 | [JsonProperty ( "b" )] 149 | public List Bid { get; set; } 150 | 151 | [JsonProperty ( "c" )] 152 | public List LastTrade { get; set; } 153 | 154 | [JsonProperty ( "v" )] 155 | public List Volume { get; set; } 156 | 157 | [JsonProperty ( "p" )] 158 | public List Price { get; set; } 159 | 160 | [JsonProperty ( "t" )] 161 | public List Trades { get; set; } 162 | 163 | [JsonProperty ( "l" )] 164 | public List Low { get; set; } 165 | 166 | [JsonProperty ( "h" )] 167 | public List High { get; set; } 168 | 169 | [JsonProperty ( "o" )] 170 | public decimal Open { get; set; } 171 | } 172 | 173 | #endregion 174 | } 175 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/TelegramKeyboardMenu.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using CryptoTickerBot.Telegram.Extensions; 5 | using CryptoTickerBot.Telegram.Interfaces; 6 | using Telegram.Bot; 7 | using Telegram.Bot.Types; 8 | using Telegram.Bot.Types.Enums; 9 | using Telegram.Bot.Types.ReplyMarkups; 10 | 11 | namespace CryptoTickerBot.Telegram.Menus 12 | { 13 | internal class TelegramKeyboardMenu : IMenu 14 | { 15 | public User User { get; } 16 | public Chat Chat { get; } 17 | public TelegramBot TelegramBot { get; } 18 | public IPage CurrentPage { get; private set; } 19 | public Message LastMessage { get; private set; } 20 | public bool IsOpen => LastMessage != null; 21 | public TelegramBotClient Client => TelegramBot.Client; 22 | public CancellationToken CancellationToken => TelegramBot.CancellationToken; 23 | 24 | 25 | protected readonly ConcurrentQueue Messages = new ConcurrentQueue ( ); 26 | 27 | public TelegramKeyboardMenu ( User user, 28 | Chat chat, 29 | TelegramBot telegramBot ) 30 | { 31 | User = user; 32 | Chat = chat; 33 | TelegramBot = telegramBot; 34 | } 35 | 36 | public async Task DeleteAsync ( ) 37 | { 38 | if ( LastMessage != null ) 39 | { 40 | await Client 41 | .DeleteMessageAsync ( Chat.Id, LastMessage.MessageId, CancellationToken ) 42 | .ConfigureAwait ( false ); 43 | LastMessage = null; 44 | } 45 | } 46 | 47 | public async Task DisplayAsync ( IPage page ) 48 | { 49 | await DeleteAsync ( ).ConfigureAwait ( false ); 50 | CurrentPage = page; 51 | 52 | if ( CurrentPage != null ) 53 | return LastMessage = await SendTextBlockAsync ( page.Title, replyMarkup: page.Keyboard ) 54 | .ConfigureAwait ( false ); 55 | 56 | return null; 57 | } 58 | 59 | public async Task SwitchPageAsync ( IPage page, 60 | bool replaceOld = false ) 61 | { 62 | CurrentPage = page; 63 | 64 | if ( replaceOld ) 65 | { 66 | if ( CurrentPage != null ) 67 | LastMessage = 68 | await EditTextBlockAsync ( LastMessage.MessageId, CurrentPage.Title, CurrentPage.Keyboard ) 69 | .ConfigureAwait ( false ); 70 | } 71 | else 72 | { 73 | await DisplayAsync ( page ).ConfigureAwait ( false ); 74 | } 75 | } 76 | 77 | public async Task HandleMessageAsync ( Message message ) 78 | { 79 | if ( message.From == User ) 80 | { 81 | Messages.Enqueue ( message ); 82 | await CurrentPage.HandleMessageAsync ( message ).ConfigureAwait ( false ); 83 | } 84 | } 85 | 86 | public async Task HandleQueryAsync ( CallbackQuery query ) 87 | { 88 | if ( query.From != User ) 89 | { 90 | await Client 91 | .AnswerCallbackQueryAsync ( query.Id, 92 | "This is not your menu!", 93 | cancellationToken: CancellationToken ).ConfigureAwait ( false ); 94 | return; 95 | } 96 | 97 | await CurrentPage.HandleQueryAsync ( query ).ConfigureAwait ( false ); 98 | } 99 | 100 | public async Task SendTextBlockAsync ( 101 | string text, 102 | int replyToMessageId = 0, 103 | bool disableWebPagePreview = false, 104 | bool disableNotification = true, 105 | IReplyMarkup replyMarkup = null 106 | ) => 107 | await Client 108 | .SendTextMessageAsync ( Chat, 109 | text.ToMarkdown ( ), ParseMode.Markdown, 110 | disableWebPagePreview, disableNotification, 111 | replyToMessageId, 112 | replyMarkup, 113 | CancellationToken ) 114 | .ConfigureAwait ( false ); 115 | 116 | public async Task EditTextBlockAsync ( 117 | int messageId, 118 | string text, 119 | InlineKeyboardMarkup markup = null 120 | ) => 121 | await Client 122 | .EditMessageTextAsync ( Chat.Id, 123 | messageId, 124 | text.ToMarkdown ( ), ParseMode.Markdown, 125 | false, 126 | markup, 127 | CancellationToken ) 128 | .ConfigureAwait ( false ); 129 | 130 | public async Task RequestReplyAsync ( 131 | string text, 132 | bool disableWebPagePreview = false, 133 | bool disableNotification = true 134 | ) => 135 | await Client 136 | .SendTextMessageAsync ( Chat, 137 | text.ToMarkdown ( ), ParseMode.Markdown, 138 | disableWebPagePreview, disableNotification, 139 | 0, 140 | new ForceReplyMarkup ( ), 141 | CancellationToken ) 142 | .ConfigureAwait ( false ); 143 | 144 | public async Task WaitForMessageAsync ( ) 145 | { 146 | ClearMessageQueue ( ); 147 | 148 | Message message; 149 | while ( !Messages.TryDequeue ( out message ) ) 150 | await Task.Delay ( 100, CancellationToken ).ConfigureAwait ( false ); 151 | 152 | return message; 153 | } 154 | 155 | protected void ClearMessageQueue ( ) 156 | { 157 | while ( Messages.TryDequeue ( out _ ) ) 158 | { 159 | // Just clearing the queue 160 | } 161 | } 162 | } 163 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc -------------------------------------------------------------------------------- /CryptoTickerBot.Core/CoreConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using CryptoTickerBot.Data.Configs; 6 | using CryptoTickerBot.Data.Domain; 7 | using Newtonsoft.Json; 8 | using Newtonsoft.Json.Converters; 9 | 10 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 11 | 12 | namespace CryptoTickerBot.Core 13 | { 14 | public class CoreConfig : IConfig 15 | { 16 | public string ConfigFileName { get; } = "Core"; 17 | public string ConfigFolderName { get; } = "Configs"; 18 | 19 | public string FixerApiKey { get; set; } 20 | 21 | [SuppressMessage ( "ReSharper", "StringLiteralTypo" )] 22 | public List ExchangeApiInfo { get; set; } = new List 23 | { 24 | new CryptoExchangeApiInfo 25 | { 26 | Id = CryptoExchangeId.Binance, 27 | Name = "Binance", 28 | Url = "https://www.binance.com/", 29 | TickerUrl = "wss://stream2.binance.com:9443/ws/!ticker@arr", 30 | BuyFees = 0.1m, 31 | SellFees = 0.1m, 32 | PollingRate = TimeSpan.FromMilliseconds ( 1000 ), 33 | CooldownPeriod = TimeSpan.FromSeconds ( 5 ), 34 | BaseSymbols = new List {"USDT", "TUSD", "USDC", "PAX", "BTC", "ETH", "BNB", "XRP"} 35 | }, 36 | new CryptoExchangeApiInfo 37 | { 38 | Id = CryptoExchangeId.Coinbase, 39 | Name = "Coinbase", 40 | Url = "https://www.coinbase.com/", 41 | TickerUrl = "wss://ws-feed.pro.coinbase.com", 42 | BuyFees = 0.3m, 43 | SellFees = 0.3m, 44 | BaseSymbols = new List {"BTC", "USD", "EUR", "GBP", "USDC"} 45 | }, 46 | new CryptoExchangeApiInfo 47 | { 48 | Id = CryptoExchangeId.CoinDelta, 49 | Name = "CoinDelta", 50 | Url = "https://coindelta.com/", 51 | TickerUrl = "https://api.coindelta.com/api/v1/public/getticker/", 52 | BuyFees = 0.3m, 53 | SellFees = 0.3m, 54 | PollingRate = TimeSpan.FromSeconds ( 60 ), 55 | BaseSymbols = new List {"USDT", "INR"} 56 | }, 57 | new CryptoExchangeApiInfo 58 | { 59 | Id = CryptoExchangeId.Koinex, 60 | Name = "Koinex", 61 | Url = "https://koinex.in/", 62 | TickerUrl = "https://koinex.in/api/ticker", 63 | BuyFees = 0.25m, 64 | SellFees = 0m, 65 | PollingRate = TimeSpan.FromSeconds ( 2 ), 66 | BaseSymbols = new List {"INR"} 67 | }, 68 | new CryptoExchangeApiInfo 69 | { 70 | Id = CryptoExchangeId.Kraken, 71 | Name = "Kraken", 72 | Url = "https://www.kraken.com/", 73 | TickerUrl = "https://api.kraken.com/", 74 | BuyFees = 0.26m, 75 | SellFees = 0.26m, 76 | CooldownPeriod = TimeSpan.FromSeconds ( 10 ), 77 | SymbolMappings = new Dictionary 78 | { 79 | ["ZUSD"] = "USD", 80 | ["ZEUR"] = "EUR", 81 | ["ZCAD"] = "CAD", 82 | ["ZGBP"] = "GBP", 83 | ["ZJPY"] = "JPY", 84 | ["XXBT"] = "BTC", 85 | ["XBT"] = "BTC", 86 | ["XETH"] = "ETH", 87 | ["XLTC"] = "LTC", 88 | ["XETC"] = "ETC", 89 | ["XICN"] = "ICN", 90 | ["XMLN"] = "MLN", 91 | ["XREP"] = "REP", 92 | ["XXDG"] = "XDG", 93 | ["XXLM"] = "XLM", 94 | ["XXMR"] = "XMR", 95 | ["XXRP"] = "XRP", 96 | ["XZEC"] = "ZEC" 97 | }, 98 | BaseSymbols = new List {"BTC", "ETH", "USD", "EUR", "GBP", "CAD", "JPY"} 99 | }, 100 | 101 | new CryptoExchangeApiInfo 102 | { 103 | Id = CryptoExchangeId.Bitstamp, 104 | Name = "Bitstamp", 105 | Url = "https://www.bitstamp.net/", 106 | TickerUrl = "de504dc5763aeef9ff52", 107 | PollingRate = TimeSpan.FromSeconds ( 1.5 ), 108 | BaseSymbols = new List {"BTC", "USD", "EUR"} 109 | }, 110 | 111 | new CryptoExchangeApiInfo 112 | { 113 | Id = CryptoExchangeId.Zebpay, 114 | Name = "Zebpay", 115 | Url = "https://www.zebpay.com/", 116 | TickerUrl = "https://www.zebapi.com/api/v1/market/ticker-new/", 117 | PollingRate = TimeSpan.FromSeconds ( 30 ), 118 | CooldownPeriod = TimeSpan.FromMinutes ( 5 ), 119 | BaseSymbols = new List {"INR"} 120 | } 121 | }; 122 | 123 | public bool TryValidate ( out IList exceptions ) 124 | { 125 | exceptions = new List ( ); 126 | 127 | if ( string.IsNullOrEmpty ( FixerApiKey ) ) 128 | exceptions.Add ( new ArgumentException ( "Fixer API Key missing", nameof ( FixerApiKey ) ) ); 129 | 130 | return !exceptions.Any ( ); 131 | } 132 | 133 | public CoreConfig RestoreDefaults ( ) 134 | { 135 | var result = new CoreConfig {FixerApiKey = FixerApiKey}; 136 | return result; 137 | } 138 | 139 | public class CryptoExchangeApiInfo 140 | { 141 | [JsonConverter ( typeof ( StringEnumConverter ) )] 142 | public CryptoExchangeId Id { get; set; } 143 | 144 | public string Name { get; set; } 145 | 146 | public string Url { get; set; } 147 | 148 | public string TickerUrl { get; set; } 149 | 150 | public Dictionary SymbolMappings { get; set; } = 151 | new Dictionary ( ); 152 | 153 | public List BaseSymbols { get; set; } = new List ( ); 154 | 155 | public TimeSpan PollingRate { get; set; } = TimeSpan.FromSeconds ( 5 ); 156 | public TimeSpan CooldownPeriod { get; set; } = TimeSpan.FromSeconds ( 60 ); 157 | 158 | public decimal BuyFees { get; set; } 159 | public decimal SellFees { get; set; } 160 | public Dictionary DepositFees { get; set; } = new Dictionary ( ); 161 | public Dictionary WithdrawalFees { get; set; } = new Dictionary ( ); 162 | public override string ToString ( ) => Name; 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /CryptoTickerBot.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.28407.52 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.Core", "CryptoTickerBot.Core\CryptoTickerBot.Core.csproj", "{636F1BD4-24ED-419F-89D6-C16E20239E41}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.CUI", "CryptoTickerBot.CUI\CryptoTickerBot.CUI.csproj", "{E2755C39-0712-4B2E-A223-A2FDD9F98010}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.GoogleSheets", "CryptoTickerBot.GoogleSheets\CryptoTickerBot.GoogleSheets.csproj", "{2FD3EEDE-73F0-41FA-9FD2-20DD4A2B6FA8}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.Runner", "CryptoTickerBot.Runner\CryptoTickerBot.Runner.csproj", "{0F5D956C-0152-49A3-BE4B-CB70E7AD5CD2}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.Data", "CryptoTickerBot.Data\CryptoTickerBot.Data.csproj", "{39AA8BD3-1407-468F-933A-47247D9DD2AF}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.Collections", "CryptoTickerBot.Collections\CryptoTickerBot.Collections.csproj", "{B4BC6565-F6FC-4822-8A2E-CA6B19F973BD}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NLog.Targets.Sentry", "NLog.Targets.Sentry\NLog.Targets.Sentry.csproj", "{068616D5-8D60-4930-854A-F6D638D043A1}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.Telegram", "CryptoTickerBot.Telegram\CryptoTickerBot.Telegram.csproj", "{E96B160D-64AB-4967-AF6B-9A2A9ED3CC75}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CryptoTickerBot.Arbitrage", "CryptoTickerBot.Arbitrage\CryptoTickerBot.Arbitrage.csproj", "{A2D33E0F-684E-40DC-AEDD-9622EA801020}" 23 | EndProject 24 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CryptoTickerBot.UnitTests", "CryptoTickerBot.UnitTests\CryptoTickerBot.UnitTests.csproj", "{364A7188-1A01-416F-B2A4-460C8ACE856A}" 25 | EndProject 26 | Global 27 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 28 | Debug|Any CPU = Debug|Any CPU 29 | Release|Any CPU = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 32 | {636F1BD4-24ED-419F-89D6-C16E20239E41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {636F1BD4-24ED-419F-89D6-C16E20239E41}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {636F1BD4-24ED-419F-89D6-C16E20239E41}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {636F1BD4-24ED-419F-89D6-C16E20239E41}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {E2755C39-0712-4B2E-A223-A2FDD9F98010}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {E2755C39-0712-4B2E-A223-A2FDD9F98010}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {E2755C39-0712-4B2E-A223-A2FDD9F98010}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {E2755C39-0712-4B2E-A223-A2FDD9F98010}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {2FD3EEDE-73F0-41FA-9FD2-20DD4A2B6FA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {2FD3EEDE-73F0-41FA-9FD2-20DD4A2B6FA8}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {2FD3EEDE-73F0-41FA-9FD2-20DD4A2B6FA8}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {2FD3EEDE-73F0-41FA-9FD2-20DD4A2B6FA8}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {0F5D956C-0152-49A3-BE4B-CB70E7AD5CD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {0F5D956C-0152-49A3-BE4B-CB70E7AD5CD2}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {0F5D956C-0152-49A3-BE4B-CB70E7AD5CD2}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {0F5D956C-0152-49A3-BE4B-CB70E7AD5CD2}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {39AA8BD3-1407-468F-933A-47247D9DD2AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {39AA8BD3-1407-468F-933A-47247D9DD2AF}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {39AA8BD3-1407-468F-933A-47247D9DD2AF}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {39AA8BD3-1407-468F-933A-47247D9DD2AF}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {B4BC6565-F6FC-4822-8A2E-CA6B19F973BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {B4BC6565-F6FC-4822-8A2E-CA6B19F973BD}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {B4BC6565-F6FC-4822-8A2E-CA6B19F973BD}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {B4BC6565-F6FC-4822-8A2E-CA6B19F973BD}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {068616D5-8D60-4930-854A-F6D638D043A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {068616D5-8D60-4930-854A-F6D638D043A1}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {068616D5-8D60-4930-854A-F6D638D043A1}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {068616D5-8D60-4930-854A-F6D638D043A1}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {E96B160D-64AB-4967-AF6B-9A2A9ED3CC75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {E96B160D-64AB-4967-AF6B-9A2A9ED3CC75}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {E96B160D-64AB-4967-AF6B-9A2A9ED3CC75}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {E96B160D-64AB-4967-AF6B-9A2A9ED3CC75}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {A2D33E0F-684E-40DC-AEDD-9622EA801020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {A2D33E0F-684E-40DC-AEDD-9622EA801020}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {A2D33E0F-684E-40DC-AEDD-9622EA801020}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {A2D33E0F-684E-40DC-AEDD-9622EA801020}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {364A7188-1A01-416F-B2A4-460C8ACE856A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {364A7188-1A01-416F-B2A4-460C8ACE856A}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {364A7188-1A01-416F-B2A4-460C8ACE856A}.Release|Any CPU.ActiveCfg = Release|Any CPU 71 | {364A7188-1A01-416F-B2A4-460C8ACE856A}.Release|Any CPU.Build.0 = Release|Any CPU 72 | EndGlobalSection 73 | GlobalSection(SolutionProperties) = preSolution 74 | HideSolutionNode = FALSE 75 | EndGlobalSection 76 | GlobalSection(ExtensibilityGlobals) = postSolution 77 | SolutionGuid = {593427D8-9D54-45FC-B073-00EE2D948E88} 78 | EndGlobalSection 79 | EndGlobal 80 | -------------------------------------------------------------------------------- /CryptoTickerBot.Core/Exchanges/BitstampExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.WebSockets; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CryptoTickerBot.Core.Abstractions; 7 | using CryptoTickerBot.Data.Converters; 8 | using CryptoTickerBot.Data.Domain; 9 | using Flurl.Http; 10 | using Newtonsoft.Json; 11 | using NLog; 12 | using PurePusher; 13 | 14 | namespace CryptoTickerBot.Core.Exchanges 15 | { 16 | public class BitstampExchange : CryptoExchangeBase 17 | { 18 | private const string TradingPairsEndpoint = "https://www.bitstamp.net/api/v2/trading-pairs-info/"; 19 | private const string TickerEndpoint = "https://www.bitstamp.net/api/v2/ticker/"; 20 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 21 | 22 | public PurePusherClient Client { get; private set; } 23 | public List Assets { get; private set; } 24 | 25 | public BitstampExchange ( ) : base ( CryptoExchangeId.Bitstamp ) 26 | { 27 | } 28 | 29 | protected override async Task FetchInitialDataAsync ( CancellationToken ct ) 30 | { 31 | Assets = await TradingPairsEndpoint 32 | .GetJsonAsync> ( ct ) 33 | .ConfigureAwait ( false ); 34 | 35 | foreach ( var asset in Assets ) 36 | { 37 | var datum = await $"{TickerEndpoint}{asset.UrlSymbol}/" 38 | .GetJsonAsync ( ct ) 39 | .ConfigureAwait ( false ); 40 | 41 | var symbol = CleanAndExtractSymbol ( asset.Name.Replace ( "/", "" ) ); 42 | ExchangeData[symbol] = 43 | new CryptoCoin ( symbol, datum.Bid, datum.Ask, datum.Last, datum.Timestamp ); 44 | Markets.AddOrUpdate ( ExchangeData[symbol] ); 45 | 46 | LastUpdate = DateTime.UtcNow; 47 | OnNext ( ExchangeData[symbol] ); 48 | OnChanged ( ExchangeData[symbol] ); 49 | 50 | await Task.Delay ( PollingRate, ct ).ConfigureAwait ( false ); 51 | } 52 | } 53 | 54 | protected override async Task GetExchangeDataAsync ( CancellationToken ct ) 55 | { 56 | var closed = false; 57 | 58 | Client = new PurePusherClient ( TickerUrl, new PurePusherClientOptions 59 | { 60 | DebugMode = false 61 | } ); 62 | 63 | Client.Connected += sender => 64 | { 65 | foreach ( var asset in Assets ) 66 | Client 67 | .Subscribe ( $"live_trades_{asset.UrlSymbol}" ) 68 | .Bind ( "trade", o => Update ( o, asset.Name ) ); 69 | }; 70 | Client.Error += ( sender, 71 | error ) => 72 | { 73 | Logger.Error ( error ); 74 | closed = true; 75 | }; 76 | 77 | if ( !Client.Connect ( ) ) 78 | return; 79 | 80 | while ( Client.Connection.State != WebSocketState.Closed ) 81 | { 82 | if ( UpTime > LastUpdateDuration && 83 | LastUpdateDuration > TimeSpan.FromHours ( 1 ) || 84 | closed ) 85 | { 86 | Client.Disconnect ( ); 87 | break; 88 | } 89 | 90 | await Task.Delay ( PollingRate, ct ).ConfigureAwait ( false ); 91 | } 92 | } 93 | 94 | protected override void Update ( dynamic data, 95 | string symbol ) 96 | { 97 | symbol = CleanAndExtractSymbol ( symbol ); 98 | 99 | if ( ExchangeData.TryGetValue ( symbol, out var old ) ) 100 | old = old.Clone ( ); 101 | else 102 | ExchangeData[symbol] = new CryptoCoin ( symbol ); 103 | 104 | DeserializeData ( data, symbol ); 105 | Markets.AddOrUpdate ( ExchangeData[symbol] ); 106 | 107 | LastUpdate = DateTime.UtcNow; 108 | OnNext ( ExchangeData[symbol] ); 109 | 110 | if ( !ExchangeData[symbol].HasSameValues ( old ) ) 111 | OnChanged ( ExchangeData[symbol] ); 112 | } 113 | 114 | protected override void DeserializeData ( dynamic data, 115 | string id ) 116 | { 117 | var price = (decimal) data["price"]; 118 | 119 | if ( data["type"] == 1 ) 120 | ExchangeData[id].HighestBid = price; 121 | else 122 | ExchangeData[id].LowestAsk = price; 123 | 124 | ExchangeData[id].Rate = price; 125 | ExchangeData[id].Time = DateTimeOffset 126 | .FromUnixTimeSeconds ( long.Parse ( data["timestamp"] ) ) 127 | .UtcDateTime; 128 | } 129 | 130 | public override Task StopReceivingAsync ( ) 131 | { 132 | Client?.Disconnect ( ); 133 | return base.StopReceivingAsync ( ); 134 | } 135 | 136 | #region JSON Classes 137 | 138 | public class TickerDatum 139 | { 140 | [JsonProperty ( "high" )] 141 | public decimal High { get; set; } 142 | 143 | [JsonProperty ( "last" )] 144 | public decimal Last { get; set; } 145 | 146 | [JsonProperty ( "timestamp" )] 147 | [JsonConverter ( typeof ( StringDateTimeConverter ) )] 148 | public DateTime Timestamp { get; set; } 149 | 150 | [JsonProperty ( "bid" )] 151 | public decimal Bid { get; set; } 152 | 153 | [JsonProperty ( "vwap" )] 154 | public decimal VolumeWeightedAvgPrice { get; set; } 155 | 156 | [JsonProperty ( "volume" )] 157 | public decimal Volume { get; set; } 158 | 159 | [JsonProperty ( "low" )] 160 | public decimal Low { get; set; } 161 | 162 | [JsonProperty ( "ask" )] 163 | public decimal Ask { get; set; } 164 | 165 | [JsonProperty ( "open" )] 166 | public decimal Open { get; set; } 167 | } 168 | 169 | public class BitstampAsset 170 | { 171 | [JsonProperty ( "base_decimals" )] 172 | public long BaseDecimals { get; set; } 173 | 174 | [JsonProperty ( "minimum_order" )] 175 | public string MinimumOrder { get; set; } 176 | 177 | [JsonProperty ( "name" )] 178 | public string Name { get; set; } 179 | 180 | [JsonProperty ( "counter_decimals" )] 181 | public long CounterDecimals { get; set; } 182 | 183 | [JsonProperty ( "trading" )] 184 | public string Trading { get; set; } 185 | 186 | [JsonProperty ( "url_symbol" )] 187 | public string UrlSymbol { get; set; } 188 | 189 | [JsonProperty ( "description" )] 190 | public string Description { get; set; } 191 | 192 | public override string ToString ( ) => Name; 193 | } 194 | 195 | #endregion 196 | } 197 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/TelegramBotBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using CryptoTickerBot.Data.Extensions; 6 | using CryptoTickerBot.Telegram.Extensions; 7 | using CryptoTickerBot.Telegram.Interfaces; 8 | using CryptoTickerBot.Telegram.Menus; 9 | using JetBrains.Annotations; 10 | using NLog; 11 | using Polly; 12 | using Telegram.Bot; 13 | using Telegram.Bot.Exceptions; 14 | using Telegram.Bot.Types; 15 | using Telegram.Bot.Types.Enums; 16 | 17 | namespace CryptoTickerBot.Telegram 18 | { 19 | public delegate Task CommandHandlerDelegate ( Message message ); 20 | 21 | public abstract class TelegramBotBase : ITelegramBot 22 | { 23 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 24 | 25 | public TelegramBotClient Client { get; } 26 | public User Self { get; protected set; } 27 | public TelegramBotConfig Config { get; } 28 | public Policy Policy { get; } 29 | public DateTime StartTime { get; protected set; } 30 | public abstract CancellationToken CancellationToken { get; } 31 | private protected readonly TelegramMenuManager MenuManager; 32 | 33 | protected ImmutableDictionary CommandHandlers; 34 | 35 | protected TelegramBotBase ( TelegramBotConfig config ) 36 | { 37 | Config = config; 38 | 39 | Client = new TelegramBotClient ( Config.BotToken ); 40 | Client.OnMessage += ( _, 41 | args ) => OnMessageInternal ( args.Message ); 42 | Client.OnInlineQuery += ( _, 43 | args ) => OnInlineQuery ( args.InlineQuery ); 44 | Client.OnCallbackQuery += ( _, 45 | args ) => OnCallbackQuery ( args.CallbackQuery ); 46 | Client.OnReceiveError += ( _, 47 | args ) => OnError ( args.ApiRequestException ); 48 | 49 | Policy = Policy 50 | .Handle ( ) 51 | .WaitAndRetryAsync ( 52 | Config.RetryLimit, 53 | i => Config.RetryInterval, 54 | ( exception, 55 | retryCount, 56 | span ) => 57 | { 58 | Logger.Error ( exception ); 59 | return Task.CompletedTask; 60 | } 61 | ); 62 | 63 | CommandHandlers = ImmutableDictionary.Empty; 64 | MenuManager = new TelegramMenuManager ( ); 65 | } 66 | 67 | public virtual async Task StartAsync ( ) 68 | { 69 | Logger.Info ( "Starting Telegram Bot" ); 70 | try 71 | { 72 | await Policy 73 | .ExecuteAsync ( async ( ) => 74 | { 75 | StartTime = DateTime.UtcNow; 76 | Self = await Client.GetMeAsync ( CancellationToken ).ConfigureAwait ( false ); 77 | Logger.Info ( $"Hello! My name is {Self.FirstName}" ); 78 | 79 | Client.StartReceiving ( cancellationToken: CancellationToken ); 80 | } ).ConfigureAwait ( false ); 81 | 82 | await OnStartAsync ( ).ConfigureAwait ( false ); 83 | } 84 | catch ( Exception e ) 85 | { 86 | Logger.Error ( e ); 87 | throw; 88 | } 89 | } 90 | 91 | public virtual void Stop ( ) 92 | { 93 | Client.StopReceiving ( ); 94 | } 95 | 96 | protected void AddCommandHandler ( string command, 97 | string usage, 98 | CommandHandlerDelegate handler ) 99 | { 100 | CommandHandlers = CommandHandlers.Add ( command, ( usage, handler ) ); 101 | } 102 | 103 | protected virtual void OnError ( ApiRequestException exception ) 104 | { 105 | Logger.Error ( 106 | exception, 107 | $"Error Code: {exception.ErrorCode}" 108 | ); 109 | } 110 | 111 | protected virtual async void OnCallbackQuery ( CallbackQuery query ) 112 | { 113 | var user = query.From; 114 | var chat = query.Message.Chat; 115 | 116 | try 117 | { 118 | if ( query.Message.Date < StartTime ) 119 | { 120 | await Client 121 | .AnswerCallbackQueryAsync ( query.Id, 122 | "Menu expired!", 123 | cancellationToken: CancellationToken ) 124 | .ConfigureAwait ( false ); 125 | await Client.DeleteMessageAsync ( query.Message.Chat, query.Message.MessageId, CancellationToken ) 126 | .ConfigureAwait ( false ); 127 | } 128 | 129 | if ( !MenuManager.TryGetMenu ( user, chat.Id, out var menu ) || 130 | menu.LastMessage.MessageId != query.Message.MessageId ) 131 | { 132 | await Client 133 | .AnswerCallbackQueryAsync ( query.Id, 134 | "Get your own menu!", 135 | cancellationToken: CancellationToken ) 136 | .ConfigureAwait ( false ); 137 | return; 138 | } 139 | 140 | await menu.HandleQueryAsync ( query ).ConfigureAwait ( false ); 141 | } 142 | catch ( Exception e ) 143 | { 144 | Logger.Error ( e ); 145 | } 146 | } 147 | 148 | protected abstract void OnInlineQuery ( InlineQuery query ); 149 | 150 | protected virtual async void OnMessageInternal ( Message message ) 151 | { 152 | try 153 | { 154 | if ( message is null || message.Type != MessageType.Text ) 155 | return; 156 | 157 | var (command, parameters) = message.ExtractCommand ( Self ); 158 | Logger.Debug ( $"Received from {message.From} : {command} {parameters.Join ( ", " )}" ); 159 | 160 | OnMessage ( message ); 161 | 162 | if ( CommandHandlers.TryGetValue ( command, out var tuple ) ) 163 | { 164 | await tuple.handler ( message ).ConfigureAwait ( false ); 165 | return; 166 | } 167 | 168 | if ( MenuManager.TryGetMenu ( message.From, message.Chat.Id, out var menu ) ) 169 | await menu.HandleMessageAsync ( message ).ConfigureAwait ( false ); 170 | } 171 | catch ( Exception exception ) 172 | { 173 | Logger.Error ( exception ); 174 | } 175 | } 176 | 177 | protected virtual void OnMessage ( [UsedImplicitly] Message message ) 178 | { 179 | } 180 | 181 | protected abstract Task OnStartAsync ( ); 182 | } 183 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Collections/Persistent/Base/PersistentCollection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Reactive.Linq; 6 | using Newtonsoft.Json; 7 | using Polly; 8 | 9 | // ReSharper disable StaticMemberInGenericType 10 | 11 | namespace CryptoTickerBot.Collections.Persistent.Base 12 | { 13 | public abstract class PersistentCollection : 14 | IPersistentCollection 15 | where TCollection : ICollection, new ( ) 16 | { 17 | public static JsonSerializerSettings DefaultSerializerSettings { get; } = new JsonSerializerSettings 18 | { 19 | NullValueHandling = NullValueHandling.Ignore, 20 | DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, 21 | Formatting = Formatting.Indented, 22 | ReferenceLoopHandling = ReferenceLoopHandling.Ignore, 23 | ObjectCreationHandling = ObjectCreationHandling.Replace 24 | }; 25 | 26 | public static TimeSpan DefaultFlushInterval => TimeSpan.FromSeconds ( 1 ); 27 | 28 | protected TCollection Collection { get; set; } 29 | 30 | public int Count => Collection.Count; 31 | 32 | public bool IsReadOnly => Collection.IsReadOnly; 33 | public string FileName { get; } 34 | 35 | public JsonSerializerSettings SerializerSettings { get; set; } 36 | public TimeSpan FlushInterval { get; } 37 | 38 | public int MaxRetryAttempts { get; set; } = 5; 39 | public TimeSpan RetryInterval { get; set; } = TimeSpan.FromSeconds ( 2 ); 40 | 41 | protected readonly object FileLock = new object ( ); 42 | 43 | private volatile bool saveRequested; 44 | private IDisposable disposable; 45 | 46 | protected PersistentCollection ( string fileName ) : 47 | this ( fileName, DefaultSerializerSettings, DefaultFlushInterval ) 48 | { 49 | } 50 | 51 | protected PersistentCollection ( string fileName, 52 | JsonSerializerSettings serializerSettings ) : 53 | this ( fileName, serializerSettings, DefaultFlushInterval ) 54 | { 55 | } 56 | 57 | protected PersistentCollection ( string fileName, 58 | JsonSerializerSettings serializerSettings, 59 | TimeSpan flushInterval ) 60 | { 61 | FileName = fileName; 62 | SerializerSettings = serializerSettings; 63 | FlushInterval = flushInterval; 64 | 65 | OpenCollections.Add ( this ); 66 | 67 | if ( !Load ( ) ) 68 | { 69 | Collection = new TCollection ( ); 70 | Save ( ); 71 | } 72 | 73 | disposable = Observable.Interval ( FlushInterval ).Subscribe ( l => ForceSave ( ) ); 74 | } 75 | 76 | public event SaveDelegate OnSave; 77 | public event LoadDelegate OnLoad; 78 | public event ErrorDelegate OnError; 79 | 80 | public virtual IEnumerator GetEnumerator ( ) => Collection.GetEnumerator ( ); 81 | 82 | IEnumerator IEnumerable.GetEnumerator ( ) => ( (IEnumerable) Collection ).GetEnumerator ( ); 83 | 84 | public virtual void Add ( T item ) 85 | { 86 | Collection.Add ( item ); 87 | Save ( ); 88 | } 89 | 90 | public virtual void Clear ( ) 91 | { 92 | Collection.Clear ( ); 93 | Save ( ); 94 | } 95 | 96 | public virtual bool Contains ( T item ) => 97 | Collection.Contains ( item ); 98 | 99 | public virtual void CopyTo ( T[] array, 100 | int arrayIndex ) 101 | { 102 | Collection.CopyTo ( array, arrayIndex ); 103 | } 104 | 105 | public virtual bool Remove ( T item ) 106 | { 107 | var result = Collection.Remove ( item ); 108 | Save ( ); 109 | 110 | return result; 111 | } 112 | 113 | public void ForceSave ( ) 114 | { 115 | if ( !saveRequested ) 116 | return; 117 | 118 | lock ( FileLock ) 119 | { 120 | try 121 | { 122 | InternalForceSave ( ); 123 | 124 | OnSave?.Invoke ( this ); 125 | } 126 | catch ( Exception e ) 127 | { 128 | disposable.Dispose ( ); 129 | disposable = null; 130 | OnError?.Invoke ( this, e ); 131 | } 132 | } 133 | 134 | saveRequested = false; 135 | } 136 | 137 | public void Save ( ) 138 | { 139 | saveRequested = true; 140 | } 141 | 142 | public bool Load ( ) 143 | { 144 | if ( !File.Exists ( FileName ) ) 145 | return false; 146 | 147 | lock ( FileLock ) 148 | { 149 | Collection = JsonConvert.DeserializeObject ( File.ReadAllText ( FileName ), 150 | SerializerSettings ); 151 | OnLoad?.Invoke ( this ); 152 | } 153 | 154 | return true; 155 | } 156 | 157 | public void Dispose ( ) 158 | { 159 | ForceSave ( ); 160 | OpenCollections.Remove ( FileName ); 161 | disposable?.Dispose ( ); 162 | } 163 | 164 | protected static TType GetOpenCollection ( string fileName ) 165 | where TType : PersistentCollection 166 | { 167 | if ( OpenCollections.TryOpen ( fileName, out var collection ) ) 168 | { 169 | if ( collection is TType result ) 170 | return result; 171 | throw new InvalidCastException ( 172 | $"{fileName} already has an open collection of type {collection.GetType ( )}" ); 173 | } 174 | 175 | return null; 176 | } 177 | 178 | protected void OneTimeSave ( string json ) 179 | { 180 | var fileInfo = new FileInfo ( FileName ); 181 | fileInfo.Directory?.Create ( ); 182 | File.WriteAllText ( FileName, json ); 183 | } 184 | 185 | protected void InternalForceSave ( ) 186 | { 187 | var json = JsonConvert.SerializeObject ( Collection ); 188 | 189 | Policy 190 | .Handle ( ) 191 | .WaitAndRetry ( MaxRetryAttempts, 192 | i => RetryInterval, 193 | ( exception, 194 | span ) => 195 | OnError?.Invoke ( this, exception ) ) 196 | .Execute ( ( ) => OneTimeSave ( json ) ); 197 | } 198 | 199 | public virtual void AddWithoutSaving ( T item ) 200 | { 201 | Collection.Add ( item ); 202 | } 203 | 204 | public virtual void AddRange ( IEnumerable collection ) 205 | { 206 | foreach ( var item in collection ) 207 | Collection.Add ( item ); 208 | 209 | Save ( ); 210 | } 211 | } 212 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Menus/Abstractions/PageBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CryptoTickerBot.Core.Interfaces; 7 | using CryptoTickerBot.Data.Domain; 8 | using CryptoTickerBot.Telegram.Extensions; 9 | using CryptoTickerBot.Telegram.Interfaces; 10 | using CryptoTickerBot.Telegram.Menus.Pages; 11 | using MoreLinq; 12 | using Nito.AsyncEx; 13 | using NLog; 14 | using Telegram.Bot; 15 | using Telegram.Bot.Types; 16 | using Telegram.Bot.Types.Enums; 17 | using Telegram.Bot.Types.ReplyMarkups; 18 | 19 | namespace CryptoTickerBot.Telegram.Menus.Abstractions 20 | { 21 | public delegate Task QueryHandlerDelegate ( CallbackQuery query ); 22 | 23 | internal abstract class PageBase : IPage 24 | { 25 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 26 | 27 | public string Title { get; protected set; } 28 | public IEnumerable> Labels { get; protected set; } 29 | public InlineKeyboardMarkup Keyboard { get; protected set; } 30 | public IPage PreviousPage { get; protected set; } 31 | public IMenu Menu { get; } 32 | 33 | protected User User => Menu.User; 34 | protected Chat Chat => Menu.Chat; 35 | protected IBot Ctb => Menu.TelegramBot.Ctb; 36 | protected TelegramBotClient Client => Menu.TelegramBot.Client; 37 | protected TelegramBot TelegramBot => Menu.TelegramBot; 38 | protected CancellationToken CancellationToken => Menu.TelegramBot.CancellationToken; 39 | 40 | protected static QueryHandlerDelegate DummyHandler => query => Task.CompletedTask; 41 | 42 | protected QueryHandlerDelegate BackHandler => 43 | async query => 44 | await GoBackAsync ( ).ConfigureAwait ( false ); 45 | 46 | protected QueryHandlerDelegate ExitHandler => 47 | async query => 48 | await Menu.DeleteAsync ( ).ConfigureAwait ( false ); 49 | 50 | protected readonly Dictionary Handlers = 51 | new Dictionary ( ); 52 | 53 | protected readonly Dictionary ButtonPopups = 54 | new Dictionary ( ); 55 | 56 | protected readonly AsyncAutoResetEvent ButtonPressResetEvent = new AsyncAutoResetEvent ( false ); 57 | 58 | protected PageBase ( string title, 59 | IMenu menu, 60 | IEnumerable> labels = null, 61 | IPage previousPage = null ) 62 | { 63 | Title = menu.Chat.Type == ChatType.Private ? title : $"{menu.User}:\n{title}"; 64 | Menu = menu; 65 | 66 | Labels = labels?.Select ( x => x.ToList ( ) ).ToList ( ); 67 | PreviousPage = previousPage; 68 | 69 | BuildKeyboard ( ); 70 | } 71 | 72 | public virtual Task HandleMessageAsync ( Message message ) => 73 | Task.CompletedTask; 74 | 75 | public async Task HandleQueryAsync ( CallbackQuery query ) 76 | { 77 | ButtonPopups.TryGetValue ( query.Data, out var popupMessage ); 78 | await Client 79 | .AnswerCallbackQueryAsync ( query.Id, popupMessage, cancellationToken: CancellationToken ) 80 | .ConfigureAwait ( false ); 81 | 82 | if ( !Handlers.TryGetValue ( query.Data, out var handler ) ) 83 | return; 84 | 85 | try 86 | { 87 | await handler ( query ).ConfigureAwait ( false ); 88 | ButtonPressResetEvent.Set ( ); 89 | } 90 | catch ( Exception e ) 91 | { 92 | Logger.Error ( e ); 93 | } 94 | } 95 | 96 | public async Task WaitForButtonPressAsync ( ) => 97 | await ButtonPressResetEvent.WaitAsync ( CancellationToken ).ConfigureAwait ( false ); 98 | 99 | protected async Task GoBackAsync ( ) => 100 | await Menu.SwitchPageAsync ( PreviousPage ).ConfigureAwait ( false ); 101 | 102 | protected async Task.SelectionResult> RunSelectionPageAsync ( 103 | IEnumerable> rows, 104 | string title = "Choose", 105 | Func toStringConverter = null 106 | ) 107 | { 108 | var page = new SelectionPage ( Menu, rows, this, title, toStringConverter ); 109 | var result = await page.DisplayAndWaitAsync ( ).ConfigureAwait ( false ); 110 | 111 | if ( !result ) 112 | await Menu.SwitchPageAsync ( this ).ConfigureAwait ( false ); 113 | return result; 114 | } 115 | 116 | protected async Task.SelectionResult> RunExchangeSelectionPageAsync ( ) => 117 | await RunSelectionPageAsync ( Ctb.Exchanges.Keys.Batch ( 2 ), 118 | "Choose an exchange:" ) 119 | .ConfigureAwait ( false ); 120 | 121 | protected void AddHandler ( string label, 122 | QueryHandlerDelegate handler, 123 | string popup = null ) 124 | { 125 | Handlers[label] = handler; 126 | ButtonPopups[label] = popup; 127 | } 128 | 129 | protected async Task RedrawAsync ( ) 130 | { 131 | await Menu.SwitchPageAsync ( this ).ConfigureAwait ( false ); 132 | } 133 | 134 | protected void AddWideLabel ( string label ) 135 | { 136 | var labels = new List> ( Labels.Select ( x => x.ToList ( ) ) ) {new List {label}}; 137 | Labels = labels; 138 | } 139 | 140 | protected void BuildKeyboard ( ) 141 | { 142 | Keyboard = Labels?.ToInlineKeyboardMarkup ( ); 143 | } 144 | 145 | protected async Task ReadPercentageAsync ( ) 146 | { 147 | var message = await Menu.WaitForMessageAsync ( ).ConfigureAwait ( false ); 148 | 149 | if ( decimal.TryParse ( message.Text.Trim ( '%' ), out var percentage ) ) 150 | return percentage / 100m; 151 | 152 | await Menu.SendTextBlockAsync ( $"{message.Text} is not a valid percentage value" ) 153 | .ConfigureAwait ( false ); 154 | 155 | return null; 156 | } 157 | 158 | protected async Task> ReadSymbolsAsync ( ) 159 | { 160 | await Menu.RequestReplyAsync ( "Enter the symbols" ).ConfigureAwait ( false ); 161 | 162 | var message = await Menu.WaitForMessageAsync ( ).ConfigureAwait ( false ); 163 | 164 | return message.Text 165 | .Split ( " ,".ToCharArray ( ), StringSplitOptions.RemoveEmptyEntries ) 166 | .ToList ( ); 167 | } 168 | 169 | public override string ToString ( ) => $"{Menu.User} {Title}"; 170 | } 171 | } -------------------------------------------------------------------------------- /CryptoTickerBot.Telegram/Subscriptions/TelegramPercentChangeSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CryptoTickerBot.Core.Subscriptions; 7 | using CryptoTickerBot.Data.Domain; 8 | using CryptoTickerBot.Data.Extensions; 9 | using CryptoTickerBot.Data.Helpers; 10 | using CryptoTickerBot.Telegram.Extensions; 11 | using Humanizer; 12 | using Humanizer.Localisation; 13 | using Newtonsoft.Json; 14 | using NLog; 15 | using Telegram.Bot.Types; 16 | using Telegram.Bot.Types.Enums; 17 | 18 | namespace CryptoTickerBot.Telegram.Subscriptions 19 | { 20 | public class TelegramPercentChangeSubscription : 21 | PercentChangeSubscription, 22 | IEquatable 23 | { 24 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger ( ); 25 | 26 | public ChatId ChatId { get; } 27 | public User User { get; } 28 | 29 | public bool IsSilent { get; set; } 30 | 31 | [JsonIgnore] 32 | public TelegramBot TelegramBot { get; private set; } 33 | 34 | private CancellationToken CancellationToken => TelegramBot.CancellationToken; 35 | 36 | public Chat Chat { get; private set; } 37 | 38 | public TelegramPercentChangeSubscription ( ChatId chatId, 39 | User user, 40 | CryptoExchangeId exchangeId, 41 | decimal threshold, 42 | bool isSilent, 43 | IEnumerable symbols ) : 44 | base ( exchangeId, threshold, symbols ) 45 | { 46 | ChatId = chatId; 47 | User = user; 48 | Threshold = threshold; 49 | IsSilent = isSilent; 50 | } 51 | 52 | public override string ToString ( ) => 53 | $"{nameof ( User )}: {User}," + 54 | $"{( Chat.Type == ChatType.Private ? "" : $" {Chat.Title}," )}" + 55 | $" {nameof ( Exchange )}: {ExchangeId}," + 56 | $" {nameof ( Threshold )}: {Threshold:P}," + 57 | $" {nameof ( IsSilent )}: {IsSilent}," + 58 | $" {nameof ( Symbols )}: {Symbols.Join ( ", " )}"; 59 | 60 | public string Summary ( ) => 61 | $"{nameof ( User )}: {User}\n" + 62 | $"{nameof ( Exchange )}: {ExchangeId}\n" + 63 | $"{nameof ( Threshold )}: {Threshold:P}\n" + 64 | $"Silent: {IsSilent}\n" + 65 | $"{nameof ( Symbols )}: {Symbols.Humanize ( )}"; 66 | 67 | public async Task StartAsync ( TelegramBot telegramBot, 68 | bool isBeingCreated = false ) 69 | { 70 | TelegramBot = telegramBot; 71 | Chat = await TelegramBot.Client 72 | .GetChatAsync ( ChatId, CancellationToken ) 73 | .ConfigureAwait ( false ); 74 | 75 | if ( !TelegramBot.Ctb.TryGetExchange ( ExchangeId, out var exchange ) ) 76 | return; 77 | 78 | Start ( exchange ); 79 | 80 | if ( isBeingCreated ) 81 | await TelegramBot.Client.SendTextBlockAsync ( ChatId, 82 | $"Created subscription:\n{Summary ( )}", 83 | disableNotification: IsSilent, 84 | cancellationToken: CancellationToken ) 85 | .ConfigureAwait ( false ); 86 | } 87 | 88 | public async Task ResumeAsync ( TelegramBot telegramBot ) => 89 | await StartAsync ( telegramBot ).ConfigureAwait ( false ); 90 | 91 | public bool IsSimilarTo ( TelegramPercentChangeSubscription subscription ) => 92 | User.Equals ( subscription.User ) && 93 | ChatId.Identifier == subscription.ChatId.Identifier && 94 | ExchangeId == subscription.ExchangeId; 95 | 96 | public async Task MergeWithAsync ( TelegramPercentChangeSubscription subscription ) 97 | { 98 | IsSilent = subscription.IsSilent; 99 | AddSymbols ( subscription.Symbols ); 100 | 101 | await TelegramBot.Client 102 | .SendTextBlockAsync ( ChatId, 103 | $"Merged with subscription:\n{Summary ( )}", 104 | disableNotification: IsSilent, 105 | cancellationToken: CancellationToken ) 106 | .ConfigureAwait ( false ); 107 | } 108 | 109 | protected override async Task OnTriggerAsync ( CryptoCoin old, 110 | CryptoCoin current ) 111 | { 112 | Logger.Debug ( 113 | $"{Id} Invoked subscription for {User} @ {current.Rate:N} {current.Symbol} {Exchange.Name}" 114 | ); 115 | 116 | var change = PriceChange.Difference ( current, old ); 117 | var builder = new StringBuilder ( ); 118 | builder 119 | .AppendLine ( $"{Exchange.Name,-14} {current.Symbol}" ) 120 | .AppendLine ( $"Current Price: {current.Rate:N}" ) 121 | .AppendLine ( $"Change: {change.Value:N}" ) 122 | .AppendLine ( $"Change %: {change.Percentage:P}" ) 123 | .AppendLine ( $"in {change.TimeDiff.Humanize ( 3, minUnit: TimeUnit.Second )}" ); 124 | 125 | await TelegramBot.Client 126 | .SendTextBlockAsync ( ChatId, 127 | builder.ToString ( ), 128 | disableNotification: IsSilent, 129 | cancellationToken: CancellationToken ) 130 | .ConfigureAwait ( false ); 131 | } 132 | 133 | #region Equality Members 134 | 135 | public bool Equals ( TelegramPercentChangeSubscription other ) 136 | { 137 | if ( other is null ) return false; 138 | return ReferenceEquals ( this, other ) || Id.Equals ( other.Id ); 139 | } 140 | 141 | public override bool Equals ( object obj ) 142 | { 143 | if ( obj is null ) return false; 144 | if ( ReferenceEquals ( this, obj ) ) return true; 145 | return obj.GetType ( ) == GetType ( ) && Equals ( (TelegramPercentChangeSubscription) obj ); 146 | } 147 | 148 | public override int GetHashCode ( ) => Id.GetHashCode ( ); 149 | 150 | public static bool operator == ( TelegramPercentChangeSubscription left, 151 | TelegramPercentChangeSubscription right ) => Equals ( left, right ); 152 | 153 | public static bool operator != ( TelegramPercentChangeSubscription left, 154 | TelegramPercentChangeSubscription right ) => !Equals ( left, right ); 155 | 156 | #endregion 157 | } 158 | } --------------------------------------------------------------------------------