Added a .stock command

This commit is contained in:
Kwoth
2022-02-06 17:43:19 +01:00
parent 73555ff70e
commit 3d1f9b8b75
16 changed files with 446 additions and 46 deletions

View File

@@ -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

View File

@@ -64,6 +64,10 @@ Used only for .time command")]
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; }

View File

@@ -8,6 +8,110 @@ public partial class Searches
{
public partial class CryptoCommands : NadekoSubmodule<CryptoService>
{
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)
{

View File

@@ -0,0 +1,7 @@
namespace NadekoBot.Modules.Searches;
public interface IStockDataService
{
public Task<IReadOnlyCollection<StockData>> GetStockDataAsync(string query);
Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query);
}

View File

@@ -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<FinnHubSearchResult> Result { get; set; }
}

View File

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

View File

@@ -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<IReadOnlyCollection<PolygonTickerData>> 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<PolygonTickerResponse>(requestString);
if (response is null)
return Array.Empty<PolygonTickerData>();
return response.Results;
}
// public async Task<PolygonTickerDetailsV3> TickerDetailsV3Async(string ticker)
// {
// return new();
// }
public void Dispose()
=> _httpClient.Dispose();
}

View File

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

View File

@@ -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<PolygonTickerData> Results { get; set; }
}

View File

@@ -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<IReadOnlyCollection<StockData>> 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,
// });
// }
// }

View File

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

View File

@@ -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<IReadOnlyCollection<StockData>> 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<StockData>();
}
}
public Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
{
return Task.FromResult<IReadOnlyCollection<SymbolData>>(Array.Empty<SymbolData>());
// try
// {
// query = Uri.EscapeDataString(query);
// using var http = _httpClientFactory.CreateClient();
// var response = await http.GetFromJsonAsync<FinnHubSearchResponse>($"https://finnhub.io/api/v1/search"
// + $"?q={query}"
// + $"&token=");
//
// if (response is null)
// return Array.Empty<SymbolData>();
//
// 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<SymbolData>();
// }
}
}
public record SymbolData(string Symbol, string Description);

View File

@@ -19,61 +19,64 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.16.1"/>
<PackageReference Include="AWSSDK.S3" Version="3.7.7.10"/>
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.2"/>
<PackageReference Include="CommandLineParser" Version="2.8.0"/>
<PackageReference Include="Discord.Net" Version="3.1.0"/>
<PackageReference Include="CoreCLR-NCalc" Version="2.2.92"/>
<PackageReference Include="Google.Apis.Urlshortener.v1" Version="1.41.1.138"/>
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.55.0.2449"/>
<PackageReference Include="Google.Apis.Customsearch.v1" Version="1.49.0.2084"/>
<PackageReference Include="Google.Protobuf" Version="3.19.2"/>
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.41.0"/>
<PackageReference Include="AngleSharp" Version="0.16.1" />
<PackageReference Include="AWSSDK.S3" Version="3.7.7.10" />
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.2" />
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.1.0" />
<PackageReference Include="CoreCLR-NCalc" Version="2.2.92" />
<PackageReference Include="Google.Apis.Urlshortener.v1" Version="1.41.1.138" />
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.55.0.2449" />
<PackageReference Include="Google.Apis.Customsearch.v1" Version="1.49.0.2084" />
<PackageReference Include="Google.Protobuf" Version="3.19.2" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.41.0" />
<PackageReference Include="Grpc.Tools" Version="2.43.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Html2Markdown" Version="5.0.1.524"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1"/>
<PackageReference Include="Html2Markdown" Version="5.0.1.524" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0"/>
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0"/>
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1"/>
<PackageReference Include="Scrutor" Version="3.3.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="5.1.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4"/>
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta0010"/>
<PackageReference Include="StackExchange.Redis" Version="2.2.88"/>
<PackageReference Include="YamlDotNet" Version="11.2.1"/>
<PackageReference Include="linq2db.EntityFrameworkCore" Version="6.6.1"/>
<PackageReference Include="Humanizer" Version="2.13.14"/>
<PackageReference Include="JetBrains.Annotations" Version="2021.3.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Scrutor" Version="3.3.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="5.1.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta0010" />
<PackageReference Include="StackExchange.Redis" Version="2.2.88" />
<PackageReference Include="YamlDotNet" Version="11.2.1" />
<PackageReference Include="linq2db.EntityFrameworkCore" Version="6.6.1" />
<PackageReference Include="Humanizer" Version="2.13.14" />
<PackageReference Include="JetBrains.Annotations" Version="2021.3.0" />
<!-- Remove this when static abstract interface members support is released -->
<PackageReference Include="System.Runtime.Experimental" Version="6.0.0" />
<!-- Used by .crypto command -->
<PackageReference Include="YahooFinanceApi" Version="2.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj"/>
<ProjectReference Include="..\NadekoBot.Generators\NadekoBot.Generators.csproj" OutputItemType="Analyzer"/>
<ProjectReference Include="..\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj" />
<ProjectReference Include="..\NadekoBot.Generators\NadekoBot.Generators.csproj" OutputItemType="Analyzer" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="data\strings\responses\responses.en-US.json"/>
<AdditionalFiles Include="data\strings\responses\responses.en-US.json" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="..\NadekoBot.Coordinator\Protos\coordinator.proto" GrpcServices="Client">
@@ -102,13 +105,9 @@
</PropertyGroup>
<!-- TODO: Remove this when the conflict issue is fixed -->
<Target Name="RemoveSystemRuntimeFromRefPack"
BeforeTargets="_HandlePackageFileConflicts"
Condition="'@(Reference -> WithMetadataValue('NugetPackageId', 'System.Runtime.Experimental'))' != ''">
<Target Name="RemoveSystemRuntimeFromRefPack" BeforeTargets="_HandlePackageFileConflicts" Condition="'@(Reference -&gt; WithMetadataValue('NugetPackageId', 'System.Runtime.Experimental'))' != ''">
<ItemGroup>
<Reference Remove="@(Reference)"
Condition="$([System.String]::Copy(%(Reference.Identity)).Contains('System.Runtime.dll')) and
'%(Reference.NuGetPackageId)' == 'Microsoft.NETCore.App.Ref'" />
<Reference Remove="@(Reference)" Condition="$([System.String]::Copy(%(Reference.Identity)).Contains('System.Runtime.dll')) and&#xD;&#xA; '%(Reference.NuGetPackageId)' == 'Microsoft.NETCore.App.Ref'" />
</ItemGroup>
</Target>
</Project>

View File

@@ -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
/// <returns>New array with updated elements</returns>
public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
=> Array.ConvertAll(arr, x => f(x));
public static IReadOnlyCollection<TOut> Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> f)
{
var toReturn = new TOut[col.Count];
var i = 0;
foreach (var item in col)
toReturn[i++] = f(item);
return toReturn;
}
}

View File

@@ -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:

View File

@@ -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}.",