mirror of
				https://gitlab.com/Kwoth/nadekobot.git
				synced 2025-11-04 00:34:26 -05:00 
			
		
		
		
	.stock now shows a candlestick chart with a sparkline on top of it. Reorganized stock code slightly
This commit is contained in:
		@@ -5,18 +5,16 @@ namespace NadekoBot.Common.TypeReaders;
 | 
			
		||||
 | 
			
		||||
public sealed class Rgba32TypeReader : NadekoTypeReader<Color>
 | 
			
		||||
{
 | 
			
		||||
    public override async ValueTask<TypeReaderResult<Color>> ReadAsync(ICommandContext context, string input)
 | 
			
		||||
    public override ValueTask<TypeReaderResult<Color>> ReadAsync(ICommandContext context, string input)
 | 
			
		||||
    {
 | 
			
		||||
        await Task.Yield();
 | 
			
		||||
 | 
			
		||||
        input = input.Replace("#", "", StringComparison.InvariantCulture);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            return TypeReaderResult.FromSuccess(Color.ParseHex(input));
 | 
			
		||||
            return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input)));
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            return TypeReaderResult.FromError<Color>(CommandError.ParseFailed, "Parameter is not a valid color hex.");
 | 
			
		||||
            return ValueTask.FromResult(TypeReaderResult.FromError<Color>(CommandError.ParseFailed, "Parameter is not a valid color hex."));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -97,7 +97,6 @@ public class VcRoleService : INService
 | 
			
		||||
 | 
			
		||||
    private async Task InitializeVcRole(GuildConfig gconf)
 | 
			
		||||
    {
 | 
			
		||||
        await Task.Yield();
 | 
			
		||||
        var g = _client.GetGuild(gconf.GuildId);
 | 
			
		||||
        if (g is null)
 | 
			
		||||
            return;
 | 
			
		||||
@@ -120,11 +119,12 @@ public class VcRoleService : INService
 | 
			
		||||
        if (missingRoles.Any())
 | 
			
		||||
        {
 | 
			
		||||
            await using var uow = _db.GetDbContext();
 | 
			
		||||
            Log.Warning("Removing {MissingRoleCount} missing roles from {ServiceName}",
 | 
			
		||||
                missingRoles.Count,
 | 
			
		||||
                nameof(VcRoleService));
 | 
			
		||||
            uow.RemoveRange(missingRoles);
 | 
			
		||||
            await uow.SaveChangesAsync();
 | 
			
		||||
            
 | 
			
		||||
            Log.Warning("Removed {MissingRoleCount} missing roles from {ServiceName}",
 | 
			
		||||
                missingRoles.Count,
 | 
			
		||||
                nameof(VcRoleService));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,13 +8,15 @@ namespace NadekoBot.Modules.Searches;
 | 
			
		||||
// todo autoplay/fairplay
 | 
			
		||||
public partial class Searches
 | 
			
		||||
{
 | 
			
		||||
    public partial class CryptoCommands : NadekoSubmodule<CryptoService>
 | 
			
		||||
    public partial class FinanceCommands : NadekoSubmodule<CryptoService>
 | 
			
		||||
    {
 | 
			
		||||
        private readonly IStockDataService _stocksService;
 | 
			
		||||
        private readonly IStockChartDrawingService _stockDrawingService;
 | 
			
		||||
 | 
			
		||||
        public CryptoCommands(IStockDataService stocksService)
 | 
			
		||||
        public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService)
 | 
			
		||||
        {
 | 
			
		||||
            _stocksService = stocksService;
 | 
			
		||||
            _stockDrawingService = stockDrawingService;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        [Cmd]
 | 
			
		||||
@@ -22,9 +24,9 @@ public partial class Searches
 | 
			
		||||
        {
 | 
			
		||||
            using var typing = ctx.Channel.EnterTypingState();
 | 
			
		||||
            
 | 
			
		||||
            var stocks = await _stocksService.GetStockDataAsync(query);
 | 
			
		||||
            var stock = await _stocksService.GetStockDataAsync(query);
 | 
			
		||||
 | 
			
		||||
            if (stocks.Count == 0)
 | 
			
		||||
            if (stock is null)
 | 
			
		||||
            {
 | 
			
		||||
                var symbols = await _stocksService.SearchSymbolAsync(query);
 | 
			
		||||
 | 
			
		||||
@@ -43,34 +45,18 @@ public partial class Searches
 | 
			
		||||
                    return;
 | 
			
		||||
 | 
			
		||||
                query = symbol.Symbol;
 | 
			
		||||
                stocks = await _stocksService.GetStockDataAsync(query);
 | 
			
		||||
                stock = await _stocksService.GetStockDataAsync(query);
 | 
			
		||||
 | 
			
		||||
                if (stocks.Count == 0)
 | 
			
		||||
                if (stock is null)
 | 
			
		||||
                {
 | 
			
		||||
                    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 candles = await _stocksService.GetCandleDataAsync(query);
 | 
			
		||||
            var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles);
 | 
			
		||||
            
 | 
			
		||||
            var localCulture = (CultureInfo)Culture.Clone();
 | 
			
		||||
            localCulture.NumberFormat.CurrencySymbol = "$";
 | 
			
		||||
 | 
			
		||||
@@ -85,19 +71,20 @@ public partial class Searches
 | 
			
		||||
                ? "\\🔼"
 | 
			
		||||
                : "\\🔻";
 | 
			
		||||
 | 
			
		||||
            var change50 = (stock.Change50d / 100).ToString("P1", Culture);
 | 
			
		||||
            var change50 = (stock.Change50d).ToString("P1", Culture);
 | 
			
		||||
            
 | 
			
		||||
            var sign200 = stock.Change200d >= 0
 | 
			
		||||
                ? "\\🔼"
 | 
			
		||||
                : "\\🔻";
 | 
			
		||||
            
 | 
			
		||||
            var change200 = (stock.Change200d / 100).ToString("P1", Culture);
 | 
			
		||||
            var change200 = (stock.Change200d).ToString("P1", Culture);
 | 
			
		||||
            
 | 
			
		||||
            var price = stock.Price.ToString("C2", localCulture);
 | 
			
		||||
 | 
			
		||||
            var eb = _eb.Create()
 | 
			
		||||
                        .WithOkColor()
 | 
			
		||||
                        .WithAuthor(stock.Ticker)
 | 
			
		||||
                        .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)
 | 
			
		||||
@@ -107,7 +94,25 @@ public partial class Searches
 | 
			
		||||
                        .AddField("Change 200d", $"{sign200}{change200}", true)
 | 
			
		||||
                        .WithFooter(stock.Exchange);
 | 
			
		||||
            
 | 
			
		||||
            await ctx.Channel.EmbedAsync(eb);
 | 
			
		||||
            var message = await ctx.Channel.EmbedAsync(eb);
 | 
			
		||||
            await using var imageData = await stockImageTask;
 | 
			
		||||
            if (imageData is null)
 | 
			
		||||
                return;
 | 
			
		||||
 | 
			
		||||
            var fileName = $"{query}-sparkline.{imageData.Extension}";
 | 
			
		||||
            await message.ModifyAsync(mp =>
 | 
			
		||||
            {
 | 
			
		||||
                mp.Attachments =
 | 
			
		||||
                    new(new[]
 | 
			
		||||
                    {
 | 
			
		||||
                        new FileAttachment(
 | 
			
		||||
                            imageData.FileData,
 | 
			
		||||
                            fileName
 | 
			
		||||
                        )
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build();
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
using System.Net.Http.Json;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using YahooFinanceApi;
 | 
			
		||||
 | 
			
		||||
namespace NadekoBot.Modules.Searches;
 | 
			
		||||
@@ -9,16 +8,14 @@ public sealed class DefaultStockDataService : IStockDataService, INService
 | 
			
		||||
    private readonly IHttpClientFactory _httpClientFactory;
 | 
			
		||||
 | 
			
		||||
    public DefaultStockDataService(IHttpClientFactory httpClientFactory)
 | 
			
		||||
    {
 | 
			
		||||
        _httpClientFactory = httpClientFactory;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    public async Task<IReadOnlyCollection<StockData>> GetStockDataAsync(string query)
 | 
			
		||||
        => _httpClientFactory = httpClientFactory;
 | 
			
		||||
 | 
			
		||||
    public async Task<StockData?> GetStockDataAsync(string query)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            if (!query.IsAlphaNumeric())
 | 
			
		||||
                return Array.Empty<StockData>();
 | 
			
		||||
                return default;
 | 
			
		||||
 | 
			
		||||
            var symbols = await Yahoo.Symbols(query)
 | 
			
		||||
                                     .Fields(Field.LongName,
 | 
			
		||||
@@ -31,28 +28,30 @@ public sealed class DefaultStockDataService : IStockDataService, INService
 | 
			
		||||
                                         Field.AverageDailyVolume10Day,
 | 
			
		||||
                                         Field.FullExchangeName)
 | 
			
		||||
                                     .QueryAsync();
 | 
			
		||||
 | 
			
		||||
            var symbol = symbols.Values.FirstOrDefault();
 | 
			
		||||
 | 
			
		||||
            if (symbol is null)
 | 
			
		||||
                return default;
 | 
			
		||||
            
 | 
			
		||||
            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();
 | 
			
		||||
            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 ex)
 | 
			
		||||
        {
 | 
			
		||||
            // what the hell is this api exception
 | 
			
		||||
            Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.Message);
 | 
			
		||||
            return Array.Empty<StockData>();
 | 
			
		||||
            return default;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -81,10 +80,11 @@ public sealed class DefaultStockDataService : IStockDataService, INService
 | 
			
		||||
                  .ToList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
 | 
			
		||||
    // {
 | 
			
		||||
    //     
 | 
			
		||||
    // }
 | 
			
		||||
}
 | 
			
		||||
    public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
 | 
			
		||||
    {
 | 
			
		||||
        var candles = await Yahoo.GetHistoricalAsync(query, DateTime.Now.Subtract(30.Days()));
 | 
			
		||||
 | 
			
		||||
public record CandleData();
 | 
			
		||||
        return candles
 | 
			
		||||
            .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 async Task<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series)
 | 
			
		||||
    {
 | 
			
		||||
        if (series.Count == 0)
 | 
			
		||||
            return default;
 | 
			
		||||
 | 
			
		||||
        using var image = CreateCanvasInternal();
 | 
			
		||||
 | 
			
		||||
        var points = GetSparklinePointsInternal(series);
 | 
			
		||||
        
 | 
			
		||||
        image.Mutate(ctx =>
 | 
			
		||||
        {
 | 
			
		||||
            ctx.DrawLines(_sparklineColor, 2, points);
 | 
			
		||||
        });
 | 
			
		||||
    
 | 
			
		||||
        return new("png", image.ToStream());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series)
 | 
			
		||||
    {
 | 
			
		||||
        if (series.Count == 0)
 | 
			
		||||
            return 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 new("png", image.ToStream());
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    public async Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series)
 | 
			
		||||
    {
 | 
			
		||||
        if (series.Count == 0)
 | 
			
		||||
            return default;
 | 
			
		||||
 | 
			
		||||
        using var image = CreateCanvasInternal();
 | 
			
		||||
 | 
			
		||||
        DrawLineGuides(image, series);
 | 
			
		||||
        
 | 
			
		||||
        var drawData = GetChartDrawingDataInternal(series);
 | 
			
		||||
        DrawChartData(image, drawData);
 | 
			
		||||
 | 
			
		||||
        return new("png", image.ToStream());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
 | 
			
		||||
public interface IStockDataService
 | 
			
		||||
{
 | 
			
		||||
    public Task<IReadOnlyCollection<StockData>> GetStockDataAsync(string query);
 | 
			
		||||
    public Task<StockData?> GetStockDataAsync(string symbol);
 | 
			
		||||
    Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query);
 | 
			
		||||
    Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query);
 | 
			
		||||
}
 | 
			
		||||
@@ -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();
 | 
			
		||||
}
 | 
			
		||||
@@ -4,7 +4,7 @@ namespace NadekoBot.Modules.Searches;
 | 
			
		||||
public class StockData
 | 
			
		||||
{
 | 
			
		||||
    public string Name { get; set; }
 | 
			
		||||
    public string Ticker { get; set; }
 | 
			
		||||
    public string Symbol { get; set; }
 | 
			
		||||
    public double Price { get; set; }
 | 
			
		||||
    public long MarketCap { get; set; }
 | 
			
		||||
    public double Close { get; set; }
 | 
			
		||||
 
 | 
			
		||||
@@ -51,43 +51,38 @@ public class CommandMapService : IInputTransformer, INService
 | 
			
		||||
        IUser user,
 | 
			
		||||
        string input)
 | 
			
		||||
    {
 | 
			
		||||
        await Task.Yield();
 | 
			
		||||
 | 
			
		||||
        if (guild is null || string.IsNullOrWhiteSpace(input))
 | 
			
		||||
            return input;
 | 
			
		||||
 | 
			
		||||
        if (guild is not null)
 | 
			
		||||
        if (AliasMaps.TryGetValue(guild.Id, out var maps))
 | 
			
		||||
        {
 | 
			
		||||
            if (AliasMaps.TryGetValue(guild.Id, out var maps))
 | 
			
		||||
            var keys = maps.Keys.OrderByDescending(x => x.Length);
 | 
			
		||||
 | 
			
		||||
            foreach (var k in keys)
 | 
			
		||||
            {
 | 
			
		||||
                var keys = maps.Keys.OrderByDescending(x => x.Length);
 | 
			
		||||
                string newInput;
 | 
			
		||||
                if (input.StartsWith(k + " ", StringComparison.InvariantCultureIgnoreCase))
 | 
			
		||||
                    newInput = maps[k] + input.Substring(k.Length, input.Length - k.Length);
 | 
			
		||||
                else if (input.Equals(k, StringComparison.InvariantCultureIgnoreCase))
 | 
			
		||||
                    newInput = maps[k];
 | 
			
		||||
                else
 | 
			
		||||
                    continue;
 | 
			
		||||
 | 
			
		||||
                foreach (var k in keys)
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    string newInput;
 | 
			
		||||
                    if (input.StartsWith(k + " ", StringComparison.InvariantCultureIgnoreCase))
 | 
			
		||||
                        newInput = maps[k] + input.Substring(k.Length, input.Length - k.Length);
 | 
			
		||||
                    else if (input.Equals(k, StringComparison.InvariantCultureIgnoreCase))
 | 
			
		||||
                        newInput = maps[k];
 | 
			
		||||
                    else
 | 
			
		||||
                        continue;
 | 
			
		||||
 | 
			
		||||
                    try
 | 
			
		||||
                    var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
 | 
			
		||||
                    _ = Task.Run(async () =>
 | 
			
		||||
                    {
 | 
			
		||||
                        var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
 | 
			
		||||
                        _ = Task.Run(async () =>
 | 
			
		||||
                        await Task.Delay(1500);
 | 
			
		||||
                        await toDelete.DeleteAsync(new()
 | 
			
		||||
                        {
 | 
			
		||||
                            await Task.Delay(1500);
 | 
			
		||||
                            await toDelete.DeleteAsync(new()
 | 
			
		||||
                            {
 | 
			
		||||
                                RetryMode = RetryMode.AlwaysRetry
 | 
			
		||||
                            });
 | 
			
		||||
                            RetryMode = RetryMode.AlwaysRetry
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                    catch { }
 | 
			
		||||
 | 
			
		||||
                    return newInput;
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
                catch { }
 | 
			
		||||
 | 
			
		||||
                return newInput;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -177,7 +177,6 @@ public class GoogleApiService : IGoogleApiService, INService
 | 
			
		||||
 | 
			
		||||
    public async Task<IEnumerable<string>> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1)
 | 
			
		||||
    {
 | 
			
		||||
        await Task.Yield();
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(keywords))
 | 
			
		||||
            throw new ArgumentNullException(nameof(keywords));
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2034,9 +2034,10 @@ crypto:
 | 
			
		||||
    - "btc"
 | 
			
		||||
    - "bitcoin"
 | 
			
		||||
stock:
 | 
			
		||||
  desc: "Shows basic information about a stock. Only symbols are supported. Full company names are not supported atm."
 | 
			
		||||
  desc: "Shows basic information about a stock. You can use a symbol or company name"
 | 
			
		||||
  args:
 | 
			
		||||
    - "tsla"
 | 
			
		||||
    - "advanced micro devices"
 | 
			
		||||
    - "amd"
 | 
			
		||||
rolelevelreq:
 | 
			
		||||
  desc: "Set a level requirement on a self-assignable role."
 | 
			
		||||
 
 | 
			
		||||
@@ -888,7 +888,6 @@
 | 
			
		||||
  "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}.",
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user