diff --git a/MarketDataRequest/FundamentalsEquityRequest.cs b/MarketDataRequest/FundamentalsEquityRequest.cs new file mode 100644 index 0000000..83d1205 --- /dev/null +++ b/MarketDataRequest/FundamentalsEquityRequest.cs @@ -0,0 +1,36 @@ +namespace Backend.MarketDataRequest +{ + public sealed class FundamentalsEquityRequest + { + public int RequestId { get; init; } + public string Symbol { get; init; } = string.Empty; + + public string SecType { get; init; } = "STK"; + public string Exchange { get; init; } = "SMART"; + public string PrimaryExchange { get; init; } = "NASDAQ"; + public string Currency { get; init; } = "USD"; + + public bool RegulatorySnapshot { get; init; } = false; + + public void Validate() + { + if (RequestId <= 0) + throw new ArgumentException("RequestId must be greater than 0."); + + if (string.IsNullOrWhiteSpace(Symbol)) + throw new ArgumentException("Symbol is required."); + + if (string.IsNullOrWhiteSpace(SecType)) + throw new ArgumentException("SecType is required."); + + if (string.IsNullOrWhiteSpace(Exchange)) + throw new ArgumentException("Exchange is required."); + + if (string.IsNullOrWhiteSpace(PrimaryExchange)) + throw new ArgumentException("PrimaryExchange is required."); + + if (string.IsNullOrWhiteSpace(Currency)) + throw new ArgumentException("Currency is required."); + } + } +} \ No newline at end of file diff --git a/MarketDataRequest/FundamentalsEquitySnapshot.cs b/MarketDataRequest/FundamentalsEquitySnapshot.cs new file mode 100644 index 0000000..2ef1d51 --- /dev/null +++ b/MarketDataRequest/FundamentalsEquitySnapshot.cs @@ -0,0 +1,25 @@ +namespace Backend.MarketDataRequest +{ + public sealed class FundamentalsEquitySnapshot + { + public string Symbol { get; init; } = string.Empty; + + public double? MarketCap { get; set; } + public double? PERatio { get; set; } + public double? DividendYield { get; set; } + + public bool IsComplete => + MarketCap.HasValue && + PERatio.HasValue && + DividendYield.HasValue; + + public override string ToString() + { + return + $"Symbol={Symbol}, " + + $"MarketCap={MarketCap}, " + + $"PERatio={PERatio}, " + + $"DividendYield={DividendYield}"; + } + } +} \ No newline at end of file diff --git a/MarketDataRequest/HistoricalEquityRequest.cs b/MarketDataRequest/HistoricalEquityRequest.cs index aa51b72..1fd9af2 100644 --- a/MarketDataRequest/HistoricalEquityRequest.cs +++ b/MarketDataRequest/HistoricalEquityRequest.cs @@ -55,14 +55,14 @@ namespace Backend.MarketDataRequest public static class HistoricalRequestMappings { - public static string ToIbString(this HistoricalBarSize barSize) => barSize switch + public static string ToIbString(this HistoricalBarSize BarSize) => BarSize switch { HistoricalBarSize.OneMin => "1 min", HistoricalBarSize.FiveMins => "5 mins", HistoricalBarSize.FifteenMins => "15 mins", HistoricalBarSize.OneHour => "1 hour", HistoricalBarSize.OneDay => "1 day", - _ => throw new ArgumentOutOfRangeException(nameof(barSize)) + _ => throw new ArgumentOutOfRangeException(nameof(BarSize)) }; public static string ToIbString(this HistoricalWhatToShow whatToShow) => whatToShow switch diff --git a/MarketDataRequest/IBGatewayControl.cs b/MarketDataRequest/IBGatewayControl.cs index efd7065..de250ac 100644 --- a/MarketDataRequest/IBGatewayControl.cs +++ b/MarketDataRequest/IBGatewayControl.cs @@ -1,5 +1,6 @@ using IBApi; using System.Collections.Concurrent; +using System.Globalization; namespace Backend.MarketDataRequest { @@ -16,9 +17,18 @@ namespace Backend.MarketDataRequest public bool IsConnected => Client.IsConnected(); public bool IsApiReady => _apiReady; - private readonly ConcurrentDictionary> _barsByRequest = new(); + // Historical data + private readonly ConcurrentDictionary> _BarsByRequest = new(); private readonly ConcurrentDictionary>> _historyRequests = new(); + // Equity data + private readonly ConcurrentDictionary _liveSnapshotsByRequest = new(); + private readonly ConcurrentDictionary> _liveRequests = new(); + + //Fundamentals data + private readonly ConcurrentDictionary _fundamentalsSnapshotsByRequest = new(); + private readonly ConcurrentDictionary> _fundamentalsRequests = new(); + public IBGatewayControl() { Client = new EClientSocket(this, _signal); @@ -121,11 +131,23 @@ namespace Backend.MarketDataRequest Console.WriteLine($"[error] id={id}, code={errorCode}, time={errorTime}, msg={errorMsg}"); - if (id > 0 && _historyRequests.TryRemove(id, out var tcs)) + if (id > 0 && _liveRequests.TryRemove(id, out var liveTcs)) { - _barsByRequest.TryRemove(id, out _); - tcs.TrySetException( - new InvalidOperationException($"IB error for reqId={id}: {errorCode} - {errorMsg}")); + Client.cancelMktData(id); + _liveSnapshotsByRequest.TryRemove(id, out _); + + liveTcs.TrySetException( + new InvalidOperationException($"IB error for live reqId={id}: {errorCode} - {errorMsg}")); + return; + } + + if (id > 0 && _fundamentalsRequests.TryRemove(id, out var fundamentalsTcs)) + { + Client.cancelMktData(id); + _fundamentalsSnapshotsByRequest.TryRemove(id, out _); + + fundamentalsTcs.TrySetException( + new InvalidOperationException($"IB error for fundamentals reqId={id}: {errorCode} - {errorMsg}")); } } @@ -149,7 +171,7 @@ namespace Backend.MarketDataRequest Currency = "USD" }; - _barsByRequest[request.RequestId] = new List(); + _BarsByRequest[request.RequestId] = new List(); Console.WriteLine( $"Requesting historical data: Symbol={request.Symbol}, End={request.EndDateTime}, Duration={request.Duration}, BarSize={request.BarSize.ToIbString()}"); @@ -189,51 +211,51 @@ namespace Backend.MarketDataRequest return tcs.Task.GetAwaiter().GetResult(); _historyRequests.TryRemove(request.RequestId, out _); - _barsByRequest.TryRemove(request.RequestId, out _); + _BarsByRequest.TryRemove(request.RequestId, out _); throw new TimeoutException($"Timed out waiting for historical data for reqId={request.RequestId}."); } catch { _historyRequests.TryRemove(request.RequestId, out _); - _barsByRequest.TryRemove(request.RequestId, out _); + _BarsByRequest.TryRemove(request.RequestId, out _); throw; } } - public override void historicalData(int reqId, Bar bar) + public override void historicalData(int reqId, Bar Bar) { - if (_barsByRequest.TryGetValue(reqId, out var bars)) + if (_BarsByRequest.TryGetValue(reqId, out var Bars)) { - lock (bars) + lock (Bars) { - bars.Add(bar); + Bars.Add(Bar); } } Console.WriteLine( - $"[historicalData] reqId={reqId}, Date={bar.Time}, Open={bar.Open}, High={bar.High}, Low={bar.Low}, Close={bar.Close}, Volume={bar.Volume}"); + $"[historicalData] reqId={reqId}, Date={Bar.Time}, Open={Bar.Open}, High={Bar.High}, Low={Bar.Low}, Close={Bar.Close}, Volume={Bar.Volume}"); } public override void historicalDataEnd(int reqId, string start, string end) { Console.WriteLine($"[historicalDataEnd] reqId={reqId}, start={start}, end={end}"); - if (_barsByRequest.TryRemove(reqId, out var bars)) + if (_BarsByRequest.TryRemove(reqId, out var Bars)) { Console.WriteLine(); Console.WriteLine("=== Final Printed Bars ==="); - lock (bars) + lock (Bars) { - foreach (var bar in bars) + foreach (var Bar in Bars) { Console.WriteLine( - $"{bar.Time} | O:{bar.Open} H:{bar.High} L:{bar.Low} C:{bar.Close} V:{bar.Volume}"); + $"{Bar.Time} | O:{Bar.Open} H:{Bar.High} L:{Bar.Low} C:{Bar.Close} V:{Bar.Volume}"); } } if (_historyRequests.TryRemove(reqId, out var tcs)) { - tcs.TrySetResult(bars); + tcs.TrySetResult(Bars); } } } @@ -253,5 +275,304 @@ namespace Backend.MarketDataRequest if (_disposed) throw new ObjectDisposedException(nameof(IBGatewayControl)); } + + private static bool IsValidNumber(double value) + { + return !double.IsNaN(value) && + !double.IsInfinity(value) && + value > -99999.0; + } + + private static bool TryParseDouble(string raw, out double value) + { + return double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out value); + } + + private static Dictionary ParseFundamentalRatios(string raw) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(raw)) + return result; + + string[] parts = raw.Split(';', StringSplitOptions.RemoveEmptyEntries); + + foreach (string part in parts) + { + int idx = part.IndexOf('='); + if (idx <= 0 || idx >= part.Length - 1) + continue; + + string key = part[..idx].Trim(); + string value = part[(idx + 1)..].Trim(); + result[key] = value; + } + + return result; + } + + private static void TryFillFundamentals(FundamentalsEquitySnapshot snapshot, string raw) + { + var ratios = ParseFundamentalRatios(raw); + + if (!snapshot.MarketCap.HasValue && + ratios.TryGetValue("MKTCAP", out var marketCapRaw) && + TryParseDouble(marketCapRaw, out var marketCap) && + IsValidNumber(marketCap)) + { + snapshot.MarketCap = marketCap; + } + + if (!snapshot.PERatio.HasValue) + { + if (ratios.TryGetValue("PEEXCLXOR", out var peRaw) && + TryParseDouble(peRaw, out var pe) && + IsValidNumber(pe)) + { + snapshot.PERatio = pe; + } + else if (ratios.TryGetValue("APENORM", out var peNormRaw) && + TryParseDouble(peNormRaw, out var peNorm) && + IsValidNumber(peNorm)) + { + snapshot.PERatio = peNorm; + } + } + + if (!snapshot.DividendYield.HasValue && + ratios.TryGetValue("YIELD", out var yieldRaw) && + TryParseDouble(yieldRaw, out var divYield) && + IsValidNumber(divYield)) + { + snapshot.DividendYield = divYield; + } + } + + private void CompleteLiveRequestIfReady(int reqId) + { + if (!_liveSnapshotsByRequest.TryGetValue(reqId, out var snapshot)) + return; + + if (!snapshot.IsComplete) + return; + + Client.cancelMktData(reqId); + _liveSnapshotsByRequest.TryRemove(reqId, out _); + + if (_liveRequests.TryRemove(reqId, out var tcs)) + tcs.TrySetResult(snapshot); + } + + private void CompleteFundamentalsRequestIfReady(int reqId) + { + if (!_fundamentalsSnapshotsByRequest.TryGetValue(reqId, out var snapshot)) + return; + + if (!snapshot.IsComplete) + return; + + Client.cancelMktData(reqId); + _fundamentalsSnapshotsByRequest.TryRemove(reqId, out _); + + if (_fundamentalsRequests.TryRemove(reqId, out var tcs)) + tcs.TrySetResult(snapshot); + } + + public LiveEquitySnapshot RequestLiveEquityBlocking(LiveEquityRequest request, TimeSpan timeout) + { + ThrowIfDisposed(); + request.Validate(); + + if (!Client.IsConnected()) + throw new InvalidOperationException("IB Gateway is not connected."); + + if (!_apiReady) + throw new InvalidOperationException("IB API is not ready yet."); + + if (_liveRequests.ContainsKey(request.RequestId)) + throw new InvalidOperationException($"Live RequestId {request.RequestId} is already in use."); + + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + if (!_liveRequests.TryAdd(request.RequestId, tcs)) + throw new InvalidOperationException($"Unable to register live request {request.RequestId}."); + + var snapshot = new LiveEquitySnapshot + { + Symbol = request.Symbol + }; + + _liveSnapshotsByRequest[request.RequestId] = snapshot; + + try + { + var contract = new Contract + { + Symbol = request.Symbol, + SecType = request.SecType, + Exchange = request.Exchange, + PrimaryExch = request.PrimaryExchange, + Currency = request.Currency + }; + + Console.WriteLine( + $"Requesting live quote data: Symbol={request.Symbol}, Ticks=Last/Volume/Close/Open"); + + Client.reqMktData( + request.RequestId, + contract, + "", + false, + request.RegulatorySnapshot, + null + ); + + if (tcs.Task.Wait(timeout)) + return tcs.Task.GetAwaiter().GetResult(); + + Client.cancelMktData(request.RequestId); + _liveRequests.TryRemove(request.RequestId, out _); + _liveSnapshotsByRequest.TryRemove(request.RequestId, out _); + + throw new TimeoutException( + $"Timed out waiting for live quote data for reqId={request.RequestId}."); + } + catch + { + Client.cancelMktData(request.RequestId); + _liveRequests.TryRemove(request.RequestId, out _); + _liveSnapshotsByRequest.TryRemove(request.RequestId, out _); + throw; + } + } + + public FundamentalsEquitySnapshot RequestFundamentalsBlocking(FundamentalsEquityRequest request, TimeSpan timeout) + { + ThrowIfDisposed(); + request.Validate(); + + if (!Client.IsConnected()) + throw new InvalidOperationException("IB Gateway is not connected."); + + if (!_apiReady) + throw new InvalidOperationException("IB API is not ready yet."); + + if (_fundamentalsRequests.ContainsKey(request.RequestId)) + throw new InvalidOperationException($"Fundamentals RequestId {request.RequestId} is already in use."); + + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + if (!_fundamentalsRequests.TryAdd(request.RequestId, tcs)) + throw new InvalidOperationException($"Unable to register fundamentals request {request.RequestId}."); + + var snapshot = new FundamentalsEquitySnapshot + { + Symbol = request.Symbol + }; + + _fundamentalsSnapshotsByRequest[request.RequestId] = snapshot; + + try + { + var contract = new Contract + { + Symbol = request.Symbol, + SecType = request.SecType, + Exchange = request.Exchange, + PrimaryExch = request.PrimaryExchange, + Currency = request.Currency + }; + + Console.WriteLine( + $"Requesting fundamentals data: Symbol={request.Symbol}, GenericTicks=258"); + + Client.reqMktData( + request.RequestId, + contract, + "258", + false, + request.RegulatorySnapshot, + null + ); + + if (tcs.Task.Wait(timeout)) + return tcs.Task.GetAwaiter().GetResult(); + + Client.cancelMktData(request.RequestId); + _fundamentalsRequests.TryRemove(request.RequestId, out _); + _fundamentalsSnapshotsByRequest.TryRemove(request.RequestId, out _); + + throw new TimeoutException( + $"Timed out waiting for fundamentals data for reqId={request.RequestId}."); + } + catch + { + Client.cancelMktData(request.RequestId); + _fundamentalsRequests.TryRemove(request.RequestId, out _); + _fundamentalsSnapshotsByRequest.TryRemove(request.RequestId, out _); + throw; + } + } + + public override void tickPrice(int tickerId, int field, double price, TickAttrib attribs) + { + if (_liveSnapshotsByRequest.TryGetValue(tickerId, out var liveSnapshot)) + { + if (IsValidNumber(price)) + { + switch (field) + { + case 4: // Last Price + liveSnapshot.LastPrice = price; + break; + + case 9: // Close Price + liveSnapshot.PreviousClose = price; + break; + + case 14: // Open Tick + liveSnapshot.Open = price; + break; + } + } + + CompleteLiveRequestIfReady(tickerId); + } + + Console.WriteLine($"[tickPrice] reqId={tickerId}, field={field}, price={price}"); + } + + public override void tickSize(int tickerId, int field, decimal size) + { + if (_liveSnapshotsByRequest.TryGetValue(tickerId, out var liveSnapshot)) + { + switch (field) + { + case 8: // Volume + liveSnapshot.Volume = (long)size; + break; + } + + CompleteLiveRequestIfReady(tickerId); + } + + Console.WriteLine($"[tickSize] reqId={tickerId}, field={field}, size={size}"); + } + + public override void tickString(int tickerId, int field, string value) + { + if (_fundamentalsSnapshotsByRequest.TryGetValue(tickerId, out var fundamentalsSnapshot)) + { + if (field == 47) + { + TryFillFundamentals(fundamentalsSnapshot, value); + CompleteFundamentalsRequestIfReady(tickerId); + } + } + + Console.WriteLine($"[tickString] reqId={tickerId}, field={field}, value={value}"); + } } } \ No newline at end of file diff --git a/MarketDataRequest/LiveEquityRequest.cs b/MarketDataRequest/LiveEquityRequest.cs index 96b1015..d85c013 100644 --- a/MarketDataRequest/LiveEquityRequest.cs +++ b/MarketDataRequest/LiveEquityRequest.cs @@ -1,91 +1,36 @@ -using System; -using System.Collections.Generic; -using IBApi; - namespace Backend.MarketDataRequest { - public enum LiveMarketDataType - { - RealTime = 1, - Frozen = 2, - Delayed = 3, - DelayedFrozen = 4 - } - public sealed class LiveEquityRequest { - public int RequestId { get; set; } - public string Symbol { get; set; } = string.Empty; - public string Exchange { get; set; } = "SMART"; - public string PrimaryExchange { get; set; } = "NASDAQ"; - public string Currency { get; set; } = "USD"; + public int RequestId { get; init; } + public string Symbol { get; init; } = string.Empty; - /// - /// Generic ticks string, e.g. "233,236" - /// Leave empty for standard top-of-book. - /// - public string GenericTickList { get; set; } = string.Empty; + public string SecType { get; init; } = "STK"; + public string Exchange { get; init; } = "SMART"; + public string PrimaryExchange { get; init; } = "NASDAQ"; + public string Currency { get; init; } = "USD"; - public bool Snapshot { get; set; } = false; - public bool RegulatorySnapshot { get; set; } = false; - public LiveMarketDataType MarketDataType { get; set; } = LiveMarketDataType.RealTime; + public bool RegulatorySnapshot { get; init; } = false; - public void Send(IBGatewayControl gateway) + public void Validate() { - if (gateway == null) - { - throw new ArgumentNullException(nameof(gateway)); - } - - if (!gateway.IsConnected) - { - throw new InvalidOperationException("IB Gateway is not connected."); - } + if (RequestId <= 0) + throw new ArgumentException("RequestId must be greater than 0."); if (string.IsNullOrWhiteSpace(Symbol)) - { - throw new ArgumentException("Symbol cannot be empty."); - } + throw new ArgumentException("Symbol is required."); - gateway.Client.reqMarketDataType((int)MarketDataType); + if (string.IsNullOrWhiteSpace(SecType)) + throw new ArgumentException("SecType is required."); - var contract = BuildContract(); + if (string.IsNullOrWhiteSpace(Exchange)) + throw new ArgumentException("Exchange is required."); - gateway.Client.reqMktData( - RequestId, - contract, - GenericTickList, - Snapshot, - RegulatorySnapshot, - new List() - ); - } + if (string.IsNullOrWhiteSpace(PrimaryExchange)) + throw new ArgumentException("PrimaryExchange is required."); - public void Cancel(IBGatewayControl gateway) - { - if (gateway == null) - { - throw new ArgumentNullException(nameof(gateway)); - } - - if (!gateway.IsConnected) - { - throw new InvalidOperationException("IB Gateway is not connected."); - } - - gateway.Client.cancelMktData(RequestId); - } - - private Contract BuildContract() - { - return new Contract - { - Symbol = Symbol, - SecType = "STK", - Exchange = Exchange, - PrimaryExch = PrimaryExchange, - Currency = Currency - }; + if (string.IsNullOrWhiteSpace(Currency)) + throw new ArgumentException("Currency is required."); } } } \ No newline at end of file diff --git a/MarketDataRequest/LiveEquitySnapshot.cs b/MarketDataRequest/LiveEquitySnapshot.cs new file mode 100644 index 0000000..16052bf --- /dev/null +++ b/MarketDataRequest/LiveEquitySnapshot.cs @@ -0,0 +1,28 @@ +namespace Backend.MarketDataRequest +{ + public sealed class LiveEquitySnapshot + { + public string Symbol { get; init; } = string.Empty; + + public double? LastPrice { get; set; } + public long? Volume { get; set; } + public double? PreviousClose { get; set; } + public double? Open { get; set; } + + public bool IsComplete => + LastPrice.HasValue && + Volume.HasValue && + PreviousClose.HasValue && + Open.HasValue; + + public override string ToString() + { + return + $"Symbol={Symbol}, " + + $"LastPrice={LastPrice}, " + + $"Volume={Volume}, " + + $"PreviousClose={PreviousClose}, " + + $"Open={Open}"; + } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index e55e991..6959d5f 100644 --- a/Program.cs +++ b/Program.cs @@ -11,14 +11,24 @@ namespace Backend public static void Main(string[] args) { var Gateway = new IBGatewayControl(); - Gateway.Start("127.0.0.1", 4002, 1); + 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); + } while (!Gateway.IsApiReady) { Thread.Sleep(50); } - var request = new HistoricalEquityRequest + // ---- TEST HISTORICAL DATA REQUEST ---- + + var HistoricalRequest = new HistoricalEquityRequest { RequestId = 1, Symbol = "NVDA", @@ -31,15 +41,59 @@ namespace Backend FormatDate = 1 }; - List bars = Gateway.RequestHistoricalBlocking( - request, + List Bars = Gateway.RequestHistoricalBlocking(HistoricalRequest, TimeSpan.FromSeconds(15)); + + Console.WriteLine(); + Console.WriteLine($"Received {Bars.Count} bars. Continue program here."); + + // ---- TEST LIVE DATA REQUEST ---- + + var liveRequest = new LiveEquityRequest + { + RequestId = 2, + Symbol = "NVDA" + }; + + var live = Gateway.RequestLiveEquityBlocking( + liveRequest, TimeSpan.FromSeconds(15)); Console.WriteLine(); - Console.WriteLine($"Received {bars.Count} bars. Continue program here."); + Console.WriteLine("=== Live Quote ==="); + Console.WriteLine($"Symbol: {live.Symbol}"); + Console.WriteLine($"Last Price: {live.LastPrice}"); + Console.WriteLine($"Volume: {live.Volume}"); + Console.WriteLine($"Previous Close: {live.PreviousClose}"); + Console.WriteLine($"Open: {live.Open}"); - // do next step here, for example save to database + // ---- FUNDAMENTALS DATA REQUEST ---- + // DOES NOT WORK, NEEDS EXTRA DATA SUBSCRIPTION +/* + var fundamentalsRequest = new FundamentalsEquityRequest + { + RequestId = 3, + Symbol = "NVDA" + }; + + try + { + var fundamentals = Gateway.RequestFundamentalsBlocking( + fundamentalsRequest, + TimeSpan.FromSeconds(15)); + + Console.WriteLine(); + Console.WriteLine("=== Fundamentals ==="); + Console.WriteLine($"Symbol: {fundamentals.Symbol}"); + Console.WriteLine($"Market Cap: {fundamentals.MarketCap}"); + Console.WriteLine($"P/E Ratio: {fundamentals.PERatio}"); + Console.WriteLine($"Dividend Yield: {fundamentals.DividendYield}"); + } + catch (Exception ex) + { + Console.WriteLine($"Fundamentals request failed."); + } +*/ Gateway.Disconnect(); } }