feat(Core): update DTOs and shared models

This commit is contained in:
2026-08-09 21:01:38 +02:00
parent 6337e63a77
commit 5475c3ac51
58 changed files with 3418 additions and 30 deletions
@@ -0,0 +1,239 @@
using System;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models.TradeRepublic;
using FinlyticCore.Util;
using Microsoft.Extensions.Logging;
namespace FinlyticCore.Services.TradeRepublic;
/// <summary>
/// A managed, thread-safe WebSocket client designed to communicate with the Trade Republic API.
/// Supports both single RPC requests and real-time live ticker subscriptions (e.g. {isin}.TIB).
/// </summary>
public class TradeRepublicClient : ManagedWebSocket
{
private readonly ILogger<TradeRepublicClient> _logger;
private int _currentSub;
private readonly ConcurrentDictionary<int, TaskCompletionSource<ReceivedMessage>> _pendingRequests = new();
private readonly ConcurrentDictionary<int, Action<string>> _tickerSubscriptions = new();
public event Action<ReceivedMessage>? UnhandledMessageReceived;
public event Action<string>? SystemMessageReceived;
/// <summary>
/// Initializes a new instance of the <see cref="TradeRepublicClient"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public TradeRepublicClient(ILogger<TradeRepublicClient> logger)
{
_logger = logger;
}
/// <summary>
/// Connects to the Trade Republic WebSocket API.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a boolean indicating whether the connection was successful.</returns>
public async Task<bool> InitAsync(CancellationToken cancellationToken = default)
{
if (IsConnected) return true;
await ConnectAsync("wss://api.traderepublic.com/", TimeSpan.FromSeconds(10));
var tcs = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests.TryAdd(-1, tcs);
try
{
var json = JsonSerializer.Serialize(new TradeRepublicConnectRequest(), typeof(TradeRepublicConnectRequest), FinlyticJsonSerializerContext.Default);
await SendAsync($"connect 34 {json}");
var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
if (string.IsNullOrWhiteSpace(res.Type)) return false;
var isConnected = res.Type == "connected";
if (isConnected)
{
_logger.LogInformation("[{Channel}] WebSocket connection to Trade Republic established.", "TradeRepublicChannel");
}
return isConnected;
}
catch (Exception ex)
{
_pendingRequests.TryRemove(-1, out _);
_logger.LogWarning(ex, "[{Channel}] Failed or timed out establishing Trade Republic WebSocket connection.", "TradeRepublicChannel");
return false;
}
}
/// <summary>
/// Sends a JSON request to the Trade Republic WebSocket API and waits for the response.
/// </summary>
/// <typeparam name="TResponse">The expected response type.</typeparam>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <param name="request">The request to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the request failed or timed out.</returns>
public async Task<TResponse?> SendRequestAsync<TResponse, TRequest>(TRequest request, CancellationToken cancellationToken = default)
where TResponse : class where TRequest : class
{
var tempSub = Interlocked.Increment(ref _currentSub);
var msg = $"sub {tempSub} {JsonSerializer.Serialize(request, typeof(TRequest), FinlyticJsonSerializerContext.Default)}";
_logger.LogDebug("[{Channel}] TR WS Sent (Request): {Message}", "TradeRepublicChannel", msg);
var tcs = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests.TryAdd(tempSub, tcs);
await SendAsync(msg);
try
{
var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(8), cancellationToken);
if (string.IsNullOrWhiteSpace(res.Data) || !res.Type.Contains('A')) return null;
return (TResponse?)JsonSerializer.Deserialize(res.Data, typeof(TResponse), FinlyticJsonSerializerContext.Default);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Error waiting for Trade Republic response ID {SubId}", "TradeRepublicChannel", tempSub);
return null;
}
finally
{
_pendingRequests.TryRemove(tempSub, out _);
try { await SendAsync($"unsub {tempSub}"); } catch { }
}
}
/// <summary>
/// Subscribes to the real-time ticker stream for a specific ISIN (e.g., US5398301094.TIB).
/// </summary>
public async Task<int?> SubscribeTickerAsync(string isin, Action<TradeRepublicTickerResponse> onTick, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim().ToUpperInvariant();
var tickerId = cleanIsin.EndsWith(".TIB") ? cleanIsin : $"{cleanIsin}.TIB";
var tempSub = Interlocked.Increment(ref _currentSub);
var req = new TradeRepublicTickerRequest(tickerId);
var msg = $"sub {tempSub} {JsonSerializer.Serialize(req, typeof(TradeRepublicTickerRequest), FinlyticJsonSerializerContext.Default)}";
_tickerSubscriptions[tempSub] = jsonPayload =>
{
// Skip empty or non-JSON payloads (e.g. TR protocol ack messages)
if (string.IsNullOrWhiteSpace(jsonPayload) || (!jsonPayload.TrimStart().StartsWith('{') && !jsonPayload.TrimStart().StartsWith('[')))
return;
try
{
var tickerRes = (TradeRepublicTickerResponse?)JsonSerializer.Deserialize(jsonPayload, typeof(TradeRepublicTickerResponse), FinlyticJsonSerializerContext.Default);
if (tickerRes != null)
{
onTick(tickerRes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Failed to parse real-time ticker payload for {TickerId}", "TradeRepublicChannel", tickerId);
}
};
_logger.LogInformation("[{Channel}] Subscribing to Trade Republic real-time ticker {TickerId} (Sub ID: {SubId})", "TradeRepublicChannel", tickerId, tempSub);
_logger.LogDebug("[{Channel}] TR WS Sent: {Message}", "TradeRepublicChannel", msg);
await SendAsync(msg);
return tempSub;
}
/// <summary>
/// Unsubscribes from a real-time ticker stream.
/// </summary>
/// <param name="subId">The subscription ID to unsubscribe.</param>
/// <returns>A task representing the async operation.</returns>
public async Task UnsubscribeTickerAsync(int subId)
{
_tickerSubscriptions.TryRemove(subId, out _);
try
{
await SendAsync($"unsub {subId}");
}
catch { }
}
/// <inheritdoc />
protected override void OnMessageReceived(string message)
{
if (string.IsNullOrWhiteSpace(message)) return;
_logger.LogDebug("[{Channel}] TR WS Recv: {Message}", "TradeRepublicChannel", message);
// Trade Republic message formats:
// "34 connected" -> subId = 34, type = "connected", payload = "connected"
// "22A {...}" or "22A{...}" -> subId = 22, type = "A", payload = "{...}"
var digitLen = 0;
while (digitLen < message.Length && char.IsDigit(message[digitLen]))
{
digitLen++;
}
if (digitLen == 0)
{
SystemMessageReceived?.Invoke(message);
return;
}
if (!int.TryParse(message.Substring(0, digitLen), out var subId))
{
SystemMessageReceived?.Invoke(message);
return;
}
var remainder = message.Substring(digitLen).TrimStart();
string type;
string payload;
if (remainder.StartsWith("connected"))
{
type = "connected";
payload = remainder;
}
else if (remainder.Length > 0)
{
// Type is usually a single character like 'A' or 'E'
// The JSON payload (or ack) starts immediately after or after a space
type = remainder[0].ToString();
payload = remainder.Substring(1).TrimStart();
}
else
{
type = "ack";
payload = string.Empty;
}
var received = new ReceivedMessage(subId, type, payload);
if (_pendingRequests.TryGetValue(subId, out var tcs))
{
tcs.TrySetResult(received);
}
if (_tickerSubscriptions.TryGetValue(subId, out var handler))
{
handler(payload);
}
UnhandledMessageReceived?.Invoke(received);
}
}
/// <summary>
/// Represents a received message from the Trade Republic WebSocket.
/// </summary>
/// <param name="SubId">The subscription ID.</param>
/// <param name="Type">The message type.</param>
/// <param name="Data">The payload data.</param>
public record ReceivedMessage(int SubId, string Type, string Data);
@@ -0,0 +1,201 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using FinlyticCore.Models.TradeRepublic;
using FinlyticCore.Models.Assets;
using Microsoft.Extensions.Logging;
namespace FinlyticCore.Services.TradeRepublic;
/// <summary>
/// Service for interacting with the Trade Republic API.
/// </summary>
public interface ITradeRepublicService
{
/// <summary>
/// Fetches asset metadata from Trade Republic by ISIN.
/// </summary>
/// <param name="isin">The ISIN to search for.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The Trade Republic search response, or null if not found/failed.</returns>
Task<TradeRepublicAssetResponse?> GetAsset(string isin, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the total count of available assets grouped by their types.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>An <see cref="AssetsCount"/> object containing the metrics.</returns>
Task<AssetsCount> GetAssetsCount(CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a paginated chunk of assets filtered by a specific type.
/// </summary>
/// <param name="type">The type of assets to retrieve.</param>
/// <param name="page">The zero-based page index.</param>
/// <param name="pageSize">The number of elements per page.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A <see cref="TradeRepublicAssetResponse"/> containing the elements, or null if the request fails.</returns>
Task<TradeRepublicAssetResponse?> GetAssets(AssetType type, int page, int pageSize, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to the real-time ticker stream for a specific ISIN.
/// </summary>
/// <param name="isin">The ISIN.</param>
/// <param name="onTick">The callback action when a tick is received.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The subscription ID, or null if failed.</returns>
Task<int?> SubscribeRealtimeTickerAsync(string isin, Action<TradeRepublicTickerResponse> onTick, CancellationToken cancellationToken = default);
/// <summary>
/// Unsubscribes from a real-time ticker stream.
/// </summary>
/// <param name="subId">The subscription ID to unsubscribe.</param>
/// <returns>A task representing the async operation.</returns>
Task UnsubscribeRealtimeTickerAsync(int subId);
}
public class TradeRepublicService : ITradeRepublicService, IDisposable
{
private readonly TradeRepublicClient _client;
private readonly ILogger<TradeRepublicService> _logger;
private readonly System.Timers.Timer _inactivityTimer;
private readonly SemaphoreSlim _lock = new(1, 1);
public TradeRepublicService(TradeRepublicClient client, ILogger<TradeRepublicService> logger)
{
_client = client;
_logger = logger;
_inactivityTimer = new System.Timers.Timer(TimeSpan.FromSeconds(461).TotalMilliseconds);
_inactivityTimer.AutoReset = false;
_inactivityTimer.Elapsed += OnInactivityTimeout;
}
private async Task EnsureConnectedAsync()
{
await _lock.WaitAsync();
try
{
_inactivityTimer.Stop();
if (!_client.IsConnected)
{
_logger.LogInformation("[{Channel}] Connecting to Trade Republic API WebSocket...", "TradeRepublicChannel");
bool connected = await _client.InitAsync();
if (!connected)
{
_logger.LogWarning("[{Channel}] Trade Republic WebSocket connection failed or timed out.", "TradeRepublicChannel");
throw new InvalidOperationException("Trade Republic WebSocket is not connected.");
}
_logger.LogInformation("[{Channel}] Successfully connected to Trade Republic API.", "TradeRepublicChannel");
}
_inactivityTimer.Start();
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />
public async Task<TradeRepublicAssetResponse?> GetAsset(string isin, CancellationToken cancellationToken = default)
{
try
{
await EnsureConnectedAsync();
var reqData = new TradeRepublicSearchData
{
Query = isin,
Page = 1,
PageSize = 1,
Filter = new[] { new TradeRepublicFilter("jurisdiction", "DE") }
};
var request = new TradeRepublicSearchRequest(Data: reqData);
return await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Error while fetching asset metadata for ISIN {Isin}", "TradeRepublicChannel", isin);
return null;
}
}
/// <inheritdoc />
public async Task<AssetsCount> GetAssetsCount(CancellationToken cancellationToken = default)
{
await EnsureConnectedAsync();
var counts = new AssetsCount();
foreach (var type in Enum.GetValues<AssetType>())
{
var reqData = new TradeRepublicSearchData
{
Query = "",
Page = 1,
PageSize = 1,
Filter = new[]
{
new TradeRepublicFilter("type", type.ToString().ToLowerInvariant()),
new TradeRepublicFilter("jurisdiction", "DE")
}
};
var request = new TradeRepublicSearchRequest(Data: reqData);
var response = await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request, cancellationToken);
var count = response?.ResultCount ?? 0;
counts.SetCountOfType(type, count);
await Task.Delay(TimeSpan.FromMilliseconds(320), cancellationToken);
}
return counts;
}
/// <inheritdoc />
public async Task<TradeRepublicAssetResponse?> GetAssets(AssetType type, int page, int pageSize, CancellationToken cancellationToken = default)
{
await EnsureConnectedAsync();
var reqData = new TradeRepublicSearchData
{
Query = "",
Page = page,
PageSize = pageSize,
Filter = new[]
{
new TradeRepublicFilter("type", type.ToString().ToLowerInvariant()),
new TradeRepublicFilter("jurisdiction", "DE")
}
};
var request = new TradeRepublicSearchRequest(Data: reqData);
return await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request, cancellationToken);
}
/// <inheritdoc />
public async Task<int?> SubscribeRealtimeTickerAsync(string isin, Action<TradeRepublicTickerResponse> onTick, CancellationToken cancellationToken = default)
{
await EnsureConnectedAsync();
return await _client.SubscribeTickerAsync(isin, onTick, cancellationToken);
}
/// <inheritdoc />
public async Task UnsubscribeRealtimeTickerAsync(int subId)
{
await _client.UnsubscribeTickerAsync(subId);
}
private async void OnInactivityTimeout(object? sender, ElapsedEventArgs e)
{
try
{
await _lock.WaitAsync();
if (!_client.IsConnected) return;
_logger.LogInformation("[{Channel}] Inactivity timer expired. Auto-disconnecting Trade Republic WebSocket.", "TradeRepublicChannel");
await _client.DisconnectAsync();
}
catch { }
finally { _lock.Release(); }
}
public void Dispose()
{
_inactivityTimer.Dispose();
_lock.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,332 @@
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 Microsoft.Extensions.Logging;
namespace FinlyticCore.Services.Yahoo;
/// <summary>
/// Managed thread-safe HTTP client for Yahoo Finance APIs.
/// Implements the two-step Cookie (A3) & Crumb token authentication flow.
/// </summary>
public class YahooFinanceClient
{
private const string DefaultUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
private readonly HttpClient _httpClient;
private readonly CookieContainer _cookieContainer;
private readonly ILogger<YahooFinanceClient>? _logger;
private readonly SemaphoreSlim _authLock = new(1, 1);
private string? _crumb;
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(ILogger<YahooFinanceClient>? logger = null, HttpClient? httpClient = null)
{
_logger = logger;
_cookieContainer = new CookieContainer();
if (httpClient != null)
{
_httpClient = httpClient;
}
else
{
var handler = new HttpClientHandler
{
CookieContainer = _cookieContainer,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
_httpClient = new HttpClient(handler);
}
if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
{
_httpClient.DefaultRequestHeaders.Add("User-Agent", DefaultUserAgent);
}
}
/// <summary>
/// Executes the Cookie (A3) &amp; Crumb token authentication flow.
/// 1. GET https://fc.yahoo.com (sets session A3 cookie)
/// 2. GET https://query1.finance.yahoo.com/v1/test/getcrumb (returns crumb string)
/// </summary>
public async Task<string?> EnsureAuthenticatedAsync(bool forceRefresh = false,
CancellationToken cancellationToken = default)
{
if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb) && (DateTime.UtcNow - _lastAuthTime).TotalHours < 12)
{
return _crumb;
}
await _authLock.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb) &&
(DateTime.UtcNow - _lastAuthTime).TotalHours < 12)
{
return _crumb;
}
_logger?.LogInformation("[YahooFinanceClient] Authenticating session (Cookie + Crumb)...");
// 1. Send GET request to fc.yahoo.com to obtain session cookie A3
using (var initRequest = new HttpRequestMessage(HttpMethod.Get, "https://fc.yahoo.com"))
{
using var initResponse = await _httpClient.SendAsync(initRequest, cancellationToken);
// CookieContainer automatically intercepts and stores 'A3' cookie
}
// 2. Send GET request to getcrumb to obtain the dynamic crumb token
using (var crumbRequest =
new HttpRequestMessage(HttpMethod.Get, "https://query1.finance.yahoo.com/v1/test/getcrumb"))
{
using var crumbResponse = await _httpClient.SendAsync(crumbRequest, cancellationToken);
if (!crumbResponse.IsSuccessStatusCode)
{
_logger?.LogWarning("[YahooFinanceClient] Failed to fetch crumb token. Status: {Status}",
crumbResponse.StatusCode);
return null;
}
var crumbText = await crumbResponse.Content.ReadAsStringAsync(cancellationToken);
_crumb = crumbText.Trim('"', ' ', '\t', '\r', '\n');
_lastAuthTime = DateTime.UtcNow;
_logger?.LogInformation("[YahooFinanceClient] Acquired Crumb token successfully: {Crumb}", _crumb);
return _crumb;
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "[YahooFinanceClient] Exception during Cookie & Crumb authentication.");
return null;
}
finally
{
_authLock.Release();
}
}
/// <summary>
/// Searches for tickers, names, ISINs, or companies via the Yahoo Finance search API.
/// URL: https://query2.finance.yahoo.com/v1/finance/search?q={query}&amp;quotesCount={quotesCount}&amp;newsCount={newsCount}
/// Note: Does not require Cookie/Crumb authentication.
/// </summary>
public async Task<YahooSearchResponseDto?> SearchAsync(
string query,
int quotesCount = 10,
int newsCount = 0,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query)) return null;
try
{
var url =
$"https://query2.finance.yahoo.com/v1/finance/search?q={Uri.EscapeDataString(query)}&quotesCount={quotesCount}&newsCount={newsCount}";
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger?.LogWarning("[YahooFinanceClient] Search for '{Query}' failed with status {Status}", query,
response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<YahooSearchResponseDto>(json, GetJsonOptions());
}
catch (Exception ex)
{
_logger?.LogError(ex, "[YahooFinanceClient] Exception during Search for query '{Query}'", query);
return null;
}
}
/// <summary>
/// Retrieves fundamentals and company metadata using the quoteSummary endpoint.
/// URL: https://query2.finance.yahoo.com/v10/finance/quoteSummary/{symbol}?crumb={crumb}&amp;modules={modules}
/// </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) =>
{
var url =
$"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{Uri.EscapeDataString(symbol)}?crumb={Uri.EscapeDataString(crumb)}&modules={Uri.EscapeDataString(moduleList)}";
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger?.LogWarning("[YahooFinanceClient] GetQuoteSummary for '{Symbol}' failed with status {Status}",
symbol, response.StatusCode);
return (
response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden, null);
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var dto = JsonSerializer.Deserialize<YahooQuoteSummaryResponseDto>(json, GetJsonOptions());
return (false, dto);
}, 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.
/// URL: https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range={range}&amp;interval={interval}&amp;crumb={crumb}
/// </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) =>
{
var url =
$"https://query1.finance.yahoo.com/v8/finance/chart/{Uri.EscapeDataString(symbol)}?range={Uri.EscapeDataString(range)}&interval={Uri.EscapeDataString(interval)}&crumb={Uri.EscapeDataString(crumb)}";
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger?.LogWarning("[YahooFinanceClient] GetChart for '{Symbol}' failed with status {Status}", symbol,
response.StatusCode);
return (
response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden, null);
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var dto = JsonSerializer.Deserialize<YahooChartResponseDto>(json, GetJsonOptions());
return (false, dto);
}, cancellationToken);
}
/// <summary>
/// Retrieves quick real-time price quotes for one or more symbols.
/// URL: https://query1.finance.yahoo.com/v7/finance/quote?symbols={symbols}&amp;crumb={crumb}
/// </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) =>
{
var url =
$"https://query1.finance.yahoo.com/v7/finance/quote?symbols={Uri.EscapeDataString(symbolsParam)}&crumb={Uri.EscapeDataString(crumb)}";
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger?.LogWarning("[YahooFinanceClient] GetQuotes failed with status {Status}", response.StatusCode);
return (
response.StatusCode == HttpStatusCode.Unauthorized ||
response.StatusCode == HttpStatusCode.Forbidden, null);
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var dto = JsonSerializer.Deserialize<YahooQuoteResponseDto>(json, GetJsonOptions());
return (false, dto);
}, 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)) return null;
var (isAuthError, result) = await action(crumb);
if (!isAuthError && result != null)
{
return result;
}
if (isAuthError)
{
_logger?.LogInformation(
"[YahooFinanceClient] Authentication error encountered (401/403). Re-authenticating...");
crumb = await EnsureAuthenticatedAsync(true, cancellationToken);
if (string.IsNullOrEmpty(crumb)) return null;
var (_, retryResult) = await action(crumb);
return retryResult;
}
return result;
}
private static JsonSerializerOptions GetJsonOptions()
{
return new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString
};
}
}