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);