using System; using System.Collections.Concurrent; 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; namespace FinlyticCore.Services.TradeRepublic; /// /// A managed, thread-safe WebSocket client designed to communicate with the Trade Republic API. /// Supports both single RPC requests and real-time live ticker subscriptions (e.g. {isin}.TIB). /// public class TradeRepublicClient : ManagedWebSocket { private readonly IFinlyticLogger _finlyticLogger; private int _currentSub; private readonly ConcurrentDictionary> _pendingRequests = new(); private readonly ConcurrentDictionary> _tickerSubscriptions = new(); public event Action? UnhandledMessageReceived; public event Action? SystemMessageReceived; /// /// Initializes a new instance of the class. /// /// The logger instance. public TradeRepublicClient(IFinlyticLogger finlyticLogger) { _finlyticLogger = finlyticLogger; } /// /// Connects to the Trade Republic WebSocket API. /// /// The cancellation token. /// A task that represents the asynchronous operation. The task result contains a boolean indicating whether the connection was successful. public async Task InitAsync(CancellationToken cancellationToken = default) { if (IsConnected) return true; await ConnectAsync("wss://api.traderepublic.com/", TimeSpan.FromSeconds(10)); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(-1, tcs); try { var json = JsonSerializer.Serialize(new TradeRepublicConnectRequest(), typeof(TradeRepublicConnectRequest), FinlyticJsonSerializerContext.Default); await SendAsync($"connect 34 {json}"); var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); if (string.IsNullOrWhiteSpace(res.Type)) return false; var isConnected = res.Type == "connected"; if (isConnected) { await _finlyticLogger.LogInfoAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] WebSocket connection to Trade Republic established."); } return isConnected; } catch (Exception ex) { _pendingRequests.TryRemove(-1, out _); await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Failed or timed out establishing Trade Republic WebSocket connection."); return false; } } /// /// Sends a JSON request to the Trade Republic WebSocket API and waits for the response. /// /// The expected response type. /// The request type. /// The request to send. /// The cancellation token. /// A task that represents the asynchronous operation. The task result contains the deserialized response, or null if the request failed or timed out. public async Task SendRequestAsync(TRequest request, CancellationToken cancellationToken = default) where TResponse : class where TRequest : class { var tempSub = Interlocked.Increment(ref _currentSub); var msg = $"sub {tempSub} {JsonSerializer.Serialize(request, typeof(TRequest), FinlyticJsonSerializerContext.Default)}"; await _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Sent (Request): {Message}", msg); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(tempSub, tcs); await SendAsync(msg); try { var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(8), cancellationToken); if (string.IsNullOrWhiteSpace(res.Data) || !res.Type.Contains('A')) return null; return (TResponse?)JsonSerializer.Deserialize(res.Data, typeof(TResponse), FinlyticJsonSerializerContext.Default); } catch (Exception ex) { await _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Error waiting for Trade Republic response ID {SubId}", tempSub); return null; } finally { _pendingRequests.TryRemove(tempSub, out _); try { await SendAsync($"unsub {tempSub}"); } catch { } } } /// /// Subscribes to the real-time ticker stream for a specific ISIN (e.g., US5398301094.TIB). /// public async Task SubscribeTickerAsync(string isin, Action onTick, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return null; var cleanIsin = isin.Trim().ToUpperInvariant(); var tickerId = cleanIsin.EndsWith(".TIB") ? cleanIsin : $"{cleanIsin}.TIB"; var tempSub = Interlocked.Increment(ref _currentSub); var req = new TradeRepublicTickerRequest(tickerId); var msg = $"sub {tempSub} {JsonSerializer.Serialize(req, typeof(TradeRepublicTickerRequest), FinlyticJsonSerializerContext.Default)}"; _tickerSubscriptions[tempSub] = jsonPayload => { if (string.IsNullOrWhiteSpace(jsonPayload) || (!jsonPayload.TrimStart().StartsWith('{') && !jsonPayload.TrimStart().StartsWith('['))) return; try { var tickerRes = (TradeRepublicTickerResponse?)JsonSerializer.Deserialize(jsonPayload, typeof(TradeRepublicTickerResponse), FinlyticJsonSerializerContext.Default); if (tickerRes != null) { onTick(tickerRes); } } catch (Exception ex) { _ = _finlyticLogger.LogWarningAsync(CoreSettingKeys.TradeRepublicChannel, ex, "[TradeRepublicClient] Failed to parse real-time ticker payload for {TickerId}", tickerId); } }; 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; } /// /// Unsubscribes from a real-time ticker stream. /// /// The subscription ID to unsubscribe. /// A task representing the async operation. public async Task UnsubscribeTickerAsync(int subId) { _tickerSubscriptions.TryRemove(subId, out _); try { await SendAsync($"unsub {subId}"); } catch { } } /// protected override void OnMessageReceived(string message) { if (string.IsNullOrWhiteSpace(message)) return; _ = _finlyticLogger.LogDebugAsync(CoreSettingKeys.TradeRepublicChannel, "[TradeRepublicClient] TR WS Recv: {Message}", message); var trimmed = message.Trim(); int subId; string type; string payload; if (trimmed.Equals("connected", StringComparison.OrdinalIgnoreCase)) { 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++; } // 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); } } /// /// Represents a received message from the Trade Republic WebSocket. /// /// The subscription ID. /// The message type. /// The payload data. public record ReceivedMessage(int SubId, string Type, string Data);