From 3d1f9b8b75d0fc8e5e5a138c89dafdec456b7b14 Mon Sep 17 00:00:00 2001 From: Kwoth Date: Sun, 6 Feb 2022 17:43:19 +0100 Subject: [PATCH] Added a .stock command --- src/NadekoBot/.editorconfig | 4 +- src/NadekoBot/Common/Creds.cs | 4 + .../Modules/Searches/Crypto/CryptoCommands.cs | 104 ++++++++++++++++++ .../Crypto/_Common/IStockDataService.cs | 7 ++ .../_Common/Polygon/FinnHubSearchResponse.cs | 13 +++ .../_Common/Polygon/FinnHubSearchResult.cs | 19 ++++ .../_Common/Polygon/PolygonApiClient.cs | 55 +++++++++ .../_Common/Polygon/PolygonTickerData.cs | 43 ++++++++ .../_Common/Polygon/PolygonTickerResponse.cs | 13 +++ .../Crypto/_Common/PolygonStockDataService.cs | 26 +++++ .../Searches/Crypto/_Common/StockData.cs | 15 +++ .../_Common/YahooFinanceStockDataService.cs | 83 ++++++++++++++ src/NadekoBot/NadekoBot.csproj | 87 ++++++++------- src/NadekoBot/_Extensions/ArrayExtensions.cs | 13 +++ .../data/strings/commands/commands.en-US.yml | 5 + .../strings/responses/responses.en-US.json | 1 + 16 files changed, 446 insertions(+), 46 deletions(-) create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/IStockDataService.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResponse.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResult.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonApiClient.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerData.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerResponse.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/PolygonStockDataService.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/StockData.cs create mode 100644 src/NadekoBot/Modules/Searches/Crypto/_Common/YahooFinanceStockDataService.cs diff --git a/src/NadekoBot/.editorconfig b/src/NadekoBot/.editorconfig index a77f398ec..041e023c1 100644 --- a/src/NadekoBot/.editorconfig +++ b/src/NadekoBot/.editorconfig @@ -81,8 +81,8 @@ csharp_style_expression_bodied_constructors = when_on_single_line:suggestion csharp_style_expression_bodied_indexers = true:suggestion csharp_style_expression_bodied_lambdas = true:suggestion csharp_style_expression_bodied_local_functions = true:suggestion -csharp_style_expression_bodied_methods = when_on_single_line:warning -csharp_style_expression_bodied_operators = when_on_single_line:warning +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion csharp_style_expression_bodied_properties = true:suggestion # Pattern matching preferences diff --git a/src/NadekoBot/Common/Creds.cs b/src/NadekoBot/Common/Creds.cs index 29867575a..d004c0ed0 100644 --- a/src/NadekoBot/Common/Creds.cs +++ b/src/NadekoBot/Common/Creds.cs @@ -63,6 +63,10 @@ Used only for .time command")] [Comment(@"https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use. Used for cryptocurrency related commands.")] public string CoinmarketcapApiKey { get; set; } + +// [Comment(@"https://polygon.io/dashboard/api-keys api key. Free plan allows for 5 queries per minute. +// Used for stocks related commands.")] +// public string PolygonIoApiKey { get; set; } [Comment(@"Api key used for Osu related commands. Obtain this key at https://osu.ppy.sh/p/api")] public string OsuApiKey { get; set; } diff --git a/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs b/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs index 5c89eb9bd..fe827b73b 100644 --- a/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs +++ b/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs @@ -8,6 +8,110 @@ public partial class Searches { public partial class CryptoCommands : NadekoSubmodule { + private readonly IStockDataService _stocksService; + + public CryptoCommands(IStockDataService stocksService) + { + _stocksService = stocksService; + } + + [Cmd] + public async partial Task Stock([Leftover]string query) + { + if (!query.IsAlphaNumeric()) + return; + + using var typing = ctx.Channel.EnterTypingState(); + + var stocks = await _stocksService.GetStockDataAsync(query); + + if (stocks.Count == 0) + { + var symbols = await _stocksService.SearchSymbolAsync(query); + + if (symbols.Count == 0) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + var symbol = symbols.First(); + var promptEmbed = _eb.Create() + .WithDescription(symbol.Description) + .WithTitle(GetText(strs.did_you_mean(symbol.Symbol))); + + if (!await PromptUserConfirmAsync(promptEmbed)) + return; + + query = symbol.Symbol; + stocks = await _stocksService.GetStockDataAsync(query); + + if (stocks.Count == 0) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + } + + // try to find a ticker match + var stock = stocks.Count == 1 + ? stocks.FirstOrDefault() + : stocks.FirstOrDefault(x => x.Ticker == query.ToUpperInvariant()); + + if (stock is null) + { + var ebImprecise = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.stocks_multiple_results)) + .WithDescription(stocks.Take(20) + .Select(s => $"{Format.Code(s.Ticker)} {s.Name.TrimTo(50)}") + .Join('\n')); + + await ctx.Channel.EmbedAsync(ebImprecise); + return; + } + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sign = stock.Price >= stock.Close + ? "\\🔼" + : "\\🔻"; + + var change = (stock.Price - stock.Close).ToString("N2", Culture); + var changePercent = (1 - (stock.Close / stock.Price)).ToString("P1", Culture); + + var sign50 = stock.Change50d >= 0 + ? "\\🔼" + : "\\🔻"; + + var change50 = (stock.Change50d / 100).ToString("P1", Culture); + + var sign200 = stock.Change200d >= 0 + ? "\\🔼" + : "\\🔻"; + + var change200 = (stock.Change200d / 100).ToString("P1", Culture); + + var price = stock.Price.ToString("C2", localCulture); + + var eb = _eb.Create() + .WithOkColor() + .WithAuthor(stock.Ticker) + .WithTitle(stock.Name) + .AddField(GetText(strs.price), $"{sign} **{price}**", true) + .AddField(GetText(strs.market_cap), stock.MarketCap.ToString("C0", localCulture), true) + .AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true) + .AddField("Change", $"{change} ({changePercent})", true) + .AddField("Change 50d", $"{sign50}{change50}", true) + .AddField("Change 200d", $"{sign200}{change200}", true) + .WithFooter(stock.Exchange); + + await ctx.Channel.EmbedAsync(eb); + } + + [Cmd] public async partial Task Crypto(string name) { diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/IStockDataService.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/IStockDataService.cs new file mode 100644 index 000000000..6ecb376c4 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/IStockDataService.cs @@ -0,0 +1,7 @@ +namespace NadekoBot.Modules.Searches; + +public interface IStockDataService +{ + public Task> GetStockDataAsync(string query); + Task> SearchSymbolAsync(string query); +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResponse.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResponse.cs new file mode 100644 index 000000000..97efb09e6 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResponse.cs @@ -0,0 +1,13 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace NadekoBot.Modules.Searches; + +public class FinnHubSearchResponse +{ + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("result")] + public List Result { get; set; } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResult.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResult.cs new file mode 100644 index 000000000..08eff7506 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/FinnHubSearchResult.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace NadekoBot.Modules.Searches; + +public class FinnHubSearchResult +{ + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("displaySymbol")] + public string DisplaySymbol { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonApiClient.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonApiClient.cs new file mode 100644 index 000000000..1ba955120 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonApiClient.cs @@ -0,0 +1,55 @@ +using System.Net.Http.Json; + +namespace NadekoBot.Modules.Searches; + +public sealed class PolygonApiClient : IDisposable +{ + private const string BASE_URL = "https://api.polygon.io/v3"; + + private readonly HttpClient _httpClient; + private readonly string _apiKey; + + public PolygonApiClient(HttpClient httpClient, string apiKey) + { + _httpClient = httpClient; + _apiKey = apiKey; + } + + public async Task> TickersAsync(string? ticker = null, string? query = null) + { + if (string.IsNullOrWhiteSpace(query)) + query = null; + + if(query is not null) + query = Uri.EscapeDataString(query); + + var requestString = $"{BASE_URL}/reference/tickers" + + "?type=CS" + + "&active=true" + + "&order=asc" + + "&limit=1000" + + $"&apiKey={_apiKey}"; + + if (!string.IsNullOrWhiteSpace(ticker)) + requestString += $"&ticker={ticker}"; + + if (!string.IsNullOrWhiteSpace(query)) + requestString += $"&search={query}"; + + + var response = await _httpClient.GetFromJsonAsync(requestString); + + if (response is null) + return Array.Empty(); + + return response.Results; + } + + // public async Task TickerDetailsV3Async(string ticker) + // { + // return new(); + // } + + public void Dispose() + => _httpClient.Dispose(); +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerData.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerData.cs new file mode 100644 index 000000000..030f4082e --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerData.cs @@ -0,0 +1,43 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace NadekoBot.Modules.Searches; + +public class PolygonTickerData +{ + [JsonPropertyName("ticker")] + public string Ticker { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("market")] + public string Market { get; set; } + + [JsonPropertyName("locale")] + public string Locale { get; set; } + + [JsonPropertyName("primary_exchange")] + public string PrimaryExchange { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("active")] + public bool Active { get; set; } + + [JsonPropertyName("currency_name")] + public string CurrencyName { get; set; } + + [JsonPropertyName("cik")] + public string Cik { get; set; } + + [JsonPropertyName("composite_figi")] + public string CompositeFigi { get; set; } + + [JsonPropertyName("share_class_figi")] + public string ShareClassFigi { get; set; } + + [JsonPropertyName("last_updated_utc")] + public DateTime LastUpdatedUtc { get; set; } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerResponse.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerResponse.cs new file mode 100644 index 000000000..746f61728 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/Polygon/PolygonTickerResponse.cs @@ -0,0 +1,13 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace NadekoBot.Modules.Searches; + +public class PolygonTickerResponse +{ + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/PolygonStockDataService.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/PolygonStockDataService.cs new file mode 100644 index 000000000..4b0690c3b --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/PolygonStockDataService.cs @@ -0,0 +1,26 @@ +// namespace NadekoBot.Modules.Searches; +// +// public sealed class PolygonStockDataService : IStockDataService +// { +// private readonly IHttpClientFactory _httpClientFactory; +// private readonly IBotCredsProvider _credsProvider; +// +// public PolygonStockDataService(IHttpClientFactory httpClientFactory, IBotCredsProvider credsProvider) +// { +// _httpClientFactory = httpClientFactory; +// _credsProvider = credsProvider; +// } +// +// public async Task> GetStockDataAsync(string? query = null) +// { +// using var httpClient = _httpClientFactory.CreateClient(); +// using var client = new PolygonApiClient(httpClient, string.Empty); +// var data = await client.TickersAsync(query: query); +// +// return data.Map(static x => new StockData() +// { +// Name = x.Name, +// Ticker = x.Ticker, +// }); +// } +// } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/StockData.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/StockData.cs new file mode 100644 index 000000000..a69a01031 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/StockData.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace NadekoBot.Modules.Searches; + +public class StockData +{ + public string Name { get; set; } + public string Ticker { get; set; } + public double Price { get; set; } + public long MarketCap { get; set; } + public double Close { get; set; } + public double Change50d { get; set; } + public double Change200d { get; set; } + public long DailyVolume { get; set; } + public string Exchange { get; set; } +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/_Common/YahooFinanceStockDataService.cs b/src/NadekoBot/Modules/Searches/Crypto/_Common/YahooFinanceStockDataService.cs new file mode 100644 index 000000000..8fe006250 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Crypto/_Common/YahooFinanceStockDataService.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Json; +using YahooFinanceApi; + +namespace NadekoBot.Modules.Searches; + +public sealed class DefaultStockDataService : IStockDataService, INService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public DefaultStockDataService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public async Task> GetStockDataAsync(string query) + { + try + { + var symbols = await Yahoo.Symbols(query) + .Fields(Field.LongName, + Field.Symbol, + Field.RegularMarketPrice, + Field.RegularMarketPreviousClose, + Field.MarketCap, + Field.FiftyDayAverageChangePercent, + Field.TwoHundredDayAverageChangePercent, + Field.AverageDailyVolume10Day, + Field.FullExchangeName) + .QueryAsync(); + + return symbols + .Select(static x => x.Value) + .Select(static x => new StockData() + { + Name = x.LongName, + Ticker = x.Symbol, + Price = x.RegularMarketPrice, + Close = x.RegularMarketPreviousClose, + MarketCap = x.MarketCap, + Change50d = x.FiftyDayAverageChangePercent, + Change200d = x.TwoHundredDayAverageChangePercent, + DailyVolume = x.AverageDailyVolume10Day, + Exchange = x.FullExchangeName + }) + .ToList(); + } + catch (Exception ex) + { + // what the hell is this api exception + Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.Message); + return Array.Empty(); + } + } + + public Task> SearchSymbolAsync(string query) + { + return Task.FromResult>(Array.Empty()); + + // try + // { + // query = Uri.EscapeDataString(query); + // using var http = _httpClientFactory.CreateClient(); + // var response = await http.GetFromJsonAsync($"https://finnhub.io/api/v1/search" + // + $"?q={query}" + // + $"&token="); + // + // if (response is null) + // return Array.Empty(); + // + // return response.Result + // .Where(x => x.Type == "Common Stock") + // .Select(static x => new SymbolData(x.Symbol, x.Description)) + // .ToList(); + // } + // catch (Exception ex) + // { + // Log.Warning(ex, "Error searching stock symbol: {ErrorMessage}", ex.Message); + // return Array.Empty(); + // } + } +} + +public record SymbolData(string Symbol, string Description); \ No newline at end of file diff --git a/src/NadekoBot/NadekoBot.csproj b/src/NadekoBot/NadekoBot.csproj index d92ed2196..d1d02ed2c 100644 --- a/src/NadekoBot/NadekoBot.csproj +++ b/src/NadekoBot/NadekoBot.csproj @@ -19,61 +19,64 @@ - - - - - - - - - - - + + + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + @@ -102,13 +105,9 @@ - + - + diff --git a/src/NadekoBot/_Extensions/ArrayExtensions.cs b/src/NadekoBot/_Extensions/ArrayExtensions.cs index 208333797..27f628bbb 100644 --- a/src/NadekoBot/_Extensions/ArrayExtensions.cs +++ b/src/NadekoBot/_Extensions/ArrayExtensions.cs @@ -1,3 +1,5 @@ +using System.Buffers; + namespace NadekoBot.Extensions; // made for expressions because they almost never get added @@ -29,4 +31,15 @@ public static class ArrayExtensions /// New array with updated elements public static TOut[] Map(this TIn[] arr, Func f) => Array.ConvertAll(arr, x => f(x)); + + public static IReadOnlyCollection Map(this IReadOnlyCollection col, Func f) + { + var toReturn = new TOut[col.Count]; + + var i = 0; + foreach (var item in col) + toReturn[i++] = f(item); + + return toReturn; + } } \ No newline at end of file diff --git a/src/NadekoBot/data/strings/commands/commands.en-US.yml b/src/NadekoBot/data/strings/commands/commands.en-US.yml index 7164fd54f..2cbb0b1ac 100644 --- a/src/NadekoBot/data/strings/commands/commands.en-US.yml +++ b/src/NadekoBot/data/strings/commands/commands.en-US.yml @@ -2033,6 +2033,11 @@ crypto: args: - "btc" - "bitcoin" +stock: + desc: "Shows basic information about a stock. Only symbols are supported. Full company names are not supported atm." + args: + - "tsla" + - "amd" rolelevelreq: desc: "Set a level requirement on a self-assignable role." args: diff --git a/src/NadekoBot/data/strings/responses/responses.en-US.json b/src/NadekoBot/data/strings/responses/responses.en-US.json index 468b18011..06422db6e 100644 --- a/src/NadekoBot/data/strings/responses/responses.en-US.json +++ b/src/NadekoBot/data/strings/responses/responses.en-US.json @@ -888,6 +888,7 @@ "volume_24h": "Volume (24h)", "change_7d_24h": "Change (7d / 24h)", "crypto_not_found": "CryptoCurrency with that name was not found.", + "stocks_multiple_results": "Multiple results found, please use a symbol", "did_you_mean": "Did you mean {0}?", "self_assign_level_req": "Self assignable role {0} now requires at least server level {1}.", "self_assign_not_level": "That self-assignable role requires at least server level {0}.",