feat(core): update DTOs, Trade Republic client, Yahoo scrapers, and dynamic settings
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bietet kanalbasierte, dynamisch steuerbare Logging-Funktionalitäten über den <see cref="ISettingsService{TContext}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContextClass">Die aufrufende Klasse (für Log-Kategorien).</typeparam>
|
||||
/// <typeparam name="TDbContext">Der DbContext des Services für den Zugriff auf die Settings.</typeparam>
|
||||
public interface IFinlyticLogger<TContextClass, TDbContext> where TDbContext : DbContext
|
||||
{
|
||||
// --- Debug ---
|
||||
Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogDebugAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Info ---
|
||||
Task LogInfoAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogInfoAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Warning ---
|
||||
Task LogWarningAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogWarningAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Error ---
|
||||
Task LogErrorAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogErrorAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Trace & Critical ---
|
||||
Task LogTraceAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogCriticalAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kanalbasierte Logger-Implementierung, die Einstellungen und Stummschaltungen
|
||||
/// in Echtzeit aus dem <see cref="ISettingsService{TContext}"/> bezieht.
|
||||
/// </summary>
|
||||
public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContextClass, TDbContext>
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
private readonly ILogger<TContextClass> _logger;
|
||||
private readonly ISettingsService<TDbContext> _settingsService;
|
||||
|
||||
public FinlyticLogger(
|
||||
ILogger<TContextClass> logger,
|
||||
ISettingsService<TDbContext> settingsService)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
}
|
||||
|
||||
#region Debug
|
||||
|
||||
public async Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogDebugAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Debug))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogDebug(exception, message, args);
|
||||
else
|
||||
_logger.LogDebug(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Info
|
||||
|
||||
public async Task LogInfoAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogInfoAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Information))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogInformation(exception, message, args);
|
||||
else
|
||||
_logger.LogInformation(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warning
|
||||
|
||||
public async Task LogWarningAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Warning))
|
||||
{
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogWarningAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Warning))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogWarning(exception, message, args);
|
||||
else
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error
|
||||
|
||||
public async Task LogErrorAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Error))
|
||||
{
|
||||
_logger.LogError(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogErrorAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Error))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogError(exception, message, args);
|
||||
else
|
||||
_logger.LogError(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trace & Critical
|
||||
|
||||
public async Task LogTraceAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Trace))
|
||||
{
|
||||
_logger.LogTrace(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogCriticalAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Critical))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogCritical(exception, message, args);
|
||||
else
|
||||
_logger.LogCritical(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein spezifischer Kanal und die aufrufende Klasse aktives Logging erlauben.
|
||||
/// </summary>
|
||||
private async Task<bool> ShouldLogAsync(SettingKey<bool> channelKey, LogLevel level)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelKey);
|
||||
|
||||
try
|
||||
{
|
||||
return await _settingsService.GetSettingAsync(channelKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return channelKey.DefaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticCore.Services.PlaywrightScrapper;
|
||||
|
||||
public interface IPlaywrightExecutionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Führt eine Scrape-Aktion auf einer einzelnen Seite innerhalb eines isolierten Kontexts aus.
|
||||
/// Der Kontext und die Page werden automatisch nach der Ausführung disposed.
|
||||
/// </summary>
|
||||
Task<T> ExecuteInPageAsync<T>(
|
||||
Func<IPage, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Führt eine Multi-Page Scrape-Aktion (z. B. bei Tabs/Popups) in einem Konfiguration-Kontext aus.
|
||||
/// </summary>
|
||||
Task<T> ExecuteInContextAsync<T>(
|
||||
Func<IBrowserContext, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class PlaywrightExecutionService : IPlaywrightExecutionService
|
||||
{
|
||||
private readonly IPlaywrightBrowserFactory _browserFactory;
|
||||
|
||||
public PlaywrightExecutionService(IPlaywrightBrowserFactory browserFactory)
|
||||
{
|
||||
_browserFactory = browserFactory;
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteInPageAsync<T>(
|
||||
Func<IPage, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _browserFactory.CreateContextAsync(contextOptions, cancellationToken);
|
||||
var page = await context.NewPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return await action(page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await page.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteInContextAsync<T>(
|
||||
Func<IBrowserContext, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _browserFactory.CreateContextAsync(contextOptions, cancellationToken);
|
||||
return await action(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticCore.Services.PlaywrightScrapper;
|
||||
|
||||
public interface IPlaywrightBrowserFactory : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass die IBrowser-Instanz verbunden ist.
|
||||
/// </summary>
|
||||
Task<IBrowser> GetBrowserAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen isolierten, vorkonfigurierten Browser-Kontext.
|
||||
/// </summary>
|
||||
Task<IBrowserContext> CreateContextAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory
|
||||
{
|
||||
private readonly ILogger<PlaywrightBrowserFactory> _logger;
|
||||
private readonly SemaphoreSlim _browserLock = new(1, 1);
|
||||
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
|
||||
public PlaywrightBrowserFactory(ILogger<PlaywrightBrowserFactory> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IBrowser> GetBrowserAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_browser != null && _browser.IsConnected) return _browser;
|
||||
|
||||
await _browserLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_browser != null && _browser.IsConnected) return _browser;
|
||||
|
||||
_playwright ??= await Playwright.CreateAsync();
|
||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args = new[]
|
||||
{
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu"
|
||||
}
|
||||
});
|
||||
|
||||
_logger.LogInformation("[PlaywrightFactory] Shared Chromium Instance successfully launched.");
|
||||
return _browser;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_browserLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IBrowserContext> CreateContextAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var browser = await GetBrowserAsync(cancellationToken);
|
||||
|
||||
options ??= GetDefaultContextOptions();
|
||||
return await browser.NewContextAsync(options);
|
||||
}
|
||||
|
||||
public static BrowserNewContextOptions GetDefaultContextOptions() => new()
|
||||
{
|
||||
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
ViewportSize = new ViewportSize { Width = 1280, Height = 900 },
|
||||
Locale = "en-US",
|
||||
ExtraHTTPHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Accept-Language"] = "en-US,en;q=0.9"
|
||||
}
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_browser != null)
|
||||
{
|
||||
await _browser.CloseAsync();
|
||||
await _browser.DisposeAsync();
|
||||
}
|
||||
|
||||
_playwright?.Dispose();
|
||||
_browserLock.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Entities.Settings;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services;
|
||||
|
||||
public interface ISettingsService<TContext> where TContext : DbContext
|
||||
{
|
||||
// --- 1. Typsicherer Zugriff über SettingKey<T> (Empfohlen) ---
|
||||
Task<T> GetSettingAsync<T>(SettingKey<T> key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetSettingAsync<T>(SettingKey<T> key, T value,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// --- 2. Dynamischer Zugriff über Enum-Key ---
|
||||
Task<T> GetSettingAsync<TEnum, T>(TEnum enumKey, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default) where TEnum : struct, Enum;
|
||||
|
||||
Task SetSettingAsync<TEnum, T>(TEnum enumKey, T value,
|
||||
CancellationToken cancellationToken = default) where TEnum : struct, Enum;
|
||||
|
||||
// --- 3. Dynamischer Zugriff über String-Key ---
|
||||
Task<T> GetSettingAsync<T>(string key, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetSettingAsync<T>(string key, T value,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class SettingsService<TContext> : ISettingsService<TContext> where TContext : DbContext
|
||||
{
|
||||
private readonly Microsoft.Extensions.DependencyInjection.IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SettingsService<TContext>>? _logger;
|
||||
|
||||
// Fast In-Memory Cache: Key Schema: "KeyName"
|
||||
private readonly ConcurrentDictionary<string, string> _cache = new();
|
||||
|
||||
public SettingsService(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory scopeFactory, ILogger<SettingsService<TContext>>? logger = null)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
#region SettingKey<T> Overloads
|
||||
|
||||
public Task<T> GetSettingAsync<T>(SettingKey<T> key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetSettingInternalAsync(key.Name, key.DefaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<T>(SettingKey<T> key, T value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SetSettingInternalAsync(key.Name, value, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enum-Key Overloads
|
||||
|
||||
public Task<T> GetSettingAsync<TEnum, T>(TEnum enumKey, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
var keyName = $"{typeof(TEnum).Name}.{enumKey}";
|
||||
return GetSettingInternalAsync(keyName, defaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<TEnum, T>(TEnum enumKey, T value, CancellationToken cancellationToken = default)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
var keyName = $"{typeof(TEnum).Name}.{enumKey}";
|
||||
return SetSettingInternalAsync(keyName, value, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region String-Key Overloads
|
||||
|
||||
public Task<T> GetSettingAsync<T>(string key, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetSettingInternalAsync(key, defaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<T>(string key, T value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SetSettingInternalAsync(key, value, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Core Engine Logik
|
||||
|
||||
private async Task<T> GetSettingInternalAsync<T>(string key, T defaultValue, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Zuerst im In-Memory Cache prüfen
|
||||
if (_cache.TryGetValue(key, out var cachedJson))
|
||||
{
|
||||
return DeserializeValue(cachedJson, defaultValue);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<TContext>(scope.ServiceProvider);
|
||||
|
||||
// 2. Aus DB der spezifischen TContext-Instanz laden
|
||||
var entity = await dbContext.Set<SettingEntity>()
|
||||
.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
|
||||
// 3. Falls noch nicht vorhanden: In DB anlegen (Seed on Demand)
|
||||
if (entity == null)
|
||||
{
|
||||
var defaultJson = JsonSerializer.Serialize(defaultValue);
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = defaultJson,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.Set<SettingEntity>().Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_cache[key] = defaultJson;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// In Cache legen & Wert zurückgeben
|
||||
_cache[key] = entity.ValueJson;
|
||||
return DeserializeValue(entity.ValueJson, defaultValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Setting '{Key}' could not be loaded or initialized in DB. Using default value in memory.", key);
|
||||
_cache[key] = JsonSerializer.Serialize(defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSettingInternalAsync<T>(string key, T value, CancellationToken cancellationToken)
|
||||
{
|
||||
var jsonValue = JsonSerializer.Serialize(value);
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<TContext>(scope.ServiceProvider);
|
||||
|
||||
var entity = await dbContext.Set<SettingEntity>()
|
||||
.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
{
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = jsonValue,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
dbContext.Set<SettingEntity>().Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.ValueJson = jsonValue;
|
||||
entity.LastUpdatedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Setting '{Key}' could not be saved to DB.", key);
|
||||
}
|
||||
|
||||
// Cache trotzdem aktualisieren
|
||||
_cache[key] = jsonValue;
|
||||
}
|
||||
|
||||
private static T DeserializeValue<T>(string json, T defaultValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<T>(json);
|
||||
return result ?? defaultValue;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.TradeRepublic;
|
||||
using FinlyticCore.Dtos.TradeRepublic;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -165,46 +165,60 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnMessageReceived(string message)
|
||||
protected override void OnMessageReceived(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
|
||||
_logger.LogDebug("[{Channel}] TR WS Recv: {Message}", "TradeRepublicChannel", message);
|
||||
|
||||
var trimmed = message.Trim();
|
||||
|
||||
int subId;
|
||||
string type;
|
||||
string payload;
|
||||
|
||||
_logger.LogDebug("Trade republic response: " + message);
|
||||
|
||||
if (trimmed.Equals("connected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
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 = "{...}"
|
||||
subId = -1;
|
||||
type = "connected";
|
||||
payload = trimmed;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ziffern am Anfang zählen (Sub-ID)
|
||||
var digitLen = 0;
|
||||
while (digitLen < message.Length && char.IsDigit(message[digitLen]))
|
||||
while (digitLen < trimmed.Length && char.IsDigit(trimmed[digitLen]))
|
||||
{
|
||||
digitLen++;
|
||||
}
|
||||
|
||||
// Keine Ziffer am Anfang (Reines System-Event/Error ohne ID)
|
||||
if (digitLen == 0)
|
||||
{
|
||||
SystemMessageReceived?.Invoke(message);
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(message.Substring(0, digitLen), out var subId))
|
||||
if (!int.TryParse(trimmed.Substring(0, digitLen), out subId))
|
||||
{
|
||||
SystemMessageReceived?.Invoke(message);
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
var remainder = message.Substring(digitLen).TrimStart();
|
||||
string type;
|
||||
string payload;
|
||||
var remainder = trimmed.Substring(digitLen).TrimStart();
|
||||
|
||||
if (remainder.StartsWith("connected"))
|
||||
// 2. FALL: "34 connected" oder "34connected"
|
||||
if (remainder.StartsWith("connected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
subId = -1; // Mapping auf deine interne -1 für InitAsync
|
||||
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
|
||||
// Standard Trade Republic Data Push (z.B. "22A {...}")
|
||||
type = remainder[0].ToString();
|
||||
payload = remainder.Substring(1).TrimStart();
|
||||
}
|
||||
@@ -213,21 +227,23 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
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);
|
||||
}
|
||||
|
||||
var received = new ReceivedMessage(subId, type, payload);
|
||||
|
||||
// Löst jetzt garantiert dein TaskCompletionSource(-1) in InitAsync auf!
|
||||
if (_pendingRequests.TryGetValue(subId, out var tcs))
|
||||
{
|
||||
tcs.TrySetResult(received);
|
||||
}
|
||||
|
||||
if (_tickerSubscriptions.TryGetValue(subId, out var handler))
|
||||
{
|
||||
handler(payload);
|
||||
}
|
||||
|
||||
UnhandledMessageReceived?.Invoke(received);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using FinlyticCore.Models.TradeRepublic;
|
||||
using FinlyticCore.Dtos.TradeRepublic;
|
||||
using FinlyticCore.Models.Assets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -53,6 +53,22 @@ public interface ITradeRepublicService
|
||||
/// <param name="subId">The subscription ID to unsubscribe.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task UnsubscribeRealtimeTickerAsync(int subId);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches stock details (company description, events, earnings, analyst ratings) for a specific ISIN.
|
||||
/// </summary>
|
||||
/// <param name="isin">The ISIN of the stock.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The stock details response, or null if failed.</returns>
|
||||
Task<TradeRepublicStockDetailsResponse?> GetStockDetailsAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches derivative products (KnockOuts, Warrants, Factor Certificates) for an underlying ISIN.
|
||||
/// </summary>
|
||||
/// <param name="request">The derivative query parameters.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The derivatives response, or null if failed.</returns>
|
||||
Task<TradeRepublicDerivativesResponse?> GetDerivativesAsync(TradeRepublicDerivativesRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
@@ -179,6 +195,37 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
await _client.UnsubscribeTickerAsync(subId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TradeRepublicStockDetailsResponse?> GetStockDetailsAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
var req = new TradeRepublicStockDetailsRequest(Id: isin);
|
||||
return await _client.SendRequestAsync<TradeRepublicStockDetailsResponse, TradeRepublicStockDetailsRequest>(req, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching stock details for ISIN {Isin}", "TradeRepublicChannel", isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TradeRepublicDerivativesResponse?> GetDerivativesAsync(TradeRepublicDerivativesRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
return await _client.SendRequestAsync<TradeRepublicDerivativesResponse, TradeRepublicDerivativesRequest>(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching derivatives for underlying {Underlying}", "TradeRepublicChannel", request.Underlying);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnInactivityTimeout(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
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) & 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}&quotesCount={quotesCount}&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)}"esCount={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}&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}&interval={interval}&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}&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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user