284 lines
7.2 KiB
C#
284 lines
7.2 KiB
C#
using Backend.DatabaseHandler;
|
|
using Backend.MarketDataRequest;
|
|
using Backend.Interface;
|
|
using IBApi;
|
|
using System.Globalization;
|
|
|
|
namespace Backend.ServiceHander
|
|
{
|
|
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<List<object>> 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<object>().ToList();
|
|
}
|
|
|
|
public async Task<List<object>> 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<object>().ToList();
|
|
}
|
|
|
|
private async Task<List<HistoricalBarRecord>> 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<Bar> 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();
|
|
}
|
|
}
|
|
} |