mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-12 02:08:27 -04:00
More restructuring
This commit is contained in:
196
src/Nadeko.Bot.Modules.Searches/Crypto/CryptoCommands.cs
Normal file
196
src/Nadeko.Bot.Modules.Searches/Crypto/CryptoCommands.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Searches.Services;
|
||||
using System.Globalization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
public partial class FinanceCommands : NadekoModule<CryptoService>
|
||||
{
|
||||
private readonly IStockDataService _stocksService;
|
||||
private readonly IStockChartDrawingService _stockDrawingService;
|
||||
|
||||
public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService)
|
||||
{
|
||||
_stocksService = stocksService;
|
||||
_stockDrawingService = stockDrawingService;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Stock([Leftover]string query)
|
||||
{
|
||||
using var typing = ctx.Channel.EnterTypingState();
|
||||
|
||||
var stock = await _stocksService.GetStockDataAsync(query);
|
||||
|
||||
if (stock is null)
|
||||
{
|
||||
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;
|
||||
stock = await _stocksService.GetStockDataAsync(query);
|
||||
|
||||
if (stock is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var candles = await _stocksService.GetCandleDataAsync(query);
|
||||
var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles);
|
||||
|
||||
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).ToString("P1", Culture);
|
||||
|
||||
var sign200 = stock.Change200d >= 0
|
||||
? "\\🔼"
|
||||
: "\\🔻";
|
||||
|
||||
var change200 = (stock.Change200d).ToString("P1", Culture);
|
||||
|
||||
var price = stock.Price.ToString("C2", localCulture);
|
||||
|
||||
var eb = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(stock.Symbol)
|
||||
.WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}")
|
||||
.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);
|
||||
|
||||
var message = await ctx.Channel.EmbedAsync(eb);
|
||||
await using var imageData = await stockImageTask;
|
||||
if (imageData is null)
|
||||
return;
|
||||
|
||||
var fileName = $"{query}-sparkline.{imageData.Extension}";
|
||||
using var attachment = new FileAttachment(
|
||||
imageData.FileData,
|
||||
fileName
|
||||
);
|
||||
await message.ModifyAsync(mp =>
|
||||
{
|
||||
mp.Attachments =
|
||||
new(new[]
|
||||
{
|
||||
attachment
|
||||
});
|
||||
|
||||
mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[Cmd]
|
||||
public async Task Crypto(string name)
|
||||
{
|
||||
name = name?.ToUpperInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
var (crypto, nearest) = await _service.GetCryptoData(name);
|
||||
|
||||
if (nearest is not null)
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.WithTitle(GetText(strs.crypto_not_found))
|
||||
.WithDescription(
|
||||
GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})"))));
|
||||
|
||||
if (await PromptUserConfirmAsync(embed))
|
||||
crypto = nearest;
|
||||
}
|
||||
|
||||
if (crypto is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.crypto_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
var usd = crypto.Quote["USD"];
|
||||
|
||||
var localCulture = (CultureInfo)Culture.Clone();
|
||||
localCulture.NumberFormat.CurrencySymbol = "$";
|
||||
|
||||
var sevenDay = (usd.PercentChange7d / 100).ToString("P2", localCulture);
|
||||
var lastDay = (usd.PercentChange24h / 100).ToString("P2", localCulture);
|
||||
var price = usd.Price < 0.01
|
||||
? usd.Price.ToString(localCulture)
|
||||
: usd.Price.ToString("C2", localCulture);
|
||||
|
||||
var volume = usd.Volume24h.ToString("C0", localCulture);
|
||||
var marketCap = usd.MarketCap.ToString("C0", localCulture);
|
||||
var dominance = (usd.MarketCapDominance / 100).ToString("P2", localCulture);
|
||||
|
||||
await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0);
|
||||
var fileName = $"{crypto.Slug}_7d.png";
|
||||
|
||||
var toSend = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor($"#{crypto.CmcRank}")
|
||||
.WithTitle($"{crypto.Name} ({crypto.Symbol})")
|
||||
.WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/")
|
||||
.WithThumbnailUrl($"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png")
|
||||
.AddField(GetText(strs.market_cap), marketCap, true)
|
||||
.AddField(GetText(strs.price), price, true)
|
||||
.AddField(GetText(strs.volume_24h), volume, true)
|
||||
.AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true)
|
||||
.AddField(GetText(strs.market_cap_dominance), dominance, true)
|
||||
.WithImageUrl($"attachment://{fileName}");
|
||||
|
||||
if (crypto.CirculatingSupply is double cs)
|
||||
{
|
||||
var csStr = cs.ToString("N0", localCulture);
|
||||
|
||||
if (crypto.MaxSupply is double ms)
|
||||
{
|
||||
var perc = (cs / ms).ToString("P1", localCulture);
|
||||
|
||||
toSend.AddField(GetText(strs.circulating_supply), $"{csStr} ({perc})", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
toSend.AddField(GetText(strs.circulating_supply), csStr, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build());
|
||||
}
|
||||
}
|
||||
}
|
216
src/Nadeko.Bot.Modules.Searches/Crypto/CryptoService.cs
Normal file
216
src/Nadeko.Bot.Modules.Searches/Crypto/CryptoService.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
#nullable enable
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Xml;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
using StringExtensions = NadekoBot.Extensions.StringExtensions;
|
||||
|
||||
namespace NadekoBot.Modules.Searches.Services;
|
||||
|
||||
public class CryptoService : INService
|
||||
{
|
||||
private readonly IBotCache _cache;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IBotCredentials _creds;
|
||||
|
||||
private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
|
||||
|
||||
public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
|
||||
{
|
||||
_cache = cache;
|
||||
_httpFactory = httpFactory;
|
||||
_creds = creds;
|
||||
}
|
||||
|
||||
private PointF[] GetSparklinePointsFromSvgText(string svgText)
|
||||
{
|
||||
var xml = new XmlDocument();
|
||||
xml.LoadXml(svgText);
|
||||
|
||||
var gElement = xml["svg"]?["g"];
|
||||
if (gElement is null)
|
||||
return Array.Empty<PointF>();
|
||||
|
||||
Span<PointF> points = new PointF[gElement.ChildNodes.Count];
|
||||
var cnt = 0;
|
||||
|
||||
bool GetValuesFromAttributes(
|
||||
XmlAttributeCollection attrs,
|
||||
out float x1,
|
||||
out float y1,
|
||||
out float x2,
|
||||
out float y2)
|
||||
{
|
||||
(x1, y1, x2, y2) = (0, 0, 0, 0);
|
||||
return attrs["x1"]?.Value is string x1Str
|
||||
&& float.TryParse(x1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x1)
|
||||
&& attrs["y1"]?.Value is string y1Str
|
||||
&& float.TryParse(y1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y1)
|
||||
&& attrs["x2"]?.Value is string x2Str
|
||||
&& float.TryParse(x2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x2)
|
||||
&& attrs["y2"]?.Value is string y2Str
|
||||
&& float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2);
|
||||
}
|
||||
|
||||
foreach (XmlElement x in gElement.ChildNodes)
|
||||
{
|
||||
if (x.Name != "line")
|
||||
continue;
|
||||
|
||||
if (GetValuesFromAttributes(x.Attributes, out var x1, out var y1, out var x2, out var y2))
|
||||
{
|
||||
points[cnt++] = new(x1, y1);
|
||||
// this point will be set twice to the same value
|
||||
// on all points except the last one
|
||||
if (cnt + 1 < points.Length)
|
||||
points[cnt + 1] = new(x2, y2);
|
||||
}
|
||||
}
|
||||
|
||||
if (cnt == 0)
|
||||
return Array.Empty<PointF>();
|
||||
|
||||
return points.Slice(0, cnt).ToArray();
|
||||
}
|
||||
|
||||
private SixLabors.ImageSharp.Image<Rgba32> GenerateSparklineChart(PointF[] points, bool up)
|
||||
{
|
||||
const int width = 164;
|
||||
const int height = 48;
|
||||
|
||||
var img = new Image<Rgba32>(width, height, Color.Transparent);
|
||||
var color = up
|
||||
? Color.Green
|
||||
: Color.FromRgb(220, 0, 0);
|
||||
|
||||
img.Mutate(x =>
|
||||
{
|
||||
x.DrawLines(color, 2, points);
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return (null, null);
|
||||
|
||||
name = name.ToUpperInvariant();
|
||||
var cryptos = await GetCryptoDataInternal();
|
||||
|
||||
if (cryptos is null or { Count: 0 })
|
||||
return (null, null);
|
||||
|
||||
var crypto = cryptos.FirstOrDefault(x
|
||||
=> x.Slug.ToUpperInvariant() == name
|
||||
|| x.Name.ToUpperInvariant() == name
|
||||
|| x.Symbol.ToUpperInvariant() == name);
|
||||
|
||||
if (crypto is not null)
|
||||
return (crypto, null);
|
||||
|
||||
|
||||
var nearest = cryptos
|
||||
.Select(elem => (Elem: elem,
|
||||
Distance: StringExtensions.LevenshteinDistance(elem.Name.ToUpperInvariant(), name)))
|
||||
.OrderBy(x => x.Distance)
|
||||
.FirstOrDefault(x => x.Distance <= 2);
|
||||
|
||||
return (null, nearest.Elem);
|
||||
}
|
||||
|
||||
public async Task<List<CmcResponseData>?> GetCryptoDataInternal()
|
||||
{
|
||||
await _getCryptoLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var data = await _cache.GetOrAddAsync(new("nadeko:crypto_data"),
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var data = await http.GetFromJsonAsync<CryptoResponse>(
|
||||
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
|
||||
+ $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
|
||||
+ "&start=1"
|
||||
+ "&limit=5000"
|
||||
+ "&convert=USD");
|
||||
|
||||
return data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error getting crypto data: {Message}", ex.Message);
|
||||
return default;
|
||||
}
|
||||
},
|
||||
TimeSpan.FromHours(2));
|
||||
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
return data.Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message);
|
||||
return default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_getCryptoLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private TypedKey<byte[]> GetSparklineKey(int id)
|
||||
=> new($"crypto:sparkline:{id}");
|
||||
|
||||
public async Task<Stream?> GetSparklineAsync(int id, bool up)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id),
|
||||
async () =>
|
||||
{
|
||||
// if it fails, generate a new one
|
||||
var points = await DownloadSparklinePointsAsync(id);
|
||||
var sparkline = GenerateSparklineChart(points, up);
|
||||
|
||||
using var stream = await sparkline.ToStreamAsync();
|
||||
return stream.ToArray();
|
||||
},
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
if (bytes is { Length: > 0 })
|
||||
{
|
||||
return bytes.ToStream();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Exception occurred while downloading sparkline points: {ErrorMessage}",
|
||||
ex.Message);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PointF[]> DownloadSparklinePointsAsync(int id)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var str = await http.GetStringAsync(
|
||||
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
|
||||
var points = GetSparklinePointsFromSvgText(str);
|
||||
return points;
|
||||
}
|
||||
}
|
@@ -0,0 +1,103 @@
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class DefaultStockDataService : IStockDataService, INService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public DefaultStockDataService(IHttpClientFactory httpClientFactory)
|
||||
=> _httpClientFactory = httpClientFactory;
|
||||
|
||||
public async Task<StockData?> GetStockDataAsync(string query)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!query.IsAlphaNumeric())
|
||||
return default;
|
||||
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
var data = await http.GetFromJsonAsync<YahooQueryModel>(
|
||||
$"https://query1.finance.yahoo.com/v7/finance/quote?symbols={query}");
|
||||
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
var symbol = data.QuoteResponse.Result.FirstOrDefault();
|
||||
|
||||
if (symbol is null)
|
||||
return default;
|
||||
|
||||
return new()
|
||||
{
|
||||
Name = symbol.LongName,
|
||||
Symbol = symbol.Symbol,
|
||||
Price = symbol.RegularMarketPrice,
|
||||
Close = symbol.RegularMarketPreviousClose,
|
||||
MarketCap = symbol.MarketCap,
|
||||
Change50d = symbol.FiftyDayAverageChangePercent,
|
||||
Change200d = symbol.TwoHundredDayAverageChangePercent,
|
||||
DailyVolume = symbol.AverageDailyVolume10Day,
|
||||
Exchange = symbol.FullExchangeName
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.Message);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
|
||||
query = Uri.EscapeDataString(query);
|
||||
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
|
||||
var res = await http.GetStringAsync(
|
||||
"https://finance.yahoo.com/_finance_doubledown/api/resource/searchassist"
|
||||
+ $";searchTerm={query}"
|
||||
+ "?device=console");
|
||||
|
||||
var data = JsonSerializer.Deserialize<YahooFinanceSearchResponse>(res);
|
||||
|
||||
if (data is null or { Items: null })
|
||||
return Array.Empty<SymbolData>();
|
||||
|
||||
return data.Items
|
||||
.Where(x => x.Type == "S")
|
||||
.Select(x => new SymbolData(x.Symbol, x.Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture)
|
||||
{
|
||||
PrepareHeaderForMatch = args => args.Header.Humanize(LetterCasing.Title)
|
||||
};
|
||||
|
||||
// todo replace .ToTimestamp() and remove google protobuf dependency
|
||||
// todo this needs testing
|
||||
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
|
||||
{
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
await using var resStream = await http.GetStreamAsync(
|
||||
$"https://query1.finance.yahoo.com/v7/finance/download/{query}"
|
||||
+ $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}"
|
||||
+ $"&period2={DateTime.UtcNow.ToTimestamp()}"
|
||||
+ "&interval=1d");
|
||||
|
||||
using var textReader = new StreamReader(resStream);
|
||||
using var csv = new CsvReader(textReader, _csvConfig);
|
||||
var records = csv.GetRecords<YahooFinanceCandleData>().ToArray();
|
||||
|
||||
return records
|
||||
.Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume));
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
/// <summary>
|
||||
/// All data required to draw a candle
|
||||
/// </summary>
|
||||
/// <param name="IsGreen">Whether the candle is green</param>
|
||||
/// <param name="BodyRect">Rectangle for the body</param>
|
||||
/// <param name="High">High line point</param>
|
||||
/// <param name="Low">Low line point</param>
|
||||
public record CandleDrawingData(bool IsGreen, RectangleF BodyRect, PointF High, PointF Low);
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface IStockChartDrawingService
|
||||
{
|
||||
Task<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series);
|
||||
Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series);
|
||||
Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series);
|
||||
}
|
@@ -0,0 +1,202 @@
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class ImagesharpStockChartDrawingService : IStockChartDrawingService, INService
|
||||
{
|
||||
private const int WIDTH = 300;
|
||||
private const int HEIGHT = 100;
|
||||
private const decimal MAX_HEIGHT = HEIGHT * 0.8m;
|
||||
|
||||
private static readonly Rgba32 _backgroundColor = Rgba32.ParseHex("17181E");
|
||||
private static readonly Rgba32 _lineGuideColor = Rgba32.ParseHex("212125");
|
||||
private static readonly Rgba32 _sparklineColor = Rgba32.ParseHex("2961FC");
|
||||
private static readonly Rgba32 _greenBrush = Rgba32.ParseHex("26A69A");
|
||||
private static readonly Rgba32 _redBrush = Rgba32.ParseHex("EF5350");
|
||||
|
||||
private static float GetNormalizedPoint(decimal max, decimal point, decimal range)
|
||||
=> (float)((MAX_HEIGHT * ((max - point) / range)) + HeightOffset());
|
||||
|
||||
private PointF[] GetSparklinePointsInternal(IReadOnlyCollection<CandleData> series)
|
||||
{
|
||||
var candleStep = WIDTH / (series.Count + 1);
|
||||
var max = series.Max(static x => x.High);
|
||||
var min = series.Min(static x => x.Low);
|
||||
|
||||
var range = max - min;
|
||||
|
||||
var points = new PointF[series.Count];
|
||||
|
||||
var i = 0;
|
||||
foreach (var candle in series)
|
||||
{
|
||||
var x = candleStep * (i + 1);
|
||||
|
||||
var y = GetNormalizedPoint(max, candle.Close, range);
|
||||
points[i++] = new(x, y);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static decimal HeightOffset()
|
||||
=> (HEIGHT - MAX_HEIGHT) / 2m;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static Image<Rgba32> CreateCanvasInternal()
|
||||
=> new Image<Rgba32>(WIDTH, HEIGHT, _backgroundColor);
|
||||
|
||||
private CandleDrawingData[] GetChartDrawingDataInternal(IReadOnlyCollection<CandleData> series)
|
||||
{
|
||||
var candleMargin = 2;
|
||||
var candleStep = (WIDTH - (candleMargin * series.Count)) / (series.Count + 1);
|
||||
var max = series.Max(static x => x.High);
|
||||
var min = series.Min(static x => x.Low);
|
||||
|
||||
var range = max - min;
|
||||
|
||||
var drawData = new CandleDrawingData[series.Count];
|
||||
|
||||
var candleWidth = candleStep;
|
||||
|
||||
var i = 0;
|
||||
foreach (var candle in series)
|
||||
{
|
||||
var offsetX = (i - 1) * candleMargin;
|
||||
var x = (candleStep * (i + 1)) + offsetX;
|
||||
var yOpen = GetNormalizedPoint(max, candle.Open, range);
|
||||
var yClose = GetNormalizedPoint(max, candle.Close, range);
|
||||
var y = candle.Open > candle.Close
|
||||
? yOpen
|
||||
: yClose;
|
||||
|
||||
var sizeH = Math.Abs(yOpen - yClose);
|
||||
|
||||
var high = GetNormalizedPoint(max, candle.High, range);
|
||||
var low = GetNormalizedPoint(max, candle.Low, range);
|
||||
drawData[i] = new(candle.Open < candle.Close,
|
||||
new(x, y, candleWidth, sizeH),
|
||||
new(x + (candleStep / 2), high),
|
||||
new(x + (candleStep / 2), low));
|
||||
++i;
|
||||
}
|
||||
|
||||
return drawData;
|
||||
}
|
||||
|
||||
private void DrawChartData(Image<Rgba32> image, CandleDrawingData[] drawData)
|
||||
=> image.Mutate(ctx =>
|
||||
{
|
||||
foreach (var data in drawData)
|
||||
DrawLineExtensions.DrawLines(ctx,
|
||||
data.IsGreen
|
||||
? _greenBrush
|
||||
: _redBrush,
|
||||
1,
|
||||
data.High,
|
||||
data.Low);
|
||||
|
||||
|
||||
foreach (var data in drawData)
|
||||
FillRectangleExtensions.Fill(ctx,
|
||||
data.IsGreen
|
||||
? _greenBrush
|
||||
: _redBrush,
|
||||
data.BodyRect);
|
||||
});
|
||||
|
||||
private void DrawLineGuides(Image<Rgba32> image, IReadOnlyCollection<CandleData> series)
|
||||
{
|
||||
var max = series.Max(x => x.High);
|
||||
var min = series.Min(x => x.Low);
|
||||
|
||||
var step = (max - min) / 5;
|
||||
|
||||
var lines = new float[6];
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
var y = GetNormalizedPoint(max, min + (step * i), max - min);
|
||||
lines[i] = y;
|
||||
}
|
||||
|
||||
image.Mutate(ctx =>
|
||||
{
|
||||
// draw guides
|
||||
foreach (var y in lines)
|
||||
ctx.DrawLines(_lineGuideColor, 1, new PointF(0, y), new PointF(WIDTH, y));
|
||||
|
||||
// // draw min and max price on the chart
|
||||
// ctx.DrawText(min.ToString(CultureInfo.InvariantCulture),
|
||||
// SystemFonts.CreateFont("Arial", 5),
|
||||
// Color.White,
|
||||
// new PointF(0, (float)HeightOffset() - 5)
|
||||
// );
|
||||
//
|
||||
// ctx.DrawText(max.ToString("N1", CultureInfo.InvariantCulture),
|
||||
// SystemFonts.CreateFont("Arial", 5),
|
||||
// Color.White,
|
||||
// new PointF(0, HEIGHT - (float)HeightOffset())
|
||||
// );
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series)
|
||||
{
|
||||
if (series.Count == 0)
|
||||
return Task.FromResult<ImageData?>(default);
|
||||
|
||||
using var image = CreateCanvasInternal();
|
||||
|
||||
var points = GetSparklinePointsInternal(series);
|
||||
|
||||
image.Mutate(ctx =>
|
||||
{
|
||||
ctx.DrawLines(_sparklineColor, 2, points);
|
||||
});
|
||||
|
||||
return Task.FromResult<ImageData?>(new("png", image.ToStream()));
|
||||
}
|
||||
|
||||
public Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series)
|
||||
{
|
||||
if (series.Count == 0)
|
||||
return Task.FromResult<ImageData?>(default);
|
||||
|
||||
using var image = CreateCanvasInternal();
|
||||
|
||||
DrawLineGuides(image, series);
|
||||
|
||||
var chartData = GetChartDrawingDataInternal(series);
|
||||
DrawChartData(image, chartData);
|
||||
|
||||
var points = GetSparklinePointsInternal(series);
|
||||
image.Mutate(ctx =>
|
||||
{
|
||||
ctx.DrawLines(Color.ParseHex("00FFFFAA"), 1, points);
|
||||
});
|
||||
|
||||
return Task.FromResult<ImageData?>(new("png", image.ToStream()));
|
||||
}
|
||||
|
||||
public Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series)
|
||||
{
|
||||
if (series.Count == 0)
|
||||
return Task.FromResult<ImageData?>(default);
|
||||
|
||||
using var image = CreateCanvasInternal();
|
||||
|
||||
DrawLineGuides(image, series);
|
||||
|
||||
var drawData = GetChartDrawingDataInternal(series);
|
||||
DrawChartData(image, drawData);
|
||||
|
||||
return Task.FromResult<ImageData?>(new("png", image.ToStream()));
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface IStockDataService
|
||||
{
|
||||
public Task<StockData?> GetStockDataAsync(string symbol);
|
||||
Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query);
|
||||
Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query);
|
||||
}
|
@@ -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; }
|
||||
}
|
@@ -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; }
|
||||
}
|
@@ -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();
|
||||
// }
|
@@ -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,
|
||||
// });
|
||||
// }
|
||||
// }
|
@@ -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; }
|
||||
}
|
@@ -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; }
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public record CandleData(
|
||||
decimal Open,
|
||||
decimal Close,
|
||||
decimal High,
|
||||
decimal Low,
|
||||
long Volume);
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public record ImageData(string Extension, Stream FileData) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync()
|
||||
=> FileData.DisposeAsync();
|
||||
}
|
@@ -0,0 +1,43 @@
|
||||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class QuoteResponse
|
||||
{
|
||||
public class ResultModel
|
||||
{
|
||||
[JsonPropertyName("longName")]
|
||||
public string LongName { get; set; }
|
||||
|
||||
[JsonPropertyName("regularMarketPrice")]
|
||||
public double RegularMarketPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("regularMarketPreviousClose")]
|
||||
public double RegularMarketPreviousClose { get; set; }
|
||||
|
||||
[JsonPropertyName("fullExchangeName")]
|
||||
public string FullExchangeName { get; set; }
|
||||
|
||||
[JsonPropertyName("averageDailyVolume10Day")]
|
||||
public int AverageDailyVolume10Day { get; set; }
|
||||
|
||||
[JsonPropertyName("fiftyDayAverageChangePercent")]
|
||||
public double FiftyDayAverageChangePercent { get; set; }
|
||||
|
||||
[JsonPropertyName("twoHundredDayAverageChangePercent")]
|
||||
public double TwoHundredDayAverageChangePercent { get; set; }
|
||||
|
||||
[JsonPropertyName("marketCap")]
|
||||
public long MarketCap { get; set; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public string Symbol { get; set; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public List<ResultModel> Result { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public object Error { get; set; }
|
||||
}
|
15
src/Nadeko.Bot.Modules.Searches/Crypto/_Common/StockData.cs
Normal file
15
src/Nadeko.Bot.Modules.Searches/Crypto/_Common/StockData.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class StockData
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Symbol { 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; }
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public record SymbolData(string Symbol, string Description);
|
@@ -0,0 +1,12 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class YahooFinanceCandleData
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public decimal Open { get; set; }
|
||||
public decimal High { get; set; }
|
||||
public decimal Low { get; set; }
|
||||
public decimal Close { get; set; }
|
||||
public decimal AdjClose { get; set; }
|
||||
public long Volume { get; set; }
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class YahooFinanceSearchResponse
|
||||
{
|
||||
[JsonPropertyName("suggestionTitleAccessor")]
|
||||
public string SuggestionTitleAccessor { get; set; }
|
||||
|
||||
[JsonPropertyName("suggestionMeta")]
|
||||
public List<string> SuggestionMeta { get; set; }
|
||||
|
||||
[JsonPropertyName("hiConf")]
|
||||
public bool HiConf { get; set; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public List<YahooFinanceSearchResponseItem> Items { get; set; }
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class YahooFinanceSearchResponseItem
|
||||
{
|
||||
[JsonPropertyName("symbol")]
|
||||
public string Symbol { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("exch")]
|
||||
public string Exch { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("exchDisp")]
|
||||
public string ExchDisp { get; set; }
|
||||
|
||||
[JsonPropertyName("typeDisp")]
|
||||
public string TypeDisp { get; set; }
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class YahooQueryModel
|
||||
{
|
||||
[JsonPropertyName("quoteResponse")]
|
||||
public QuoteResponse QuoteResponse { get; set; } = null!;
|
||||
}
|
Reference in New Issue
Block a user