Files
Finlytic/FinlyticCore/Clients/YahooFinanceClient.cs
T

729 lines
30 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Yahoo;
using FinlyticCore.Models.Settings;
using FinlyticCore.Services;
using FinlyticCore.Services.PlaywrightScrapper;
using Microsoft.Playwright;
namespace FinlyticCore.Services.Yahoo;
public class YahooFinanceClient
{
private readonly HttpClient _httpClient;
private readonly CookieContainer _cookieContainer;
private readonly IFinlyticLogger<YahooFinanceClient>? _finlyticLogger;
private readonly ISettingsService? _settingsService;
private readonly IPlaywrightExecutionService? _playwrightService;
private readonly SemaphoreSlim _authLock = new(1, 1);
private string? _crumb;
private string? _rawCookieHeader;
private DateTime _lastAuthTime = DateTime.MinValue;
/// <summary>
/// Standard modules available for the quoteSummary endpoint.
/// </summary>
public static readonly string[] StandardQuoteSummaryModules = new[]
{
"assetProfile",
"financialData",
"defaultKeyStatistics",
"summaryDetail",
"incomeStatementHistory",
"incomeStatementHistoryQuarterly",
"balanceSheetHistory",
"balanceSheetHistoryQuarterly",
"cashflowStatementHistory",
"cashflowStatementHistoryQuarterly",
"calendarEvents"
};
public YahooFinanceClient(
IFinlyticLogger<YahooFinanceClient>? finlyticLogger = null,
ISettingsService? settingsService = null,
IPlaywrightExecutionService? playwrightService = null,
HttpClient? httpClient = null)
{
_finlyticLogger = finlyticLogger;
_settingsService = settingsService;
_playwrightService = playwrightService;
_cookieContainer = new CookieContainer();
if (httpClient != null)
{
_httpClient = httpClient;
}
else
{
var handler = new HttpClientHandler
{
CookieContainer = _cookieContainer,
UseCookies = true,
AllowAutoRedirect = true
};
_httpClient = new HttpClient(handler);
_httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
}
}
/// <summary>
/// Ensures that an active Yahoo session (Cookie + dynamic Crumb token) is initialized.
/// Uses persistent DB caching and only refreshes when the crumb is invalid or forceRefresh is true.
/// </summary>
public async Task<string?> EnsureAuthenticatedAsync(bool forceRefresh = false,
CancellationToken cancellationToken = default)
{
await _authLock.WaitAsync(cancellationToken);
try
{
// 1. Check in-memory crumb
if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb))
{
return _crumb;
}
// 2. Check persistent DB cache via SettingsService
if (!forceRefresh && _settingsService != null)
{
try
{
var cachedCrumb = await _settingsService.GetSettingAsync(CoreSettingKeys.YahooAuthCrumb, cancellationToken);
var cachedCookies = await _settingsService.GetSettingAsync(CoreSettingKeys.YahooAuthCookie, cancellationToken);
if (!string.IsNullOrWhiteSpace(cachedCrumb) && !string.IsNullOrWhiteSpace(cachedCookies))
{
RestoreCookies(cachedCookies);
_crumb = cachedCrumb;
_rawCookieHeader = cachedCookies;
_lastAuthTime = DateTime.UtcNow;
if (_finlyticLogger != null)
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Restored cached Yahoo session & crumb from database ({Crumb}).", _crumb);
return _crumb;
}
}
catch (Exception ex)
{
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Error restoring cached Yahoo session from DB.");
}
}
if (_finlyticLogger != null)
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Authenticating fresh session with Yahoo (Cookie + Crumb)...");
// 3. Send GET request to fc.yahoo.com to obtain session cookie A3
try
{
using var initRequest = new HttpRequestMessage(HttpMethod.Get, "https://fc.yahoo.com");
initRequest.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
initRequest.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8");
using var initResponse = await _httpClient.SendAsync(initRequest, cancellationToken);
}
catch { }
// 4. Send GET request to getcrumb to obtain dynamic crumb token
var crumb = await FetchCrumbWithHttpClientAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(crumb))
{
_crumb = crumb;
_lastAuthTime = DateTime.UtcNow;
await PersistSessionAsync(_crumb, cancellationToken);
return _crumb;
}
// 5. FALLBACK: Playwright Browser Authentication (Bypasses EU Consent Wall and 429)
if (_playwrightService != null)
{
var (browserCrumb, browserCookies) = await AuthenticateViaPlaywrightAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(browserCrumb))
{
_crumb = browserCrumb;
_lastAuthTime = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(browserCookies))
{
RestoreCookies(browserCookies);
_rawCookieHeader = browserCookies;
}
await PersistSessionAsync(_crumb, cancellationToken);
return _crumb;
}
}
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Failed to fetch crumb token from all endpoints.");
return null;
}
catch (Exception ex)
{
if (_finlyticLogger != null)
await _finlyticLogger.LogErrorAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during Cookie & Crumb authentication.");
return null;
}
finally
{
_authLock.Release();
}
}
private async Task<string?> FetchCrumbWithHttpClientAsync(CancellationToken cancellationToken)
{
string[] crumbUrls = new[]
{
"https://query1.finance.yahoo.com/v1/test/getcrumb",
"https://query2.finance.yahoo.com/v1/test/getcrumb"
};
foreach (var url in crumbUrls)
{
try
{
using var crumbRequest = new HttpRequestMessage(HttpMethod.Get, url);
crumbRequest.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
crumbRequest.Headers.Add("Accept", "*/*");
crumbRequest.Headers.Add("Origin", "https://finance.yahoo.com");
crumbRequest.Headers.Add("Referer", "https://finance.yahoo.com/");
if (!string.IsNullOrWhiteSpace(_rawCookieHeader))
{
crumbRequest.Headers.Add("Cookie", _rawCookieHeader);
}
using var crumbResponse = await _httpClient.SendAsync(crumbRequest, cancellationToken);
var crumbText = await crumbResponse.Content.ReadAsStringAsync(cancellationToken);
if (crumbResponse.IsSuccessStatusCode && !string.IsNullOrWhiteSpace(crumbText))
{
var cleanCrumb = crumbText.Trim('"', ' ', '\t', '\r', '\n');
if (!cleanCrumb.Contains("<html", StringComparison.OrdinalIgnoreCase) && cleanCrumb.Length < 100)
{
return cleanCrumb;
}
}
else
{
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel,
"[YahooFinanceClient] Endpoint '{Url}' returned status {Status}. Response Body: {Body}",
url, crumbResponse.StatusCode, crumbText);
}
}
catch (Exception ex)
{
if (_finlyticLogger != null)
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during FetchCrumb on '{Url}'", url);
}
}
return null;
}
private async Task<(string? crumb, string? cookieStr)> AuthenticateViaPlaywrightAsync(CancellationToken cancellationToken)
{
if (_playwrightService == null) return (null, null);
try
{
if (_finlyticLogger != null)
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Launching Playwright browser to acquire valid EU Yahoo session and Crumb...");
return await _playwrightService.ExecuteInContextAsync(async context =>
{
var page = await context.NewPageAsync();
try
{
await page.GotoAsync("https://finance.yahoo.com/quote/AAPL/", new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 30_000
});
// Handle EU Consent if redirected
var url = page.Url;
if (url.Contains("consent.yahoo.com", StringComparison.OrdinalIgnoreCase) ||
url.Contains("guce.yahoo.com", StringComparison.OrdinalIgnoreCase))
{
var selectors = new[]
{
"button[name='agree']",
"button[value='agree']",
"button.accept-all",
"button.btn.primary",
"button.btn.secondary.accept-all",
"form[action*='consent'] button[type='submit']",
"button:has-text('Alle akzeptieren')",
"button:has-text('Accept all')",
"button:has-text('Akzeptieren')",
"button:has-text('Agree')"
};
foreach (var sel in selectors)
{
var btn = page.Locator(sel);
if (await btn.CountAsync() > 0 && await btn.First.IsVisibleAsync())
{
await btn.First.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15_000 });
break;
}
}
}
// 1. Extract cookies from BrowserContext
var cookies = await context.CookiesAsync();
var pairs = new List<string>();
foreach (var c in cookies)
{
if (c.Domain.Contains("yahoo.com", StringComparison.OrdinalIgnoreCase))
{
pairs.Add($"{c.Name}={c.Value}");
}
}
var extractedCookies = string.Join(";", pairs);
// 2. Fetch Crumb from inside the authenticated page
string? extractedCrumb = null;
try
{
extractedCrumb = await page.EvaluateAsync<string>(@"async () => {
try {
const res = await fetch('/v1/test/getcrumb');
if (res.ok) {
return await res.text();
}
} catch {}
return null;
}");
}
catch { }
// 3. If in-page fetch was empty, use the extracted cookies with HttpClient
if (string.IsNullOrWhiteSpace(extractedCrumb) && !string.IsNullOrWhiteSpace(extractedCookies))
{
RestoreCookies(extractedCookies);
_rawCookieHeader = extractedCookies;
extractedCrumb = await FetchCrumbWithHttpClientAsync(cancellationToken);
}
if (!string.IsNullOrWhiteSpace(extractedCrumb))
{
extractedCrumb = extractedCrumb.Trim('"', ' ', '\t', '\r', '\n');
if (_finlyticLogger != null)
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Playwright successfully acquired Yahoo Crumb token: {Crumb}", extractedCrumb);
}
return (extractedCrumb, extractedCookies);
}
finally
{
await page.CloseAsync();
}
}, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Playwright-based authentication failed.");
return (null, null);
}
}
private async Task PersistSessionAsync(string crumb, CancellationToken cancellationToken)
{
if (_settingsService == null || string.IsNullOrWhiteSpace(crumb)) return;
try
{
var serializedCookies = SerializeCookies();
await _settingsService.SetSettingAsync(CoreSettingKeys.YahooAuthCrumb, crumb, cancellationToken);
if (!string.IsNullOrWhiteSpace(serializedCookies))
{
_rawCookieHeader = serializedCookies;
await _settingsService.SetSettingAsync(CoreSettingKeys.YahooAuthCookie, serializedCookies, cancellationToken);
}
if (_finlyticLogger != null)
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Persisted valid Yahoo session & crumb to database.");
}
catch (Exception ex)
{
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Failed to persist Yahoo session to database.");
}
}
private string SerializeCookies()
{
if (!string.IsNullOrWhiteSpace(_rawCookieHeader))
{
return _rawCookieHeader;
}
try
{
var cookies = _cookieContainer.GetAllCookies();
var pairs = new List<string>();
foreach (System.Net.Cookie cookie in cookies)
{
pairs.Add($"{cookie.Name}={cookie.Value}");
}
return string.Join(";", pairs);
}
catch
{
return string.Empty;
}
}
private void RestoreCookies(string serializedCookies)
{
if (string.IsNullOrWhiteSpace(serializedCookies)) return;
_rawCookieHeader = serializedCookies;
try
{
var parts = serializedCookies.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var uris = new[]
{
new Uri("https://yahoo.com"),
new Uri("https://finance.yahoo.com"),
new Uri("https://query1.finance.yahoo.com"),
new Uri("https://query2.finance.yahoo.com"),
new Uri("https://fc.yahoo.com")
};
foreach (var part in parts)
{
var eqIdx = part.IndexOf('=');
if (eqIdx > 0 && eqIdx < part.Length - 1)
{
var name = part.Substring(0, eqIdx).Trim();
var val = part.Substring(eqIdx + 1).Trim();
foreach (var uri in uris)
{
try
{
_cookieContainer.Add(uri, new System.Net.Cookie(name, val));
}
catch { }
}
}
}
}
catch
{
// Ignore cookie restore errors
}
}
/// <summary>
/// Searches for tickers, names, ISINs, or companies via the Yahoo Finance search API.
/// </summary>
public async Task<YahooSearchResponseDto?> SearchAsync(
string query,
int quotesCount = 10,
int newsCount = 0,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query)) return null;
string[] endpoints = new[]
{
$"https://query2.finance.yahoo.com/v1/finance/search?q={Uri.EscapeDataString(query)}&quotesCount={quotesCount}&newsCount={newsCount}",
$"https://query1.finance.yahoo.com/v1/finance/search?q={Uri.EscapeDataString(query)}&quotesCount={quotesCount}&newsCount={newsCount}"
};
foreach (var url in endpoints)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
request.Headers.Add("Accept", "*/*");
if (!string.IsNullOrWhiteSpace(_rawCookieHeader))
{
request.Headers.Add("Cookie", _rawCookieHeader);
}
using var response = await _httpClient.SendAsync(request, cancellationToken);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
var dto = JsonSerializer.Deserialize<YahooSearchResponseDto>(json, GetJsonOptions());
if (dto != null)
{
return dto;
}
}
else
{
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel,
"[YahooFinanceClient] Search endpoint '{Url}' for '{Query}' returned status {Status}. Response Body: {Body}",
url, query, response.StatusCode, json);
}
}
catch (Exception ex)
{
if (_finlyticLogger != null)
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during Search endpoint '{Url}'", url);
}
}
return null;
}
/// <summary>
/// Retrieves fundamentals and company metadata using the quoteSummary endpoint.
/// </summary>
public async Task<YahooQuoteSummaryResponseDto?> GetQuoteSummaryAsync(
string symbol,
IEnumerable<string> modules,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(symbol)) return null;
var moduleList = string.Join(",", modules);
return await ExecuteWithRetryAsync(async (crumb) =>
{
string[] baseUrls = new[]
{
"https://query2.finance.yahoo.com/v10/finance/quoteSummary",
"https://query1.finance.yahoo.com/v10/finance/quoteSummary"
};
foreach (var baseUrl in baseUrls)
{
var url = $"{baseUrl}/{Uri.EscapeDataString(symbol)}?crumb={Uri.EscapeDataString(crumb)}&modules={Uri.EscapeDataString(moduleList)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
request.Headers.Add("Accept", "*/*");
request.Headers.Add("Origin", "https://finance.yahoo.com");
request.Headers.Add("Referer", "https://finance.yahoo.com/");
if (!string.IsNullOrWhiteSpace(_rawCookieHeader))
{
request.Headers.Add("Cookie", _rawCookieHeader);
}
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
var dto = JsonSerializer.Deserialize<YahooQuoteSummaryResponseDto>(responseContent, GetJsonOptions());
return (false, dto);
}
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel,
"[YahooFinanceClient] GetQuoteSummary for '{Symbol}' failed with status {Status}. URL: {Url}. Response Body: {Body}",
symbol, response.StatusCode, url, responseContent);
if (response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden ||
response.StatusCode == HttpStatusCode.TooManyRequests)
{
return (true, null);
}
}
return (false, null);
}, cancellationToken);
}
/// <summary>
/// Convenience method to fetch all standard quoteSummary modules for a given symbol.
/// </summary>
public Task<YahooQuoteSummaryResponseDto?> GetFullQuoteSummaryAsync(string symbol,
CancellationToken cancellationToken = default)
{
return GetQuoteSummaryAsync(symbol, StandardQuoteSummaryModules, cancellationToken);
}
/// <summary>
/// Retrieves historical OHLCV chart data for a given symbol.
/// </summary>
public async Task<YahooChartResponseDto?> GetChartAsync(
string symbol,
string range = "1y",
string interval = "1d",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(symbol)) return null;
return await ExecuteWithRetryAsync(async (crumb) =>
{
string[] baseUrls = new[]
{
"https://query1.finance.yahoo.com/v8/finance/chart",
"https://query2.finance.yahoo.com/v8/finance/chart"
};
foreach (var baseUrl in baseUrls)
{
var url = $"{baseUrl}/{Uri.EscapeDataString(symbol)}?range={Uri.EscapeDataString(range)}&interval={Uri.EscapeDataString(interval)}&crumb={Uri.EscapeDataString(crumb)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
request.Headers.Add("Accept", "*/*");
request.Headers.Add("Origin", "https://finance.yahoo.com");
request.Headers.Add("Referer", "https://finance.yahoo.com/");
if (!string.IsNullOrWhiteSpace(_rawCookieHeader))
{
request.Headers.Add("Cookie", _rawCookieHeader);
}
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
var dto = JsonSerializer.Deserialize<YahooChartResponseDto>(responseContent, GetJsonOptions());
return (false, dto);
}
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel,
"[YahooFinanceClient] GetChart for '{Symbol}' failed with status {Status}. URL: {Url}. Response Body: {Body}",
symbol, response.StatusCode, url, responseContent);
if (response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden ||
response.StatusCode == HttpStatusCode.TooManyRequests)
{
return (true, null);
}
}
return (false, null);
}, cancellationToken);
}
/// <summary>
/// Retrieves quick real-time price quotes for one or more symbols.
/// </summary>
public async Task<YahooQuoteResponseDto?> GetQuotesAsync(
IEnumerable<string> symbols,
CancellationToken cancellationToken = default)
{
var symbolList = symbols.Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
if (symbolList.Count == 0) return null;
var symbolsParam = string.Join(",", symbolList);
return await ExecuteWithRetryAsync(async (crumb) =>
{
string[] baseUrls = new[]
{
"https://query1.finance.yahoo.com/v7/finance/quote",
"https://query2.finance.yahoo.com/v7/finance/quote"
};
foreach (var baseUrl in baseUrls)
{
var url = $"{baseUrl}?symbols={Uri.EscapeDataString(symbolsParam)}&crumb={Uri.EscapeDataString(crumb)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
request.Headers.Add("Accept", "*/*");
request.Headers.Add("Origin", "https://finance.yahoo.com");
request.Headers.Add("Referer", "https://finance.yahoo.com/");
if (!string.IsNullOrWhiteSpace(_rawCookieHeader))
{
request.Headers.Add("Cookie", _rawCookieHeader);
}
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
var dto = JsonSerializer.Deserialize<YahooQuoteResponseDto>(responseContent, GetJsonOptions());
return (false, dto);
}
if (_finlyticLogger != null)
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel,
"[YahooFinanceClient] GetQuotes failed with status {Status}. URL: {Url}. Response Body: {Body}",
response.StatusCode, url, responseContent);
if (response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden ||
response.StatusCode == HttpStatusCode.TooManyRequests)
{
return (true, null);
}
}
return (false, null);
}, cancellationToken);
}
/// <summary>
/// Convenient helper method to fetch the current live price for a single symbol (e.g., "^VIX").
/// </summary>
public async Task<decimal?> GetLivePriceAsync(string symbol, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(symbol)) return null;
var quotes = await GetQuotesAsync(new[] { symbol }, cancellationToken);
var item = quotes?.QuoteResponse?.Result?.FirstOrDefault();
if (item?.RegularMarketPrice.HasValue == true && item.RegularMarketPrice.Value > 0)
{
return Convert.ToDecimal(item.RegularMarketPrice.Value);
}
return null;
}
private async Task<T?> ExecuteWithRetryAsync<T>(
Func<string, Task<(bool isAuthError, T? result)>> action,
CancellationToken cancellationToken) where T : class
{
var crumb = await EnsureAuthenticatedAsync(false, cancellationToken);
if (!string.IsNullOrEmpty(crumb))
{
var (isAuthError, result) = await action(crumb);
if (!isAuthError && result != null)
{
return result;
}
if (!isAuthError)
{
return result;
}
}
// Re-authenticate when auth error (401/403/429) or empty crumb occurs
if (_finlyticLogger != null)
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Authentication error or invalid crumb encountered (401/403/429). Force re-authenticating...");
crumb = await EnsureAuthenticatedAsync(true, cancellationToken);
if (string.IsNullOrEmpty(crumb)) return null;
var (_, retryResult) = await action(crumb);
return retryResult;
}
private static JsonSerializerOptions GetJsonOptions()
{
return new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
}