mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-10 17:28:27 -04: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 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);
|
input = input.Replace("#", "", StringComparison.InvariantCulture);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return TypeReaderResult.FromSuccess(Color.ParseHex(input));
|
return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input)));
|
||||||
}
|
}
|
||||||
catch
|
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)
|
private async Task InitializeVcRole(GuildConfig gconf)
|
||||||
{
|
{
|
||||||
await Task.Yield();
|
|
||||||
var g = _client.GetGuild(gconf.GuildId);
|
var g = _client.GetGuild(gconf.GuildId);
|
||||||
if (g is null)
|
if (g is null)
|
||||||
return;
|
return;
|
||||||
@@ -120,11 +119,12 @@ public class VcRoleService : INService
|
|||||||
if (missingRoles.Any())
|
if (missingRoles.Any())
|
||||||
{
|
{
|
||||||
await using var uow = _db.GetDbContext();
|
await using var uow = _db.GetDbContext();
|
||||||
Log.Warning("Removing {MissingRoleCount} missing roles from {ServiceName}",
|
|
||||||
missingRoles.Count,
|
|
||||||
nameof(VcRoleService));
|
|
||||||
uow.RemoveRange(missingRoles);
|
uow.RemoveRange(missingRoles);
|
||||||
await uow.SaveChangesAsync();
|
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
|
// todo autoplay/fairplay
|
||||||
public partial class Searches
|
public partial class Searches
|
||||||
{
|
{
|
||||||
public partial class CryptoCommands : NadekoSubmodule<CryptoService>
|
public partial class FinanceCommands : NadekoSubmodule<CryptoService>
|
||||||
{
|
{
|
||||||
private readonly IStockDataService _stocksService;
|
private readonly IStockDataService _stocksService;
|
||||||
|
private readonly IStockChartDrawingService _stockDrawingService;
|
||||||
|
|
||||||
public CryptoCommands(IStockDataService stocksService)
|
public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService)
|
||||||
{
|
{
|
||||||
_stocksService = stocksService;
|
_stocksService = stocksService;
|
||||||
|
_stockDrawingService = stockDrawingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Cmd]
|
[Cmd]
|
||||||
@@ -22,9 +24,9 @@ public partial class Searches
|
|||||||
{
|
{
|
||||||
using var typing = ctx.Channel.EnterTypingState();
|
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);
|
var symbols = await _stocksService.SearchSymbolAsync(query);
|
||||||
|
|
||||||
@@ -43,34 +45,18 @@ public partial class Searches
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
query = symbol.Symbol;
|
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);
|
await ReplyErrorLocalizedAsync(strs.not_found);
|
||||||
return;
|
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();
|
var localCulture = (CultureInfo)Culture.Clone();
|
||||||
localCulture.NumberFormat.CurrencySymbol = "$";
|
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 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 price = stock.Price.ToString("C2", localCulture);
|
||||||
|
|
||||||
var eb = _eb.Create()
|
var eb = _eb.Create()
|
||||||
.WithOkColor()
|
.WithOkColor()
|
||||||
.WithAuthor(stock.Ticker)
|
.WithAuthor(stock.Symbol)
|
||||||
|
.WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}")
|
||||||
.WithTitle(stock.Name)
|
.WithTitle(stock.Name)
|
||||||
.AddField(GetText(strs.price), $"{sign} **{price}**", true)
|
.AddField(GetText(strs.price), $"{sign} **{price}**", true)
|
||||||
.AddField(GetText(strs.market_cap), stock.MarketCap.ToString("C0", localCulture), 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)
|
.AddField("Change 200d", $"{sign200}{change200}", true)
|
||||||
.WithFooter(stock.Exchange);
|
.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;
|
using YahooFinanceApi;
|
||||||
|
|
||||||
namespace NadekoBot.Modules.Searches;
|
namespace NadekoBot.Modules.Searches;
|
||||||
@@ -9,16 +8,14 @@ public sealed class DefaultStockDataService : IStockDataService, INService
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
public DefaultStockDataService(IHttpClientFactory httpClientFactory)
|
public DefaultStockDataService(IHttpClientFactory httpClientFactory)
|
||||||
{
|
=> _httpClientFactory = httpClientFactory;
|
||||||
_httpClientFactory = httpClientFactory;
|
|
||||||
}
|
public async Task<StockData?> GetStockDataAsync(string query)
|
||||||
|
|
||||||
public async Task<IReadOnlyCollection<StockData>> GetStockDataAsync(string query)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!query.IsAlphaNumeric())
|
if (!query.IsAlphaNumeric())
|
||||||
return Array.Empty<StockData>();
|
return default;
|
||||||
|
|
||||||
var symbols = await Yahoo.Symbols(query)
|
var symbols = await Yahoo.Symbols(query)
|
||||||
.Fields(Field.LongName,
|
.Fields(Field.LongName,
|
||||||
@@ -31,28 +28,30 @@ public sealed class DefaultStockDataService : IStockDataService, INService
|
|||||||
Field.AverageDailyVolume10Day,
|
Field.AverageDailyVolume10Day,
|
||||||
Field.FullExchangeName)
|
Field.FullExchangeName)
|
||||||
.QueryAsync();
|
.QueryAsync();
|
||||||
|
|
||||||
|
var symbol = symbols.Values.FirstOrDefault();
|
||||||
|
|
||||||
|
if (symbol is null)
|
||||||
|
return default;
|
||||||
|
|
||||||
return symbols
|
return new()
|
||||||
.Select(static x => x.Value)
|
{
|
||||||
.Select(static x => new StockData()
|
Name = symbol.LongName,
|
||||||
{
|
Symbol = symbol.Symbol,
|
||||||
Name = x.LongName,
|
Price = symbol.RegularMarketPrice,
|
||||||
Ticker = x.Symbol,
|
Close = symbol.RegularMarketPreviousClose,
|
||||||
Price = x.RegularMarketPrice,
|
MarketCap = symbol.MarketCap,
|
||||||
Close = x.RegularMarketPreviousClose,
|
Change50d = symbol.FiftyDayAverageChangePercent,
|
||||||
MarketCap = x.MarketCap,
|
Change200d = symbol.TwoHundredDayAverageChangePercent,
|
||||||
Change50d = x.FiftyDayAverageChangePercent,
|
DailyVolume = symbol.AverageDailyVolume10Day,
|
||||||
Change200d = x.TwoHundredDayAverageChangePercent,
|
Exchange = symbol.FullExchangeName
|
||||||
DailyVolume = x.AverageDailyVolume10Day,
|
};
|
||||||
Exchange = x.FullExchangeName
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// what the hell is this api exception
|
// what the hell is this api exception
|
||||||
Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.Message);
|
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();
|
.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 interface IStockDataService
|
||||||
{
|
{
|
||||||
public Task<IReadOnlyCollection<StockData>> GetStockDataAsync(string query);
|
public Task<StockData?> GetStockDataAsync(string symbol);
|
||||||
Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query);
|
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 class StockData
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string Ticker { get; set; }
|
public string Symbol { get; set; }
|
||||||
public double Price { get; set; }
|
public double Price { get; set; }
|
||||||
public long MarketCap { get; set; }
|
public long MarketCap { get; set; }
|
||||||
public double Close { get; set; }
|
public double Close { get; set; }
|
||||||
|
@@ -51,43 +51,38 @@ public class CommandMapService : IInputTransformer, INService
|
|||||||
IUser user,
|
IUser user,
|
||||||
string input)
|
string input)
|
||||||
{
|
{
|
||||||
await Task.Yield();
|
|
||||||
|
|
||||||
if (guild is null || string.IsNullOrWhiteSpace(input))
|
if (guild is null || string.IsNullOrWhiteSpace(input))
|
||||||
return 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;
|
var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
|
||||||
if (input.StartsWith(k + " ", StringComparison.InvariantCultureIgnoreCase))
|
_ = Task.Run(async () =>
|
||||||
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}");
|
await Task.Delay(1500);
|
||||||
_ = Task.Run(async () =>
|
await toDelete.DeleteAsync(new()
|
||||||
{
|
{
|
||||||
await Task.Delay(1500);
|
RetryMode = RetryMode.AlwaysRetry
|
||||||
await toDelete.DeleteAsync(new()
|
|
||||||
{
|
|
||||||
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)
|
public async Task<IEnumerable<string>> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1)
|
||||||
{
|
{
|
||||||
await Task.Yield();
|
|
||||||
if (string.IsNullOrWhiteSpace(keywords))
|
if (string.IsNullOrWhiteSpace(keywords))
|
||||||
throw new ArgumentNullException(nameof(keywords));
|
throw new ArgumentNullException(nameof(keywords));
|
||||||
|
|
||||||
|
@@ -2034,9 +2034,10 @@ crypto:
|
|||||||
- "btc"
|
- "btc"
|
||||||
- "bitcoin"
|
- "bitcoin"
|
||||||
stock:
|
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:
|
args:
|
||||||
- "tsla"
|
- "tsla"
|
||||||
|
- "advanced micro devices"
|
||||||
- "amd"
|
- "amd"
|
||||||
rolelevelreq:
|
rolelevelreq:
|
||||||
desc: "Set a level requirement on a self-assignable role."
|
desc: "Set a level requirement on a self-assignable role."
|
||||||
|
@@ -888,7 +888,6 @@
|
|||||||
"volume_24h": "Volume (24h)",
|
"volume_24h": "Volume (24h)",
|
||||||
"change_7d_24h": "Change (7d / 24h)",
|
"change_7d_24h": "Change (7d / 24h)",
|
||||||
"crypto_not_found": "CryptoCurrency with that name was not found.",
|
"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}?",
|
"did_you_mean": "Did you mean {0}?",
|
||||||
"self_assign_level_req": "Self assignable role {0} now requires at least server level {1}.",
|
"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}.",
|
"self_assign_not_level": "That self-assignable role requires at least server level {0}.",
|
||||||
|
Reference in New Issue
Block a user