using Backend.DatabaseHandler; using Backend.MarketDataRequest; using Backend.Interface; using IBApi; using System.Globalization; namespace Backend.ServiceHandler { public sealed class MarketDataService { private readonly IBGatewayControl Gateway; private readonly IHistoricalMarketDataCache Cache; private int NextRequestId = 1; private readonly object Lock = new(); public MarketDataService(IHistoricalMarketDataCache cache) { Cache = cache; Gateway = new IBGatewayControl(); try { Gateway.Start("127.0.0.1", 4002, 1); } catch { Console.WriteLine("Invalid connection to IB Gateway. Please ensure gateway is running and connected."); Environment.Exit(1); } } private int GetNextRequestId() { lock (Lock) { return NextRequestId++; } } public LiveEquitySnapshot GetLiveQuote(string symbol) { var request = new LiveEquityRequest { RequestId = GetNextRequestId(), Symbol = symbol, SecType = "STK", Exchange = "SMART", PrimaryExchange = "NASDAQ", Currency = "USD", RegulatorySnapshot = false }; return Gateway.RequestLiveEquityBlocking(request, TimeSpan.FromSeconds(10)); } public async Task> GetHistoricalAsync( string symbol, string endDateTime, string duration, HistoricalBarSize barSize, HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades, bool useRth = true) { var bars = await GetHistoricalBarsAsync( symbol, endDateTime, duration, barSize, whatToShow, useRth); return bars.Select(b => new { timestamp = ToIsoUtcString(b.TimestampUtc), open = b.Open, high = b.High, low = b.Low, close = b.Close, volume = b.Volume }).Cast().ToList(); } public async Task> GetHistoricalLineAsync( string symbol, string endDateTime, string duration, HistoricalBarSize barSize, HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades, bool useRth = true) { var bars = await GetHistoricalBarsAsync( symbol, endDateTime, duration, barSize, whatToShow, useRth); return bars.Select(b => new { timestamp = ToIsoUtcString(b.TimestampUtc), price = b.Close, volume = b.Volume }).Cast().ToList(); } private async Task> GetHistoricalBarsAsync( string symbol, string endDateTimeIsoUtc, string duration, HistoricalBarSize barSize, HistoricalWhatToShow whatToShow, bool useRth) { var window = HistoricalRequestParser.BuildWindow(endDateTimeIsoUtc, duration, barSize); var cachedBars = await Cache.GetBarsAsync( symbol, barSize, whatToShow, useRth, window.StartUtc, window.EndUtc); if (cachedBars.Count >= window.ExpectedBarCountMin) { return cachedBars; } var request = new HistoricalEquityRequest { RequestId = GetNextRequestId(), Symbol = symbol, EndDateTime = ToIbHistoricalEndDateTime(window.EndUtc), Duration = NormalizeDuration(duration), BarSize = barSize, WhatToShow = whatToShow, UseRth = useRth, KeepUpToDate = false, FormatDate = 1 }; List ibBars = Gateway.RequestHistoricalBlocking(request, TimeSpan.FromSeconds(20)); var mappedBars = ibBars.Select(b => new HistoricalBarRecord { Symbol = symbol, BarSize = barSize.ToIbString(), WhatToShow = whatToShow.ToIbString(), UseRth = useRth, TimestampUtc = ParseIbBarTimeToUtc(b.Time), Open = b.Open, High = b.High, Low = b.Low, Close = b.Close, Volume = (long)b.Volume }) .OrderBy(b => b.TimestampUtc) .ToList(); await Cache.UpsertBarsAsync(mappedBars); return mappedBars .Where(b => b.TimestampUtc >= window.StartUtc && b.TimestampUtc <= window.EndUtc) .OrderBy(b => b.TimestampUtc) .ToList(); } public (bool Found, string Message) CheckSymbol(string symbol) { var request = new SymbolLookupRequest { RequestId = GetNextRequestId(), Pattern = symbol }; ContractDescription[] results = Gateway.RequestMatchingSymbolsBlocking(request, TimeSpan.FromSeconds(10)); // Find exact symbol matches (IBKR pattern search may return partial matches) var exactMatches = results .Where(c => string.Equals(c.Contract.Symbol, symbol, StringComparison.OrdinalIgnoreCase)) .ToList(); bool isStockOrEtf = exactMatches.Any(c => string.Equals(c.Contract.SecType, "STK", StringComparison.OrdinalIgnoreCase) || string.Equals(c.Contract.SecType, "ETF", StringComparison.OrdinalIgnoreCase)); if (!isStockOrEtf) return (false, "This symbol does not belong to any stock or ETF, please try again."); bool isNasdaq = exactMatches.Any(c => (string.Equals(c.Contract.SecType, "STK", StringComparison.OrdinalIgnoreCase) || string.Equals(c.Contract.SecType, "ETF", StringComparison.OrdinalIgnoreCase)) && c.Contract.PrimaryExch.Contains("NASDAQ", StringComparison.OrdinalIgnoreCase)); if (!isNasdaq) return (false, "Unfortunately, this symbol is currently unsupported. We only support NASDAQ listed stocks."); return (true, "Symbol found."); } private static string NormalizeDuration(string duration) { return duration.Trim().Replace("+", " "); } private static string ToIsoUtcString(DateTime utc) { return utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); } private static string ToIbHistoricalEndDateTime(DateTime utc) { var eastern = GetEasternTimeZone(); var easternTime = TimeZoneInfo.ConvertTimeFromUtc(utc.ToUniversalTime(), eastern); return easternTime.ToString("yyyyMMdd HH:mm:ss", CultureInfo.InvariantCulture) + " US/Eastern"; } private static DateTime ParseIbBarTimeToUtc(string raw) { if (string.IsNullOrWhiteSpace(raw)) throw new FormatException("IB bar time is empty."); raw = raw.Trim(); const string easternSuffix = " US/Eastern"; if (raw.EndsWith(easternSuffix, StringComparison.Ordinal)) { string localPart = raw[..^easternSuffix.Length]; if (!DateTime.TryParseExact( localPart, "yyyyMMdd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var easternLocal)) { throw new FormatException($"Unable to parse IB datetime portion: {localPart}"); } return TimeZoneInfo.ConvertTimeToUtc(easternLocal, GetEasternTimeZone()); } if (DateTime.TryParseExact( raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dayOnly)) { // Daily bars: treat as midnight UTC for storage consistency. return DateTime.SpecifyKind(dayOnly, DateTimeKind.Utc); } if (DateTimeOffset.TryParseExact( raw, new[] { "yyyy-MM-ddTHH:mm:ss'Z'", "O" }, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var iso)) { return iso.UtcDateTime; } throw new FormatException($"Unable to parse IB bar time: {raw}"); } private static TimeZoneInfo GetEasternTimeZone() { return TimeZoneInfo.FindSystemTimeZoneById( OperatingSystem.IsWindows() ? "Eastern Standard Time" : "America/New_York"); } public void Dispose() { Gateway.Dispose(); } public void Disconnect() { Gateway.Disconnect(); } } }