fixed .stock command, probably
This commit is contained in:
parent
ba1bc1732e
commit
34ba6e782b
4 changed files with 163 additions and 43 deletions
|
@ -2,6 +2,8 @@
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace EllieBot.Modules.Searches;
|
namespace EllieBot.Modules.Searches;
|
||||||
|
@ -9,54 +11,57 @@ namespace EllieBot.Modules.Searches;
|
||||||
public sealed class DefaultStockDataService : IStockDataService, IEService
|
public sealed class DefaultStockDataService : IStockDataService, IEService
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IBotCache _cache;
|
||||||
|
|
||||||
public DefaultStockDataService(IHttpClientFactory httpClientFactory)
|
public DefaultStockDataService(IHttpClientFactory httpClientFactory, IBotCache cache)
|
||||||
=> _httpClientFactory = httpClientFactory;
|
=> (_httpClientFactory, _cache) = (httpClientFactory, cache);
|
||||||
|
|
||||||
|
private static TypedKey<StockData> GetStockDataKey(string query)
|
||||||
|
=> new($"stockdata:{query}");
|
||||||
|
|
||||||
public async Task<StockData?> GetStockDataAsync(string query)
|
public async Task<StockData?> GetStockDataAsync(string query)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
||||||
|
|
||||||
|
return await _cache.GetOrAddAsync(GetStockDataKey(query.Trim().ToLowerInvariant()),
|
||||||
|
() => GetStockDataInternalAsync(query),
|
||||||
|
expiry: TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StockData?> GetStockDataInternalAsync(string query)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!query.IsAlphaNumeric())
|
if (!query.IsAlphaNumeric())
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
using var http = _httpClientFactory.CreateClient();
|
var info = await GetNasdaqDataResponse<NasdaqSummaryResponse>(
|
||||||
|
$"https://api.nasdaq.com/api/quote/{query}/summary?assetclass=stocks");
|
||||||
|
|
||||||
var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}";
|
if (info?.Data is not { } d || d.SummaryData is not { } sd)
|
||||||
|
|
||||||
var config = Configuration.Default.WithDefaultLoader();
|
|
||||||
using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage);
|
|
||||||
|
|
||||||
var tickerName = document.QuerySelector("div.top > .left > .container > h1")
|
|
||||||
?.TextContent;
|
|
||||||
|
|
||||||
if (tickerName is null)
|
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
var marketcap = document
|
|
||||||
.QuerySelector("li > span > fin-streamer[data-field='marketCap']")
|
|
||||||
?.TextContent;
|
|
||||||
|
|
||||||
|
var closePrice = double.Parse(sd.PreviousClose.Value?.Substring(1) ?? "0",
|
||||||
|
NumberStyles.Any,
|
||||||
|
CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']")
|
var price = d.BidAsk.Bid.Value.IndexOf('*') is var idx and > 0
|
||||||
?.TextContent;
|
&& double.TryParse(d.BidAsk.Bid.Value.Substring(1, idx - 1),
|
||||||
|
NumberStyles.Any,
|
||||||
var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']")
|
CultureInfo.InvariantCulture,
|
||||||
?.TextContent
|
out var bid)
|
||||||
?? "0";
|
? bid
|
||||||
|
: double.NaN;
|
||||||
var price = document.QuerySelector("fin-streamer.livePrice > span")
|
|
||||||
?.TextContent
|
|
||||||
?? "0";
|
|
||||||
|
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
Name = tickerName,
|
Name = query,
|
||||||
Symbol = query,
|
Symbol = info.Data.Symbol,
|
||||||
Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture),
|
Price = price,
|
||||||
Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture),
|
Close = closePrice,
|
||||||
MarketCap = marketcap,
|
MarketCap = sd.MarketCap.Value,
|
||||||
DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
|
DailyVolume =
|
||||||
|
(long)double.Parse(sd.AverageVolume.Value ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
@ -66,6 +71,36 @@ public sealed class DefaultStockDataService : IStockDataService, IEService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<NasdaqDataResponse<T>?> GetNasdaqDataResponse<T>(string url)
|
||||||
|
{
|
||||||
|
using var httpClient = _httpClientFactory.CreateClient("google:search");
|
||||||
|
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get,
|
||||||
|
url)
|
||||||
|
{
|
||||||
|
Headers =
|
||||||
|
{
|
||||||
|
{ "Host", "api.nasdaq.com" },
|
||||||
|
{ "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0" },
|
||||||
|
{ "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
|
||||||
|
{ "Accept-Language", "en-US,en;q=0.5" },
|
||||||
|
{ "Accept-Encoding", "gzip, deflate, br, zstd" },
|
||||||
|
{ "Connection", "keep-alive" },
|
||||||
|
{ "Upgrade-Insecure-Requests", "1" },
|
||||||
|
{ "Sec-Fetch-Dest", "document" },
|
||||||
|
{ "Sec-Fetch-Mode", "navigate" },
|
||||||
|
{ "Sec-Fetch-Site", "none" },
|
||||||
|
{ "Sec-Fetch-User", "?1" },
|
||||||
|
{ "Priority", "u=0, i" },
|
||||||
|
{ "TE", "trailers" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var res = await httpClient.SendAsync(req);
|
||||||
|
|
||||||
|
var info = await res.Content.ReadFromJsonAsync<NasdaqDataResponse<T>>();
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
|
public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
@ -91,22 +126,37 @@ public sealed class DefaultStockDataService : IStockDataService, IEService
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture);
|
private static TypedKey<IReadOnlyCollection<CandleData>> GetCandleDataKey(string query)
|
||||||
|
=> new($"candledata:{query}");
|
||||||
|
|
||||||
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
|
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
|
||||||
|
=> await _cache.GetOrAddAsync(GetCandleDataKey(query),
|
||||||
|
async () => await GetCandleDataInternalAsync(query),
|
||||||
|
expiry: TimeSpan.FromHours(4))
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataInternalAsync(string query)
|
||||||
{
|
{
|
||||||
using var http = _httpClientFactory.CreateClient();
|
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);
|
var now = DateTime.UtcNow;
|
||||||
using var csv = new CsvReader(textReader, _csvConfig);
|
var fromdate = now.Subtract(30.Days()).ToString("yyyy-MM-dd");
|
||||||
var records = csv.GetRecords<YahooFinanceCandleData>().ToArray();
|
var todate = now.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
return records
|
var res = await GetNasdaqDataResponse<NasdaqChartResponse>(
|
||||||
.Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume));
|
$"https://api.nasdaq.com/api/quote/{query}/chart?assetclass=stocks"
|
||||||
|
+ $"&fromdate={fromdate}"
|
||||||
|
+ $"&todate={todate}");
|
||||||
|
|
||||||
|
if (res?.Data?.Chart is not { } chart)
|
||||||
|
return Array.Empty<CandleData>();
|
||||||
|
|
||||||
|
|
||||||
|
return chart.Select(d => new CandleData(d.Z.Open,
|
||||||
|
d.Z.Close,
|
||||||
|
d.Z.High,
|
||||||
|
d.Z.Low,
|
||||||
|
(long)double.Parse(d.Z.Volume, NumberStyles.Any, CultureInfo.InvariantCulture)))
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public sealed class NasdaqChartResponse
|
||||||
|
{
|
||||||
|
public required NasdaqChartResponseData[] Chart { get; init; }
|
||||||
|
|
||||||
|
public sealed class NasdaqChartResponseData
|
||||||
|
{
|
||||||
|
public required CandleData Z { get; init; }
|
||||||
|
|
||||||
|
public sealed class CandleData
|
||||||
|
{
|
||||||
|
public required decimal High { get; init; }
|
||||||
|
public required decimal Low { get; init; }
|
||||||
|
public required decimal Open { get; init; }
|
||||||
|
public required decimal Close { get; init; }
|
||||||
|
public required string Volume { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public sealed class NasdaqDataResponse<T>
|
||||||
|
{
|
||||||
|
public required T? Data { get; init; }
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public sealed class NasdaqSummaryResponse
|
||||||
|
{
|
||||||
|
public required string Symbol { get; init; }
|
||||||
|
|
||||||
|
public required NasdaqSummaryResponseData SummaryData { get; init; }
|
||||||
|
public required NasdaqSummaryBidAsk BidAsk { get; init; }
|
||||||
|
|
||||||
|
public sealed class NasdaqSummaryBidAsk
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Bid * Size")]
|
||||||
|
public required NasdaqBid Bid { get; init; }
|
||||||
|
|
||||||
|
public sealed class NasdaqBid
|
||||||
|
{
|
||||||
|
public required string Value { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NasdaqSummaryResponseData
|
||||||
|
{
|
||||||
|
public required PreviousCloseData PreviousClose { get; init; }
|
||||||
|
public required MarketCapData MarketCap { get; init; }
|
||||||
|
public required AverageVolumeData AverageVolume { get; init; }
|
||||||
|
|
||||||
|
public sealed class PreviousCloseData
|
||||||
|
{
|
||||||
|
public required string Value { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MarketCapData
|
||||||
|
{
|
||||||
|
public required string Value { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AverageVolumeData
|
||||||
|
{
|
||||||
|
public required string Value { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue