feat(core): dynamic settings service, IFinlyticLogger, log broadcaster, and persistent Yahoo auth
This commit is contained in:
@@ -1,17 +1,42 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Logging;
|
||||
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}"/>.
|
||||
/// Globaler Broadcaster für strukturierte Logs in Echtzeit.
|
||||
/// </summary>
|
||||
public static class FinlyticLogBroadcaster
|
||||
{
|
||||
public static Func<LogMessageDto, Task>? OnLogPublished { get; set; }
|
||||
|
||||
public static void Broadcast(LogMessageDto dto)
|
||||
{
|
||||
if (OnLogPublished != null)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await OnLogPublished(dto);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore broadcast errors to never disrupt execution
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bietet kanalbasierte, dynamisch steuerbare Logging-Funktionalitäten über den <see cref="ISettingsService"/>.
|
||||
/// </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
|
||||
public interface IFinlyticLogger<TContextClass>
|
||||
{
|
||||
// --- Debug ---
|
||||
Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
@@ -36,22 +61,49 @@ public interface IFinlyticLogger<TContextClass, TDbContext> where TDbContext : D
|
||||
|
||||
/// <summary>
|
||||
/// Kanalbasierte Logger-Implementierung, die Einstellungen und Stummschaltungen
|
||||
/// in Echtzeit aus dem <see cref="ISettingsService{TContext}"/> bezieht.
|
||||
/// in Echtzeit aus dem <see cref="ISettingsService"/> bezieht.
|
||||
/// </summary>
|
||||
public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContextClass, TDbContext>
|
||||
where TDbContext : DbContext
|
||||
public class FinlyticLogger<TContextClass> : IFinlyticLogger<TContextClass>
|
||||
{
|
||||
private static readonly string ServiceName = typeof(TContextClass).Assembly.GetName().Name ?? "Finlytic";
|
||||
private readonly ILogger<TContextClass> _logger;
|
||||
private readonly ISettingsService<TDbContext> _settingsService;
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public FinlyticLogger(
|
||||
ILogger<TContextClass> logger,
|
||||
ISettingsService<TDbContext> settingsService)
|
||||
ISettingsService settingsService)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
}
|
||||
|
||||
private void DispatchBroadcast(SettingKey<bool> channelKey, LogLevel level, string message, Exception? exception, params object[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
string formattedMsg = args != null && args.Length > 0 ? string.Format(message, args) : message;
|
||||
FinlyticLogBroadcaster.Broadcast(new LogMessageDto(
|
||||
Timestamp: DateTime.UtcNow,
|
||||
ServiceName: ServiceName,
|
||||
Channel: channelKey.Name,
|
||||
Level: level.ToString(),
|
||||
Message: formattedMsg,
|
||||
Exception: exception?.ToString()
|
||||
));
|
||||
}
|
||||
catch
|
||||
{
|
||||
FinlyticLogBroadcaster.Broadcast(new LogMessageDto(
|
||||
Timestamp: DateTime.UtcNow,
|
||||
ServiceName: ServiceName,
|
||||
Channel: channelKey.Name,
|
||||
Level: level.ToString(),
|
||||
Message: message,
|
||||
Exception: exception?.ToString()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#region Debug
|
||||
|
||||
public async Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
@@ -59,6 +111,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Debug, message, null, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +123,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
_logger.LogDebug(exception, message, args);
|
||||
else
|
||||
_logger.LogDebug(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Debug, message, exception, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +136,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Information, message, null, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +148,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
_logger.LogInformation(exception, message, args);
|
||||
else
|
||||
_logger.LogInformation(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Information, message, exception, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +161,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Warning))
|
||||
{
|
||||
_logger.LogWarning(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Warning, message, null, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +173,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
_logger.LogWarning(exception, message, args);
|
||||
else
|
||||
_logger.LogWarning(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Warning, message, exception, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +186,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Error))
|
||||
{
|
||||
_logger.LogError(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Error, message, null, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +198,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
_logger.LogError(exception, message, args);
|
||||
else
|
||||
_logger.LogError(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Error, message, exception, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +211,7 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Trace))
|
||||
{
|
||||
_logger.LogTrace(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Trace, message, null, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,14 +223,12 @@ public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContex
|
||||
_logger.LogCritical(exception, message, args);
|
||||
else
|
||||
_logger.LogCritical(message, args);
|
||||
DispatchBroadcast(channelKey, LogLevel.Critical, message, exception, 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);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using FinlyticCore.Services;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticCore.Services.PlaywrightScrapper;
|
||||
|
||||
public interface IPlaywrightBrowserFactory : IAsyncDisposable
|
||||
public interface IPlaywrightBrowserFactory : IAsyncDisposable, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass die IBrowser-Instanz verbunden ist.
|
||||
@@ -18,15 +23,15 @@ public interface IPlaywrightBrowserFactory : IAsyncDisposable
|
||||
|
||||
public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory
|
||||
{
|
||||
private readonly ILogger<PlaywrightBrowserFactory> _logger;
|
||||
private readonly IFinlyticLogger<PlaywrightBrowserFactory> _finlyticLogger;
|
||||
private readonly SemaphoreSlim _browserLock = new(1, 1);
|
||||
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
|
||||
public PlaywrightBrowserFactory(ILogger<PlaywrightBrowserFactory> logger)
|
||||
public PlaywrightBrowserFactory(IFinlyticLogger<PlaywrightBrowserFactory> finlyticLogger)
|
||||
{
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
public async Task<IBrowser> GetBrowserAsync(CancellationToken cancellationToken = default)
|
||||
@@ -51,7 +56,7 @@ public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory
|
||||
}
|
||||
});
|
||||
|
||||
_logger.LogInformation("[PlaywrightFactory] Shared Chromium Instance successfully launched.");
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.PlaywrightChannel, "[PlaywrightFactory] Shared Chromium Instance successfully launched.");
|
||||
return _browser;
|
||||
}
|
||||
finally
|
||||
@@ -79,12 +84,41 @@ public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory
|
||||
}
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_browser != null)
|
||||
{
|
||||
_browser.CloseAsync().GetAwaiter().GetResult();
|
||||
_browser.DisposeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore any sync disposal timeouts
|
||||
}
|
||||
finally
|
||||
{
|
||||
_playwright?.Dispose();
|
||||
_browserLock.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_browser != null)
|
||||
{
|
||||
await _browser.CloseAsync();
|
||||
await _browser.DisposeAsync();
|
||||
try
|
||||
{
|
||||
await _browser.CloseAsync();
|
||||
await _browser.DisposeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
|
||||
_playwright?.Dispose();
|
||||
|
||||
@@ -1,50 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Database;
|
||||
using FinlyticCore.Dtos.Settings;
|
||||
using FinlyticCore.Entities.Settings;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services;
|
||||
|
||||
public interface ISettingsService<TContext> where TContext : DbContext
|
||||
public interface ISettingsService
|
||||
{
|
||||
// --- 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);
|
||||
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;
|
||||
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<T> GetSettingAsync<T>(string key, T defaultValue = default!, CancellationToken cancellationToken = default);
|
||||
Task SetSettingAsync<T>(string key, T value, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetSettingAsync<T>(string key, T value,
|
||||
CancellationToken cancellationToken = default);
|
||||
// --- 4. Reflection-Erkennung & Bulk-Verwaltung für Web UI / MQTT ---
|
||||
Task<List<DynamicSettingDto>> GetAllRegisteredSettingsAsync(IEnumerable<Type>? customKeyHolders = null, CancellationToken cancellationToken = default);
|
||||
Task UpdateSettingsAsync(Dictionary<string, object?> updatedSettings, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class SettingsService<TContext> : ISettingsService<TContext> where TContext : DbContext
|
||||
public class SettingsService : ISettingsService
|
||||
{
|
||||
private readonly Microsoft.Extensions.DependencyInjection.IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SettingsService<TContext>>? _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SettingsService>? _logger;
|
||||
|
||||
// Fast In-Memory Cache: Key Schema: "KeyName"
|
||||
private readonly ConcurrentDictionary<string, string> _cache = new();
|
||||
// Fast In-Memory Cache: Key Schema: "KeyName" -> JSON string
|
||||
private readonly ConcurrentDictionary<string, string> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SettingsService(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory scopeFactory, ILogger<SettingsService<TContext>>? logger = null)
|
||||
public SettingsService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<SettingsService>? logger = null)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -52,11 +56,13 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
|
||||
public Task<T> GetSettingAsync<T>(SettingKey<T> key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
return GetSettingInternalAsync(key.Name, key.DefaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<T>(SettingKey<T> key, T value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
return SetSettingInternalAsync(key.Name, value, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -96,11 +102,138 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
|
||||
#endregion
|
||||
|
||||
#region Core Engine Logik
|
||||
#region Bulk & Reflection Discovery
|
||||
|
||||
public async Task<List<DynamicSettingDto>> GetAllRegisteredSettingsAsync(
|
||||
IEnumerable<Type>? customKeyHolders = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var holderTypes = new List<Type> { typeof(CoreSettingKeys) };
|
||||
if (customKeyHolders != null)
|
||||
{
|
||||
holderTypes.AddRange(customKeyHolders);
|
||||
}
|
||||
|
||||
var resultList = new List<DynamicSettingDto>();
|
||||
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 1. Reflection auf allen SettingKey<T> Feldern
|
||||
foreach (var type in holderTypes.Distinct())
|
||||
{
|
||||
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var fieldType = field.FieldType;
|
||||
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(SettingKey<>))
|
||||
{
|
||||
var valType = fieldType.GetGenericArguments()[0];
|
||||
var settingKeyObj = field.GetValue(null);
|
||||
if (settingKeyObj == null) continue;
|
||||
|
||||
var nameProp = fieldType.GetProperty("Name");
|
||||
var defaultProp = fieldType.GetProperty("DefaultValue");
|
||||
|
||||
var keyName = nameProp?.GetValue(settingKeyObj)?.ToString() ?? field.Name;
|
||||
if (seenKeys.Contains(keyName)) continue;
|
||||
seenKeys.Add(keyName);
|
||||
|
||||
var defVal = defaultProp?.GetValue(settingKeyObj);
|
||||
var typeName = MapToSimpleTypeName(valType);
|
||||
|
||||
// Aktuellen Wert aus DB / Cache lesen
|
||||
var currentRawValue = await GetSettingInternalObjectAsync(keyName, valType, defVal, cancellationToken);
|
||||
|
||||
resultList.Add(new DynamicSettingDto(
|
||||
Key: keyName,
|
||||
Value: currentRawValue,
|
||||
Type: typeName,
|
||||
Description: FormatDescriptionFromKey(keyName),
|
||||
UpdatedAt: DateTime.UtcNow
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Prüfen, ob in der DB weitere gespeicherte Settings existieren, die nicht im Code deklariert sind
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
|
||||
if (dbContext != null)
|
||||
{
|
||||
var dbSettings = await dbContext.DynamicSettings.AsNoTracking().ToListAsync(cancellationToken);
|
||||
foreach (var dbSetting in dbSettings)
|
||||
{
|
||||
if (!seenKeys.Contains(dbSetting.Key))
|
||||
{
|
||||
seenKeys.Add(dbSetting.Key);
|
||||
var (inferredVal, inferredType) = InferJsonValueAndType(dbSetting.ValueJson);
|
||||
resultList.Add(new DynamicSettingDto(
|
||||
Key: dbSetting.Key,
|
||||
Value: inferredVal,
|
||||
Type: inferredType,
|
||||
Description: FormatDescriptionFromKey(dbSetting.Key),
|
||||
UpdatedAt: dbSetting.LastUpdatedUtc
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "[SettingsService] Error reading database settings during GetAllRegisteredSettingsAsync.");
|
||||
}
|
||||
|
||||
return resultList.OrderBy(s => s.Key).ToList();
|
||||
}
|
||||
|
||||
public async Task UpdateSettingsAsync(Dictionary<string, object?> updatedSettings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (updatedSettings == null || updatedSettings.Count == 0) return;
|
||||
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
|
||||
|
||||
foreach (var (key, rawValue) in updatedSettings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) continue;
|
||||
|
||||
string jsonValue = NormalizeJsonValue(rawValue);
|
||||
_cache[key] = jsonValue;
|
||||
|
||||
if (dbContext != null)
|
||||
{
|
||||
var entity = await dbContext.DynamicSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
if (entity == null)
|
||||
{
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = jsonValue,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
dbContext.DynamicSettings.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.ValueJson = jsonValue;
|
||||
entity.LastUpdatedUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dbContext != null)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Engine Logic
|
||||
|
||||
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);
|
||||
@@ -108,17 +241,21 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<TContext>(scope.ServiceProvider);
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
|
||||
if (dbContext == null)
|
||||
{
|
||||
var defaultJson = NormalizeJsonValue(defaultValue);
|
||||
_cache[key] = defaultJson;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// 2. Aus DB der spezifischen TContext-Instanz laden
|
||||
var entity = await dbContext.Set<SettingEntity>()
|
||||
var entity = await dbContext.DynamicSettings
|
||||
.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);
|
||||
var defaultJson = NormalizeJsonValue(defaultValue);
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
@@ -126,35 +263,76 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.Set<SettingEntity>().Add(entity);
|
||||
dbContext.DynamicSettings.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);
|
||||
_logger?.LogWarning(ex, "[SettingsService] Setting '{Key}' could not be loaded or initialized in DB. Using default value.", key);
|
||||
_cache[key] = NormalizeJsonValue(defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> GetSettingInternalObjectAsync(string key, Type valueType, object? defaultValue, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var cachedJson))
|
||||
{
|
||||
return DeserializeObject(cachedJson, valueType, defaultValue);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
|
||||
if (dbContext == null) return defaultValue;
|
||||
|
||||
var entity = await dbContext.DynamicSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
if (entity == null)
|
||||
{
|
||||
var defaultJson = NormalizeJsonValue(defaultValue);
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = defaultJson,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.DynamicSettings.Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_cache[key] = defaultJson;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
_cache[key] = entity.ValueJson;
|
||||
return DeserializeObject(entity.ValueJson, valueType, defaultValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSettingInternalAsync<T>(string key, T value, CancellationToken cancellationToken)
|
||||
{
|
||||
var jsonValue = JsonSerializer.Serialize(value);
|
||||
var jsonValue = NormalizeJsonValue(value);
|
||||
_cache[key] = jsonValue;
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<TContext>(scope.ServiceProvider);
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetService<ISettingsDbContext>();
|
||||
if (dbContext == null) return;
|
||||
|
||||
var entity = await dbContext.Set<SettingEntity>()
|
||||
var entity = await dbContext.DynamicSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
@@ -165,7 +343,7 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
ValueJson = jsonValue,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
dbContext.Set<SettingEntity>().Add(entity);
|
||||
dbContext.DynamicSettings.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -177,17 +355,91 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Setting '{Key}' could not be saved to DB.", key);
|
||||
_logger?.LogWarning(ex, "[SettingsService] Setting '{Key}' could not be saved to DB.", key);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeJsonValue(object? rawValue)
|
||||
{
|
||||
if (rawValue == null) return "null";
|
||||
|
||||
if (rawValue is JsonElement jsonElem)
|
||||
{
|
||||
if (jsonElem.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = jsonElem.GetString()?.Trim() ?? string.Empty;
|
||||
var unquoted = str.Trim('\"', ' ');
|
||||
if (bool.TryParse(unquoted, out var b)) return b ? "true" : "false";
|
||||
if (long.TryParse(unquoted, out var l)) return l.ToString();
|
||||
if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d.ToString(CultureInfo.InvariantCulture);
|
||||
return JsonSerializer.Serialize(str);
|
||||
}
|
||||
return jsonElem.GetRawText();
|
||||
}
|
||||
|
||||
// Cache trotzdem aktualisieren
|
||||
_cache[key] = jsonValue;
|
||||
if (rawValue is string s)
|
||||
{
|
||||
var unquoted = s.Trim('\"', ' ');
|
||||
if (bool.TryParse(unquoted, out var b)) return b ? "true" : "false";
|
||||
if (long.TryParse(unquoted, out var l)) return l.ToString();
|
||||
if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d.ToString(CultureInfo.InvariantCulture);
|
||||
return JsonSerializer.Serialize(s);
|
||||
}
|
||||
|
||||
if (rawValue is bool bVal) return bVal ? "true" : "false";
|
||||
if (rawValue is int or long or short or byte) return rawValue.ToString()!;
|
||||
if (rawValue is double or float or decimal) return Convert.ToString(rawValue, CultureInfo.InvariantCulture)!;
|
||||
|
||||
return JsonSerializer.Serialize(rawValue);
|
||||
}
|
||||
|
||||
private static T DeserializeValue<T>(string json, T defaultValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
var unquoted = json.Trim('\"', ' ');
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
{
|
||||
if (bool.TryParse(unquoted, out var b))
|
||||
{
|
||||
return (T)(object)b;
|
||||
}
|
||||
}
|
||||
else if (typeof(T) == typeof(int))
|
||||
{
|
||||
if (int.TryParse(unquoted, out var i))
|
||||
{
|
||||
return (T)(object)i;
|
||||
}
|
||||
}
|
||||
else if (typeof(T) == typeof(long))
|
||||
{
|
||||
if (long.TryParse(unquoted, out var l))
|
||||
{
|
||||
return (T)(object)l;
|
||||
}
|
||||
}
|
||||
else if (typeof(T) == typeof(double))
|
||||
{
|
||||
if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
return (T)(object)d;
|
||||
}
|
||||
}
|
||||
else if (typeof(T) == typeof(string))
|
||||
{
|
||||
var trimmed = json.Trim();
|
||||
if (trimmed.StartsWith("\"") && trimmed.EndsWith("\"") && trimmed.Length >= 2)
|
||||
{
|
||||
try { return (T)(object)(JsonSerializer.Deserialize<string>(trimmed) ?? trimmed.Trim('\"')); }
|
||||
catch { return (T)(object)trimmed.Trim('\"'); }
|
||||
}
|
||||
return (T)(object)trimmed;
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<T>(json);
|
||||
return result ?? defaultValue;
|
||||
}
|
||||
@@ -197,5 +449,106 @@ public class SettingsService<TContext> : ISettingsService<TContext> where TConte
|
||||
}
|
||||
}
|
||||
|
||||
private static object? DeserializeObject(string json, Type valueType, object? defaultValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
var unquoted = json.Trim('\"', ' ');
|
||||
|
||||
if (valueType == typeof(bool))
|
||||
{
|
||||
if (bool.TryParse(unquoted, out var b)) return b;
|
||||
}
|
||||
else if (valueType == typeof(int))
|
||||
{
|
||||
if (int.TryParse(unquoted, out var i)) return i;
|
||||
}
|
||||
else if (valueType == typeof(long))
|
||||
{
|
||||
if (long.TryParse(unquoted, out var l)) return l;
|
||||
}
|
||||
else if (valueType == typeof(double))
|
||||
{
|
||||
if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d;
|
||||
}
|
||||
else if (valueType == typeof(string))
|
||||
{
|
||||
var trimmed = json.Trim();
|
||||
if (trimmed.StartsWith("\"") && trimmed.EndsWith("\"") && trimmed.Length >= 2)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<string>(trimmed) ?? trimmed.Trim('\"'); }
|
||||
catch { return trimmed.Trim('\"'); }
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, valueType) ?? defaultValue;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapToSimpleTypeName(Type type)
|
||||
{
|
||||
if (type == typeof(bool)) return "bool";
|
||||
if (type == typeof(int) || type == typeof(short) || type == typeof(byte) || type == typeof(long)) return "int";
|
||||
if (type == typeof(double) || type == typeof(float) || type == typeof(decimal)) return "double";
|
||||
return "string";
|
||||
}
|
||||
|
||||
private static (object? Value, string Type) InferJsonValueAndType(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return (string.Empty, "string");
|
||||
|
||||
try
|
||||
{
|
||||
var unquoted = json.Trim('\"', ' ');
|
||||
if (bool.TryParse(unquoted, out var b)) return (b, "bool");
|
||||
if (long.TryParse(unquoted, out var l)) return (l, "int");
|
||||
if (double.TryParse(unquoted, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return (d, "double");
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
return root.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => (true, "bool"),
|
||||
JsonValueKind.False => (false, "bool"),
|
||||
JsonValueKind.Number when root.TryGetInt64(out var i) => (i, "int"),
|
||||
JsonValueKind.Number => (root.GetDouble(), "double"),
|
||||
JsonValueKind.String => (root.GetString(), "string"),
|
||||
_ => (json, "string")
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (json, "string");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatDescriptionFromKey(string key)
|
||||
{
|
||||
if (key.StartsWith("Logging.Channel.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Logging-Kanal für {key.Substring(16)} (Ein/Aus)";
|
||||
}
|
||||
if (key.StartsWith("Feature.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Feature-Toggle für {key.Substring(8)}";
|
||||
}
|
||||
if (key.StartsWith("Cache.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Cache-Konfiguration ({key.Substring(6)})";
|
||||
}
|
||||
if (key.StartsWith("Scraper.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Scraper-Konfiguration ({key.Substring(8)})";
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -4,8 +4,9 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.TradeRepublic;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services.TradeRepublic;
|
||||
|
||||
@@ -15,7 +16,7 @@ namespace FinlyticCore.Services.TradeRepublic;
|
||||
/// </summary>
|
||||
public class TradeRepublicClient : ManagedWebSocket
|
||||
{
|
||||
private readonly ILogger<TradeRepublicClient> _logger;
|
||||
private readonly IFinlyticLogger<TradeRepublicClient> _finlyticLogger;
|
||||
private int _currentSub;
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<ReceivedMessage>> _pendingRequests = new();
|
||||
private readonly ConcurrentDictionary<int, Action<string>> _tickerSubscriptions = new();
|
||||
@@ -26,10 +27,10 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TradeRepublicClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public TradeRepublicClient(ILogger<TradeRepublicClient> logger)
|
||||
/// <param name="finlyticLogger">The logger instance.</param>
|
||||
public TradeRepublicClient(IFinlyticLogger<TradeRepublicClient> finlyticLogger)
|
||||
{
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,7 +58,7 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
var isConnected = res.Type == "connected";
|
||||
if (isConnected)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] WebSocket connection to Trade Republic established.", "TradeRepublicChannel");
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] WebSocket connection to Trade Republic established.");
|
||||
}
|
||||
|
||||
return isConnected;
|
||||
@@ -65,7 +66,7 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingRequests.TryRemove(-1, out _);
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed or timed out establishing Trade Republic WebSocket connection.", "TradeRepublicChannel");
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Failed or timed out establishing Trade Republic WebSocket connection.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -84,7 +85,7 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
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);
|
||||
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Sent (Request): {Message}", msg);
|
||||
var tcs = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_pendingRequests.TryAdd(tempSub, tcs);
|
||||
@@ -99,7 +100,7 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Error waiting for Trade Republic response ID {SubId}", "TradeRepublicChannel", tempSub);
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Error waiting for Trade Republic response ID {SubId}", tempSub);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
@@ -125,7 +126,6 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
|
||||
_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;
|
||||
|
||||
@@ -139,12 +139,12 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed to parse real-time ticker payload for {TickerId}", "TradeRepublicChannel", tickerId);
|
||||
_ = _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Failed to parse real-time ticker payload for {TickerId}", 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 _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] Subscribing to Trade Republic real-time ticker {TickerId} (Sub ID: {SubId})", tickerId, tempSub);
|
||||
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Sent: {Message}", msg);
|
||||
await SendAsync(msg);
|
||||
return tempSub;
|
||||
}
|
||||
@@ -165,85 +165,81 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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))
|
||||
protected override void OnMessageReceived(string message)
|
||||
{
|
||||
subId = -1;
|
||||
type = "connected";
|
||||
payload = trimmed;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ziffern am Anfang zählen (Sub-ID)
|
||||
var digitLen = 0;
|
||||
while (digitLen < trimmed.Length && char.IsDigit(trimmed[digitLen]))
|
||||
{
|
||||
digitLen++;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
|
||||
// Keine Ziffer am Anfang (Reines System-Event/Error ohne ID)
|
||||
if (digitLen == 0)
|
||||
{
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
_ = _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Recv: {Message}", message);
|
||||
|
||||
if (!int.TryParse(trimmed.Substring(0, digitLen), out subId))
|
||||
{
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
var trimmed = message.Trim();
|
||||
|
||||
var remainder = trimmed.Substring(digitLen).TrimStart();
|
||||
int subId;
|
||||
string type;
|
||||
string payload;
|
||||
|
||||
// 2. FALL: "34 connected" oder "34connected"
|
||||
if (remainder.StartsWith("connected", StringComparison.OrdinalIgnoreCase))
|
||||
if (trimmed.Equals("connected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
subId = -1; // Mapping auf deine interne -1 für InitAsync
|
||||
subId = -1;
|
||||
type = "connected";
|
||||
payload = remainder;
|
||||
}
|
||||
else if (remainder.Length > 0)
|
||||
{
|
||||
// Standard Trade Republic Data Push (z.B. "22A {...}")
|
||||
type = remainder[0].ToString();
|
||||
payload = remainder.Substring(1).TrimStart();
|
||||
payload = trimmed;
|
||||
}
|
||||
else
|
||||
{
|
||||
type = "ack";
|
||||
payload = string.Empty;
|
||||
// Ziffern am Anfang zählen (Sub-ID)
|
||||
var digitLen = 0;
|
||||
while (digitLen < trimmed.Length && char.IsDigit(trimmed[digitLen]))
|
||||
{
|
||||
digitLen++;
|
||||
}
|
||||
|
||||
// Keine Ziffer am Anfang (Reines System-Event/Error ohne ID)
|
||||
if (digitLen == 0)
|
||||
{
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(trimmed.Substring(0, digitLen), out subId))
|
||||
{
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
var remainder = trimmed.Substring(digitLen).TrimStart();
|
||||
|
||||
// 2. FALL: "34 connected" oder "34connected"
|
||||
if (remainder.StartsWith("connected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
subId = -1;
|
||||
type = "connected";
|
||||
payload = remainder;
|
||||
}
|
||||
else if (remainder.Length > 0)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using FinlyticCore.Dtos.TradeRepublic;
|
||||
using FinlyticCore.Models.Assets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using FinlyticCore.Models.Settings;
|
||||
|
||||
namespace FinlyticCore.Services.TradeRepublic;
|
||||
|
||||
@@ -16,72 +16,50 @@ 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);
|
||||
|
||||
/// <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
|
||||
{
|
||||
private readonly TradeRepublicClient _client;
|
||||
private readonly ILogger<TradeRepublicService> _logger;
|
||||
private readonly IFinlyticLogger<TradeRepublicService> _finlyticLogger;
|
||||
private readonly System.Timers.Timer _inactivityTimer;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public TradeRepublicService(TradeRepublicClient client, ILogger<TradeRepublicService> logger)
|
||||
public TradeRepublicService(TradeRepublicClient client, IFinlyticLogger<TradeRepublicService> finlyticLogger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
|
||||
_inactivityTimer = new System.Timers.Timer(TimeSpan.FromSeconds(461).TotalMilliseconds);
|
||||
_inactivityTimer.AutoReset = false;
|
||||
@@ -96,14 +74,14 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
_inactivityTimer.Stop();
|
||||
if (!_client.IsConnected)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Connecting to Trade Republic API WebSocket...", "TradeRepublicChannel");
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Connecting to Trade Republic API WebSocket...");
|
||||
bool connected = await _client.InitAsync();
|
||||
if (!connected)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] Trade Republic WebSocket connection failed or timed out.", "TradeRepublicChannel");
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Trade Republic WebSocket connection failed or timed out.");
|
||||
throw new InvalidOperationException("Trade Republic WebSocket is not connected.");
|
||||
}
|
||||
_logger.LogInformation("[{Channel}] Successfully connected to Trade Republic API.", "TradeRepublicChannel");
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Successfully connected to Trade Republic API.");
|
||||
}
|
||||
_inactivityTimer.Start();
|
||||
}
|
||||
@@ -131,7 +109,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching asset metadata for ISIN {Isin}", "TradeRepublicChannel", isin);
|
||||
await _finlyticLogger.LogErrorAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicService] Error while fetching asset metadata for ISIN {Isin}", isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -206,7 +184,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching stock details for ISIN {Isin}", "TradeRepublicChannel", isin);
|
||||
await _finlyticLogger.LogErrorAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicService] Error while fetching stock details for ISIN {Isin}", isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -221,7 +199,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching derivatives for underlying {Underlying}", "TradeRepublicChannel", request.Underlying);
|
||||
await _finlyticLogger.LogErrorAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicService] Error while fetching derivatives for underlying {Underlying}", request.Underlying);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -232,7 +210,7 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
if (!_client.IsConnected) return;
|
||||
_logger.LogInformation("[{Channel}] Inactivity timer expired. Auto-disconnecting Trade Republic WebSocket.", "TradeRepublicChannel");
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicService] Inactivity timer expired. Auto-disconnecting Trade Republic WebSocket.");
|
||||
await _client.DisconnectAsync();
|
||||
}
|
||||
catch { }
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Clients;
|
||||
using FinlyticCore.Dtos.Fundamentals;
|
||||
using FinlyticCore.Dtos.Yahoo;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Utils;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace FinlyticCore.Services.Yahoo;
|
||||
|
||||
public interface IYahooFinanceScraper
|
||||
{
|
||||
/// <summary>
|
||||
/// Ermittelt den primären Börsenticker zu einer ISIN anhand von Börsenplatz-Prioritäten.
|
||||
/// </summary>
|
||||
Task<TickerInfoDto?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt alle gefundenen Börsenticker zu einer ISIN, sortiert nach Priorität.
|
||||
/// </summary>
|
||||
Task<List<TickerInfoDto>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ruft Fundamental- und Unternehmensdaten primär über die Yahoo Finance API ab
|
||||
/// und fällt automatisch auf den Playwright HTML Scraper zurück, falls keine Daten vorhanden sind.
|
||||
/// </summary>
|
||||
Task<YahooQuoteSummaryModulesDto?> GetQuoteSummaryModulesAsync(
|
||||
string symbolOrIsin,
|
||||
bool forceHtmlScrape = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class YahooFinanceScraper : IYahooFinanceScraper
|
||||
{
|
||||
private const string _serviceName = nameof(YahooFinanceScraper);
|
||||
private readonly YahooFinanceClient _yahooApiClient;
|
||||
private readonly IYahooFinanceHtmlClient _htmlScraperClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IFinlyticLogger<YahooFinanceScraper> _finlyticLogger;
|
||||
|
||||
public YahooFinanceScraper(
|
||||
YahooFinanceClient yahooApiClient,
|
||||
IYahooFinanceHtmlClient htmlScraperClient,
|
||||
IConfiguration configuration,
|
||||
IFinlyticLogger<YahooFinanceScraper> finlyticLogger)
|
||||
{
|
||||
_yahooApiClient = yahooApiClient;
|
||||
_htmlScraperClient = htmlScraperClient;
|
||||
_configuration = configuration;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TickerInfoDto?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tickers = await ResolveAllTickersFromIsinAsync(isin, cancellationToken);
|
||||
return tickers.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<TickerInfoDto>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin)) return new List<TickerInfoDto>();
|
||||
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
var symbols = new List<(string symbol, string exchange, int priority)>();
|
||||
|
||||
// Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017)
|
||||
if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var (cryptoSubtitle, cryptoName) = await CryptoSubtitleResolver.ResolveCryptoInfoAsync(
|
||||
cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cryptoSubtitle))
|
||||
{
|
||||
var cryptoEur = $"{cryptoSubtitle}-EUR";
|
||||
var cryptoUsd = $"{cryptoSubtitle}-USD";
|
||||
|
||||
symbols.Add((cryptoEur, "Crypto", 0));
|
||||
symbols.Add((cryptoUsd, "Crypto", 1));
|
||||
|
||||
try
|
||||
{
|
||||
var searchRes = await _yahooApiClient.SearchAsync(cryptoSubtitle, quotesCount: 10, cancellationToken: cancellationToken);
|
||||
if (searchRes?.Quotes != null)
|
||||
{
|
||||
foreach (var q in searchRes.Quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)))
|
||||
{
|
||||
if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
symbols.Add((q.Symbol, q.Exchange ?? "Crypto", 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.FundamentalsChannel,
|
||||
$"[{_serviceName}] Resolved Crypto ISIN {cleanIsin} to {cryptoEur} using Subtitle {cryptoSubtitle}");
|
||||
|
||||
return symbols
|
||||
.OrderBy(s => s.priority)
|
||||
.Select(s => new TickerInfoDto { Ticker = s.symbol, Exchange = s.exchange })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Suche via ISIN - der allererste Ticker von Yahoo Finance ist der absolute Primary Ticker
|
||||
var primary = await _yahooApiClient.SearchAsync(cleanIsin, quotesCount: 20, cancellationToken: cancellationToken);
|
||||
var quotes = primary?.Quotes ?? new List<YahooSearchQuoteDto>();
|
||||
var validQuotes = quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)).ToList();
|
||||
|
||||
if (validQuotes.Count > 0)
|
||||
{
|
||||
var first = validQuotes[0];
|
||||
symbols.Add((first.Symbol, first.Exchange ?? string.Empty, 0));
|
||||
|
||||
foreach (var q in validQuotes.Skip(1))
|
||||
{
|
||||
if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Falls Ticker gefunden, aber mit Unternehmensname noch mehr Exchangeticker auffindbar sind
|
||||
if (validQuotes.Count > 0)
|
||||
{
|
||||
var companyName = validQuotes[0].LongName ?? validQuotes[0].ShortName;
|
||||
if (!string.IsNullOrWhiteSpace(companyName))
|
||||
{
|
||||
var secondary = await _yahooApiClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken);
|
||||
foreach (var q in secondary?.Quotes ?? new List<YahooSearchQuoteDto>())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(q.Symbol) &&
|
||||
!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex,
|
||||
$"[{_serviceName}] Fehler beim Auflösen des Tickers für ISIN '{cleanIsin}'");
|
||||
}
|
||||
|
||||
return symbols
|
||||
.OrderBy(s => s.priority)
|
||||
.Select(s => new TickerInfoDto { Ticker = s.symbol, Exchange = s.exchange })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<YahooQuoteSummaryModulesDto?> GetQuoteSummaryModulesAsync(
|
||||
string symbolOrIsin,
|
||||
bool forceHtmlScrape = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolOrIsin)) return null;
|
||||
|
||||
var symbol = symbolOrIsin.Trim().ToUpperInvariant();
|
||||
|
||||
// Falls eine ISIN übergeben wurde, zuerst Ticker auflösen
|
||||
if (IsIsin(symbol))
|
||||
{
|
||||
var resolvedTicker = await ResolveTickerFromIsinAsync(symbol, cancellationToken);
|
||||
if (resolvedTicker != null)
|
||||
{
|
||||
symbol = resolvedTicker.Ticker;
|
||||
}
|
||||
}
|
||||
|
||||
YahooQuoteSummaryModulesDto? apiModules = null;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 1. PRIMÄRE DATENQUELLE: Yahoo Finance API (Cookie/Crumb)
|
||||
// -------------------------------------------------------------
|
||||
if (!forceHtmlScrape)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel,
|
||||
$"[{_serviceName}] Starte primären API-Abruf für '{symbol}'...");
|
||||
|
||||
var apiResponse = await _yahooApiClient.GetFullQuoteSummaryAsync(symbol, cancellationToken);
|
||||
apiModules = apiResponse?.QuoteSummary?.Result?.FirstOrDefault();
|
||||
|
||||
if (apiModules != null && HasSufficientData(apiModules))
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel,
|
||||
$"[{_serviceName}] Erfolgreich Daten über API bezogen für '{symbol}'.");
|
||||
return apiModules;
|
||||
}
|
||||
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel,
|
||||
$"[{_serviceName}] API lieferte unvollständige Daten für '{symbol}'. Initiiere Fallback...");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex,
|
||||
$"[{_serviceName}] API-Abruf fehlgeschlagen für '{symbol}'. Wechsle zu Scraper...");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2. FALLBACK DATENQUELLE: Playwright HTML Scraper
|
||||
// -------------------------------------------------------------
|
||||
YahooQuoteSummaryModulesDto? htmlModules = null;
|
||||
try
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel,
|
||||
$"[{_serviceName}] Starte HTML-Scraper Fallback für '{symbol}'...");
|
||||
|
||||
htmlModules = await _htmlScraperClient.ScrapeQuoteSummaryModulesAsync(symbol, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogErrorAsync(CoreSettingKeys.YahooClientChannel, ex,
|
||||
$"[{_serviceName}] HTML-Scraper Fallback ebenfalls fehlgeschlagen für '{symbol}'.");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3. Zusammenführen (Merge API & HTML Fallback)
|
||||
// -------------------------------------------------------------
|
||||
if (apiModules == null) return htmlModules;
|
||||
if (htmlModules == null) return apiModules;
|
||||
|
||||
return MergeModules(apiModules, htmlModules);
|
||||
}
|
||||
|
||||
private static bool HasSufficientData(YahooQuoteSummaryModulesDto modules)
|
||||
{
|
||||
return modules.SummaryDetail != null ||
|
||||
modules.FinancialData != null ||
|
||||
modules.DefaultKeyStatistics != null;
|
||||
}
|
||||
|
||||
private static YahooQuoteSummaryModulesDto MergeModules(
|
||||
YahooQuoteSummaryModulesDto primary,
|
||||
YahooQuoteSummaryModulesDto secondary)
|
||||
{
|
||||
return new YahooQuoteSummaryModulesDto(
|
||||
QuoteType: primary.QuoteType ?? secondary.QuoteType,
|
||||
AssetProfile: primary.AssetProfile ?? secondary.AssetProfile,
|
||||
FinancialData: primary.FinancialData ?? secondary.FinancialData,
|
||||
DefaultKeyStatistics: primary.DefaultKeyStatistics ?? secondary.DefaultKeyStatistics,
|
||||
SummaryDetail: primary.SummaryDetail ?? secondary.SummaryDetail,
|
||||
IncomeStatementHistory: primary.IncomeStatementHistory ?? secondary.IncomeStatementHistory,
|
||||
IncomeStatementHistoryQuarterly: primary.IncomeStatementHistoryQuarterly ?? secondary.IncomeStatementHistoryQuarterly,
|
||||
BalanceSheetHistory: primary.BalanceSheetHistory ?? secondary.BalanceSheetHistory,
|
||||
BalanceSheetHistoryQuarterly: primary.BalanceSheetHistoryQuarterly ?? secondary.BalanceSheetHistoryQuarterly,
|
||||
CashflowStatementHistory: primary.CashflowStatementHistory ?? secondary.CashflowStatementHistory,
|
||||
CashflowStatementHistoryQuarterly: primary.CashflowStatementHistoryQuarterly ?? secondary.CashflowStatementHistoryQuarterly,
|
||||
CalendarEvents: primary.CalendarEvents ?? secondary.CalendarEvents
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsIsin(string value)
|
||||
{
|
||||
return value.Length == 12 &&
|
||||
char.IsLetter(value[0]) &&
|
||||
char.IsLetter(value[1]) &&
|
||||
value.All(char.IsLetterOrDigit);
|
||||
}
|
||||
|
||||
private static int GetExchangePriority(string symbol, string isin)
|
||||
{
|
||||
bool isGermanIsin = isin.StartsWith("DE", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isGermanIsin)
|
||||
{
|
||||
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 1; // Xetra
|
||||
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return 2; // Frankfurt
|
||||
if (symbol.EndsWith(".STU", StringComparison.OrdinalIgnoreCase)) return 3; // Stuttgart
|
||||
if (symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase)) return 4; // Stuttgart (alt)
|
||||
if (symbol.EndsWith(".HM", StringComparison.OrdinalIgnoreCase)) return 5; // Hamburg
|
||||
if (!symbol.Contains('.')) return 6; // US Primary
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!symbol.Contains('.')) return 1; // US Primary (NASDAQ, NYSE)
|
||||
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 2; // Xetra
|
||||
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return 3; // Frankfurt
|
||||
if (symbol.EndsWith(".L", StringComparison.OrdinalIgnoreCase)) return 4; // London
|
||||
if (symbol.EndsWith(".PA", StringComparison.OrdinalIgnoreCase)) return 5; // Paris
|
||||
}
|
||||
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user