diff --git a/Backend.csproj b/Backend.csproj new file mode 100644 index 0000000..4d2f476 --- /dev/null +++ b/Backend.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/Functions.cs b/Functions.cs new file mode 100644 index 0000000..e69de29 diff --git a/MarketDataRequest/HistoricalEquityRequest.cs b/MarketDataRequest/HistoricalEquityRequest.cs new file mode 100644 index 0000000..aa51b72 --- /dev/null +++ b/MarketDataRequest/HistoricalEquityRequest.cs @@ -0,0 +1,77 @@ +namespace Backend.MarketDataRequest +{ + public enum HistoricalBarSize + { + OneMin, + FiveMins, + FifteenMins, + OneHour, + OneDay + } + + public enum HistoricalWhatToShow + { + Trades, + Bid, + Ask, + Midpoint + } + + public sealed class HistoricalEquityRequest + { + public int RequestId { get; init; } + public string Symbol { get; init; } = string.Empty; + + // Example: "" or "20260311 11:00:00 US/Eastern" + public string EndDateTime { get; init; } = string.Empty; + + // Examples: "1 H", "1 D", "5 D", "1 M" + public string Duration { get; init; } = "1 D"; + + public HistoricalBarSize BarSize { get; init; } = HistoricalBarSize.OneMin; + public HistoricalWhatToShow WhatToShow { get; init; } = HistoricalWhatToShow.Trades; + + public bool UseRth { get; init; } = true; + public bool KeepUpToDate { get; init; } = false; + + // IBKR formatDate=1 returns human-readable dates for intraday bars. + public int FormatDate { get; init; } = 1; + + 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(Duration)) + throw new ArgumentException("Duration is required."); + + if (string.IsNullOrWhiteSpace(EndDateTime)) + throw new ArgumentException("EndDateTime is required for this example."); + } + } + + public static class HistoricalRequestMappings + { + 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)) + }; + + public static string ToIbString(this HistoricalWhatToShow whatToShow) => whatToShow switch + { + HistoricalWhatToShow.Trades => "TRADES", + HistoricalWhatToShow.Bid => "BID", + HistoricalWhatToShow.Ask => "ASK", + HistoricalWhatToShow.Midpoint => "MIDPOINT", + _ => throw new ArgumentOutOfRangeException(nameof(whatToShow)) + }; + } +} \ No newline at end of file diff --git a/MarketDataRequest/IBGatewayControl.cs b/MarketDataRequest/IBGatewayControl.cs new file mode 100644 index 0000000..efd7065 --- /dev/null +++ b/MarketDataRequest/IBGatewayControl.cs @@ -0,0 +1,257 @@ +using IBApi; +using System.Collections.Concurrent; + +namespace Backend.MarketDataRequest +{ + public sealed class IBGatewayControl : DefaultEWrapper, IDisposable + { + private readonly EReaderMonitorSignal _signal = new(); + public EClientSocket Client { get; } + private EReader? _reader; + private Thread? _readerThread; + private volatile bool _disposed; + private volatile bool _readerRunning; + private volatile bool _apiReady; + + public bool IsConnected => Client.IsConnected(); + public bool IsApiReady => _apiReady; + + private readonly ConcurrentDictionary> _barsByRequest = new(); + private readonly ConcurrentDictionary>> _historyRequests = new(); + + public IBGatewayControl() + { + Client = new EClientSocket(this, _signal); + } + + public void Start(string host = "127.0.0.1", int port = 4002, int clientId = 1) + { + ThrowIfDisposed(); + + if (Client.IsConnected()) + return; + + Console.WriteLine($"Connecting to IB Gateway at {host}:{port} with clientId={clientId}..."); + Client.eConnect(host, port, clientId); + + if (!Client.IsConnected()) + throw new InvalidOperationException("Failed to connect to IB Gateway."); + + _reader = new EReader(Client, _signal); + _reader.Start(); + + _readerRunning = true; + _readerThread = new Thread(ReadLoop) + { + IsBackground = true, + Name = "IBGatewayControl.EReader" + }; + _readerThread.Start(); + + Console.WriteLine("IB Gateway socket connected."); + } + + public void Disconnect() + { + if (_disposed) + return; + + _readerRunning = false; + _apiReady = false; + + if (Client.IsConnected()) + { + Console.WriteLine("Disconnecting from IB Gateway..."); + Client.eDisconnect(); + } + } + + private void ReadLoop() + { + try + { + while (_readerRunning && Client.IsConnected()) + { + _signal.waitForSignal(); + _reader?.processMsgs(); + } + } + catch (Exception ex) + { + Console.WriteLine($"[IBGatewayControl reader error] {ex}"); + } + } + + public override void connectAck() + { + Console.WriteLine("[connectAck]"); + } + + public override void nextValidId(int orderId) + { + _apiReady = true; + Console.WriteLine($"[nextValidId] {orderId}"); + } + + public override void managedAccounts(string accountsList) + { + Console.WriteLine($"[managedAccounts] {accountsList}"); + } + + public override void connectionClosed() + { + _apiReady = false; + Console.WriteLine("[connectionClosed]"); + } + + public override void error(Exception e) + { + Console.WriteLine($"[error exception] {e}"); + } + + public override void error(string str) + { + Console.WriteLine($"[error string] {str}"); + } + + public override void error(int id, long errorCode, int errorTime, string errorMsg, string advancedOrderRejectJson) + { + if (errorCode == 2104 || errorCode == 2106 || errorCode == 2158) + return; + + Console.WriteLine($"[error] id={id}, code={errorCode}, time={errorTime}, msg={errorMsg}"); + + if (id > 0 && _historyRequests.TryRemove(id, out var tcs)) + { + _barsByRequest.TryRemove(id, out _); + tcs.TrySetException( + new InvalidOperationException($"IB error for reqId={id}: {errorCode} - {errorMsg}")); + } + } + + public void RequestHistorical(HistoricalEquityRequest request) + { + 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."); + + var contract = new Contract + { + Symbol = request.Symbol, + SecType = "STK", + Exchange = "SMART", + PrimaryExch = "NASDAQ", + Currency = "USD" + }; + + _barsByRequest[request.RequestId] = new List(); + + Console.WriteLine( + $"Requesting historical data: Symbol={request.Symbol}, End={request.EndDateTime}, Duration={request.Duration}, BarSize={request.BarSize.ToIbString()}"); + + Client.reqHistoricalData( + request.RequestId, + contract, + request.EndDateTime, + request.Duration, + request.BarSize.ToIbString(), + request.WhatToShow.ToIbString(), + request.UseRth ? 1 : 0, + request.FormatDate, + request.KeepUpToDate, + null + ); + } + + public List RequestHistoricalBlocking(HistoricalEquityRequest request, TimeSpan timeout) + { + ThrowIfDisposed(); + request.Validate(); + + if (_historyRequests.ContainsKey(request.RequestId)) + throw new InvalidOperationException($"RequestId {request.RequestId} is already in use."); + + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + if (!_historyRequests.TryAdd(request.RequestId, tcs)) + throw new InvalidOperationException($"Unable to register RequestId {request.RequestId}."); + + try + { + RequestHistorical(request); + + if (tcs.Task.Wait(timeout)) + return tcs.Task.GetAwaiter().GetResult(); + + _historyRequests.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 _); + throw; + } + } + + public override void historicalData(int reqId, Bar bar) + { + if (_barsByRequest.TryGetValue(reqId, out var bars)) + { + lock (bars) + { + 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}"); + } + + 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)) + { + Console.WriteLine(); + Console.WriteLine("=== Final Printed Bars ==="); + lock (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}"); + } + } + + if (_historyRequests.TryRemove(reqId, out var tcs)) + { + tcs.TrySetResult(bars); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + + Disconnect(); + _disposed = true; + GC.SuppressFinalize(this); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(IBGatewayControl)); + } + } +} \ No newline at end of file diff --git a/MarketDataRequest/LiveEquityRequest.cs b/MarketDataRequest/LiveEquityRequest.cs new file mode 100644 index 0000000..96b1015 --- /dev/null +++ b/MarketDataRequest/LiveEquityRequest.cs @@ -0,0 +1,91 @@ +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"; + + /// + /// Generic ticks string, e.g. "233,236" + /// Leave empty for standard top-of-book. + /// + public string GenericTickList { get; set; } = string.Empty; + + public bool Snapshot { get; set; } = false; + public bool RegulatorySnapshot { get; set; } = false; + public LiveMarketDataType MarketDataType { get; set; } = LiveMarketDataType.RealTime; + + public void Send(IBGatewayControl gateway) + { + if (gateway == null) + { + throw new ArgumentNullException(nameof(gateway)); + } + + if (!gateway.IsConnected) + { + throw new InvalidOperationException("IB Gateway is not connected."); + } + + if (string.IsNullOrWhiteSpace(Symbol)) + { + throw new ArgumentException("Symbol cannot be empty."); + } + + gateway.Client.reqMarketDataType((int)MarketDataType); + + var contract = BuildContract(); + + gateway.Client.reqMktData( + RequestId, + contract, + GenericTickList, + Snapshot, + RegulatorySnapshot, + new List() + ); + } + + 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 + }; + } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..e55e991 --- /dev/null +++ b/Program.cs @@ -0,0 +1,46 @@ +using Backend.MarketDataRequest; +using IBApi; +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Backend +{ + public static class Program + { + public static void Main(string[] args) + { + var Gateway = new IBGatewayControl(); + Gateway.Start("127.0.0.1", 4002, 1); + + while (!Gateway.IsApiReady) + { + Thread.Sleep(50); + } + + var request = new HistoricalEquityRequest + { + RequestId = 1, + Symbol = "NVDA", + EndDateTime = "20260311 11:00:00 US/Eastern", + Duration = "3600 S", + BarSize = HistoricalBarSize.FiveMins, + WhatToShow = HistoricalWhatToShow.Trades, + UseRth = true, + KeepUpToDate = false, + FormatDate = 1 + }; + + List bars = Gateway.RequestHistoricalBlocking( + request, + TimeSpan.FromSeconds(15)); + + Console.WriteLine(); + Console.WriteLine($"Received {bars.Count} bars. Continue program here."); + + // do next step here, for example save to database + + Gateway.Disconnect(); + } + } +} \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..3cbd1d4 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5246", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7039;http://localhost:5246", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}