using System; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Services; using FinlyticTechnicals.Util; namespace FinlyticTechnicals.Services; /// /// Cleaned, normalized real-time tick ready for multi-timeframe aggregation. /// public record CleanLiveTick( string Isin, decimal MidPrice, decimal Bid, decimal Ask, decimal LastPrice, decimal SpreadPercent, bool IsSpreadVolatile, DateTime TimestampUtc ); public interface ITradeRepublicIngestionService { /// /// Event triggered when a cleaned, UTC-normalized tick arrives. /// event Func? OnTickReceived; /// /// Processes a raw tick from Trade Republic (e.g. via WebSocket or Poller). /// Task ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default); } public class TradeRepublicIngestionService : ITradeRepublicIngestionService { private readonly IFinlyticLogger _finlyticLogger; private static readonly TimeZoneInfo BerlinTimeZone = GetBerlinTimeZone(); public event Func? OnTickReceived; public TradeRepublicIngestionService(IFinlyticLogger finlyticLogger) { _finlyticLogger = finlyticLogger; } /// /// Processes a raw incoming tick with strict UTC normalization, spread check, and mid-price calculation. /// public async Task ProcessRawTickAsync(string isin, decimal bid, decimal ask, decimal? last, DateTime? timestamp, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return null; var cleanIsin = isin.Trim().ToUpperInvariant(); // 1. Strict UTC Normalization DateTime utcTimestamp; if (timestamp.HasValue) { var rawTime = timestamp.Value; if (rawTime.Kind == DateTimeKind.Utc) { utcTimestamp = rawTime; } else if (rawTime.Kind == DateTimeKind.Unspecified) { // Trade Republic ticks typically arrive in German local market time (Europe/Berlin) utcTimestamp = TimeZoneInfo.ConvertTimeToUtc(rawTime, BerlinTimeZone); } else { utcTimestamp = rawTime.ToUniversalTime(); } } else { utcTimestamp = DateTime.UtcNow; } // 2. Clean Mid-Price Calculation: (Bid + Ask) / 2 decimal cleanMidPrice; if (bid > 0m && ask > 0m) { cleanMidPrice = (bid + ask) / 2m; } else if (last.HasValue && last.Value > 0m) { cleanMidPrice = last.Value; if (bid <= 0m) bid = cleanMidPrice; if (ask <= 0m) ask = cleanMidPrice; } else { return null; // Invalid quote } // 3. Spread Calculation & Volatility Tagging decimal spreadPercent = 0m; bool isSpreadVolatile = false; if (cleanMidPrice > 0m && ask >= bid) { spreadPercent = ((ask - bid) / cleanMidPrice) * 100m; if (spreadPercent > 1.5m) { isSpreadVolatile = true; } } var cleanTick = new CleanLiveTick( Isin: cleanIsin, MidPrice: cleanMidPrice, Bid: bid, Ask: ask, LastPrice: last ?? cleanMidPrice, SpreadPercent: spreadPercent, IsSpreadVolatile: isSpreadVolatile, TimestampUtc: utcTimestamp ); if (OnTickReceived != null) { await OnTickReceived.Invoke(cleanTick); } return cleanTick; } private static TimeZoneInfo GetBerlinTimeZone() { try { return TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time"); // Windows ID } catch { try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // Linux IANA ID } catch { return TimeZoneInfo.Utc; } } } }