feat(core): dynamic settings service, IFinlyticLogger, log broadcaster, and persistent Yahoo auth
This commit is contained in:
@@ -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 { }
|
||||
|
||||
Reference in New Issue
Block a user