UpbitWebSocketService.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Crypto;
  3. using Application.Abstractions.Data;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Hosting;
  7. using Microsoft.Extensions.Logging;
  8. using System.Buffers;
  9. using System.Net.WebSockets;
  10. using System.Text;
  11. using System.Text.Json;
  12. namespace Infrastructure.Crypto;
  13. public sealed class UpbitWebSocketService(
  14. IServiceScopeFactory scopeFactory,
  15. ICacheService cache,
  16. ICryptoHubService hub,
  17. ILogger<UpbitWebSocketService> logger
  18. ) : BackgroundService {
  19. private static readonly Uri _wsUri = new("wss://api.upbit.com/websocket/v1");
  20. private static readonly TimeSpan _reconnectDelay = TimeSpan.FromSeconds(5);
  21. private static readonly TimeSpan _pingInterval = TimeSpan.FromSeconds(60);
  22. protected override async Task ExecuteAsync(CancellationToken ct)
  23. {
  24. // 서비스 시작 직후 잠시 대기 (DB 준비)
  25. await Task.Delay(3000, ct);
  26. while (!ct.IsCancellationRequested)
  27. {
  28. try
  29. {
  30. await ConnectAndReceiveAsync(ct);
  31. }
  32. catch (OperationCanceledException) when (ct.IsCancellationRequested)
  33. {
  34. break;
  35. }
  36. catch (Exception ex)
  37. {
  38. logger.LogWarning(ex, "Upbit WebSocket 연결 실패. {Delay}초 후 재연결...", _reconnectDelay.TotalSeconds);
  39. await Task.Delay(_reconnectDelay, ct);
  40. }
  41. }
  42. }
  43. // 연결 진행
  44. private async Task ConnectAndReceiveAsync(CancellationToken ct)
  45. {
  46. var marketCodes = await GetMarketCodesAsync(ct);
  47. if (marketCodes.Count == 0)
  48. {
  49. logger.LogInformation("활성 거래쌍이 없습니다. 30초 후 재확인...");
  50. await Task.Delay(TimeSpan.FromSeconds(30), ct);
  51. return;
  52. }
  53. var uniqueQuotes = marketCodes.Select(x => x.Split('-')[0]).Distinct().Count();
  54. using var ws = new ClientWebSocket();
  55. await ws.ConnectAsync(_wsUri, ct);
  56. logger.LogInformation("Upbit WebSocket 연결 완료. 코인 수: {0}, 마켓 수: {1}", marketCodes.Count, uniqueQuotes);
  57. // 구독 메시지 전송
  58. var subscribeMsg = BuildSubscribeMessage(marketCodes);
  59. var msgBytes = Encoding.UTF8.GetBytes(subscribeMsg);
  60. await ws.SendAsync(new ArraySegment<byte>(msgBytes), WebSocketMessageType.Text, true, ct);
  61. // Ticker 수집 딕셔너리
  62. var tickers = new Dictionary<string, UpbitTicker>();
  63. // PING 타이머
  64. using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
  65. var pingTask = PingLoopAsync(ws, pingCts.Token);
  66. try
  67. {
  68. var buffer = ArrayPool<byte>.Shared.Rent(8192);
  69. try
  70. {
  71. while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
  72. {
  73. using var ms = new MemoryStream();
  74. WebSocketReceiveResult result;
  75. do
  76. {
  77. result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
  78. if (result.MessageType == WebSocketMessageType.Close)
  79. {
  80. return;
  81. }
  82. ms.Write(buffer, 0, result.Count);
  83. } while (!result.EndOfMessage);
  84. if (result.MessageType is WebSocketMessageType.Text or WebSocketMessageType.Binary)
  85. {
  86. var json = Encoding.UTF8.GetString(ms.ToArray());
  87. await ProcessMessageAsync(json, tickers, ct);
  88. }
  89. }
  90. }
  91. finally
  92. {
  93. ArrayPool<byte>.Shared.Return(buffer);
  94. }
  95. }
  96. finally
  97. {
  98. await pingCts.CancelAsync();
  99. try
  100. {
  101. await pingTask;
  102. }
  103. catch
  104. {
  105. /* ping 태스크 정리 */
  106. }
  107. }
  108. }
  109. // 수신 메시지를 ty 필드로 분기 처리
  110. private async Task ProcessMessageAsync(string json, Dictionary<string, UpbitTicker> tickers, CancellationToken ct)
  111. {
  112. try
  113. {
  114. using var doc = JsonDocument.Parse(json);
  115. var root = doc.RootElement;
  116. if (!root.TryGetProperty("ty", out var tyProp))
  117. {
  118. return;
  119. }
  120. var type = tyProp.GetString() ?? "";
  121. if (type == "ticker")
  122. {
  123. await ProcessTickerMessageAsync(root, tickers, ct);
  124. // 전체 Ticker 목록을 quote별로 Redis 저장 + SignalR 전송
  125. if (tickers.Count > 0)
  126. {
  127. var grouped = tickers.Values.GroupBy(t => t.Market.Split('-')[0]);
  128. foreach (var group in grouped)
  129. {
  130. await cache.SetAsync(CacheKeys.CryptoTickers(group.Key), group.ToList(), ct);
  131. }
  132. try
  133. {
  134. foreach (var group in grouped)
  135. {
  136. var hubTickers = group.Select(t => new CryptoHubData.TickerData(
  137. t.Market, t.Market.Split('-')[^1],
  138. t.OpeningPrice, t.HighPrice, t.LowPrice, t.TradePrice,
  139. t.PrevClosingPrice, t.Change, t.ChangePrice, t.SignedChangePrice,
  140. t.ChangeRate, t.SignedChangeRate, t.TradeVolume, t.AccTradeVolume,
  141. t.AccTradeVolume24h, t.AccTradePrice, t.AccTradePrice24h,
  142. t.TradeDate, t.TradeTime, t.TradeTimestamp, t.AskBid,
  143. t.AccAskVolume, t.AccBidVolume, t.Highest52WeekPrice,
  144. t.Highest52WeekDate, t.Lowest52WeekPrice, t.Lowest52WeekDate,
  145. t.MarketState, t.DelistingDate, t.MarketWarning,
  146. t.Timestamp, t.StreamType
  147. )).ToList();
  148. await hub.SendTickersAsync(hubTickers, ct);
  149. }
  150. }
  151. catch (Exception ex)
  152. {
  153. logger.LogDebug(ex, "SignalR tickers 전송 실패");
  154. }
  155. }
  156. }
  157. else if (type == "trade")
  158. {
  159. await ProcessTradeMessageAsync(root, ct);
  160. }
  161. else if (type == "orderbook")
  162. {
  163. await ProcessOrderbookMessageAsync(root, ct);
  164. }
  165. else if (type.StartsWith("candle"))
  166. {
  167. await ProcessCandleMessageAsync(root, ct);
  168. }
  169. }
  170. catch (JsonException ex)
  171. {
  172. logger.LogDebug(ex, "WebSocket 메시지 파싱 실패: {Json}", json[..Math.Min(json.Length, 200)]);
  173. }
  174. }
  175. // DB에서 활성 코인의 전체 마켓 코드를 CoinMarket 테이블에서 조회
  176. private async Task<List<string>> GetMarketCodesAsync(CancellationToken ct)
  177. {
  178. using var scope = scopeFactory.CreateScope();
  179. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  180. return await db.CoinMarket.AsNoTracking().Join(db.Coin.AsNoTracking().Where(c => c.IsActive && !c.IsDelisted), cm => cm.CoinID, c => c.ID, (cm, c) => cm.Market).ToListAsync(ct);
  181. }
  182. // 구독 메시지 생성 (Ticker + Trade + Orderbook + Candle.1m)
  183. private static string BuildSubscribeMessage(List<string> marketCodes)
  184. {
  185. var codes = string.Join("\",\"", marketCodes);
  186. var codesJson = $"[\"{codes}\"]";
  187. return $"[{{\"ticket\":\"bitforum\"}},"
  188. + $"{{\"type\":\"ticker\",\"codes\":{codesJson},\"isOnlyRealtime\":true}},"
  189. + $"{{\"type\":\"trade\",\"codes\":{codesJson},\"isOnlyRealtime\":true}},"
  190. + $"{{\"type\":\"orderbook\",\"codes\":{codesJson},\"isOnlyRealtime\":true}},"
  191. + $"{{\"type\":\"candle.1m\",\"codes\":{codesJson},\"isOnlyRealtime\":true}},"
  192. + $"{{\"format\":\"SIMPLE\"}}]";
  193. }
  194. // ─── Ticker ─────────────────────────────────────────────────────
  195. private async Task ProcessTickerMessageAsync(JsonElement root, Dictionary<string, UpbitTicker> tickers, CancellationToken ct)
  196. {
  197. var market = root.TryGetProperty("cd", out var cd) ? cd.GetString() ?? "" : "";
  198. if (string.IsNullOrEmpty(market))
  199. {
  200. return;
  201. }
  202. var ticker = new UpbitTicker(
  203. market,
  204. root.TryGetProperty("op", out var op) ? op.GetDecimal() : 0m,
  205. root.TryGetProperty("hp", out var hp) ? hp.GetDecimal() : 0m,
  206. root.TryGetProperty("lp", out var lp) ? lp.GetDecimal() : 0m,
  207. root.TryGetProperty("tp", out var tp) ? tp.GetDecimal() : 0m,
  208. root.TryGetProperty("pcp", out var pcp) ? pcp.GetDecimal() : 0m,
  209. GetSafeString(root, "c"),
  210. root.TryGetProperty("cp", out var cpVal) ? cpVal.GetDecimal() : 0m,
  211. root.TryGetProperty("scp", out var scp) ? scp.GetDecimal() : 0m,
  212. root.TryGetProperty("cr", out var cr) ? cr.GetDecimal() : 0m,
  213. root.TryGetProperty("scr", out var scr) ? scr.GetDecimal() : 0m,
  214. root.TryGetProperty("tv", out var tv) ? tv.GetDecimal() : 0m,
  215. root.TryGetProperty("atv", out var atv) ? atv.GetDecimal() : 0m,
  216. root.TryGetProperty("atv24h", out var atv24h) ? atv24h.GetDecimal() : 0m,
  217. root.TryGetProperty("atp", out var atp) ? atp.GetDecimal() : 0m,
  218. root.TryGetProperty("atp24h", out var atp24h) ? atp24h.GetDecimal() : 0m,
  219. GetSafeString(root, "tdt"),
  220. GetSafeString(root, "ttm"),
  221. root.TryGetProperty("ttms", out var ttms) ? ttms.GetInt64() : 0L,
  222. GetSafeString(root, "ab"),
  223. root.TryGetProperty("aav", out var aav) ? aav.GetDecimal() : 0m,
  224. root.TryGetProperty("abv", out var abv) ? abv.GetDecimal() : 0m,
  225. root.TryGetProperty("h52wp", out var h52wp) ? h52wp.GetDecimal() : 0m,
  226. GetSafeString(root, "h52wdt"),
  227. root.TryGetProperty("l52wp", out var l52wp) ? l52wp.GetDecimal() : 0m,
  228. GetSafeString(root, "l52wdt"),
  229. GetSafeString(root, "ms"),
  230. GetSafeNullableString(root, "dd"),
  231. GetSafeString(root, "mw"),
  232. root.TryGetProperty("tms", out var tms) ? tms.GetInt64() : 0L,
  233. GetSafeString(root, "st")
  234. );
  235. tickers[market] = ticker;
  236. // 개별 Ticker Redis 저장 + SignalR 전송
  237. var symbol = market.Split('-')[^1];
  238. await cache.SetAsync(CacheKeys.CryptoTicker(market), ticker, ct);
  239. try
  240. {
  241. await hub.SendTickerAsync(new CryptoHubData.TickerData(
  242. market, symbol, ticker.OpeningPrice, ticker.HighPrice, ticker.LowPrice,
  243. ticker.TradePrice, ticker.PrevClosingPrice, ticker.Change,
  244. ticker.ChangePrice, ticker.SignedChangePrice, ticker.ChangeRate,
  245. ticker.SignedChangeRate, ticker.TradeVolume, ticker.AccTradeVolume,
  246. ticker.AccTradeVolume24h, ticker.AccTradePrice, ticker.AccTradePrice24h,
  247. ticker.TradeDate, ticker.TradeTime, ticker.TradeTimestamp, ticker.AskBid,
  248. ticker.AccAskVolume, ticker.AccBidVolume, ticker.Highest52WeekPrice,
  249. ticker.Highest52WeekDate, ticker.Lowest52WeekPrice, ticker.Lowest52WeekDate,
  250. ticker.MarketState, ticker.DelistingDate, ticker.MarketWarning,
  251. ticker.Timestamp, ticker.StreamType
  252. ), ct);
  253. }
  254. catch (Exception ex)
  255. {
  256. logger.LogDebug(ex, "SignalR ticker 전송 실패: {Market}", market);
  257. }
  258. }
  259. // ─── Trade ──────────────────────────────────────────────────────
  260. // SIMPLE 필드: cd, tp, tv, ab, pcp, c, cp, td, ttm, ttms, sid, tms, st, bap, bas, bbp, bbs
  261. private async Task ProcessTradeMessageAsync(JsonElement root, CancellationToken ct)
  262. {
  263. var market = root.TryGetProperty("cd", out var cd) ? cd.GetString() ?? "" : "";
  264. if (string.IsNullOrEmpty(market))
  265. {
  266. return;
  267. }
  268. var symbol = market.Split('-')[^1];
  269. var trade = new LiveTrade(
  270. root.TryGetProperty("tp", out var tp) ? tp.GetDecimal() : 0m,
  271. root.TryGetProperty("tv", out var tv) ? tv.GetDecimal() : 0m,
  272. root.TryGetProperty("ab", out var ab) ? ab.GetString() ?? "" : "",
  273. root.TryGetProperty("pcp", out var pcp) ? pcp.GetDecimal() : 0m,
  274. root.TryGetProperty("c", out var c) ? c.GetString() ?? "" : "",
  275. root.TryGetProperty("cp", out var cp) ? cp.GetDecimal() : 0m,
  276. root.TryGetProperty("td", out var td) ? td.GetString() ?? "" : "",
  277. root.TryGetProperty("ttm", out var ttm) ? ttm.GetString() ?? "" : "",
  278. root.TryGetProperty("ttms", out var ttms) ? ttms.GetInt64() : 0L,
  279. root.TryGetProperty("sid", out var sid) ? sid.GetInt64() : 0L,
  280. root.TryGetProperty("tms", out var tms) ? tms.GetInt64() : 0L,
  281. root.TryGetProperty("st", out var st) ? st.GetString() ?? "" : "",
  282. root.TryGetProperty("bap", out var bap) ? bap.GetDecimal() : 0m,
  283. root.TryGetProperty("bas", out var bas) ? bas.GetDecimal() : 0m,
  284. root.TryGetProperty("bbp", out var bbp) ? bbp.GetDecimal() : 0m,
  285. root.TryGetProperty("bbs", out var bbs) ? bbs.GetDecimal() : 0m
  286. );
  287. await cache.SetAsync(CacheKeys.CryptoTradeLive(market), trade, ct);
  288. try
  289. {
  290. await hub.SendTradeAsync(new CryptoHubData.TradeData(
  291. market, symbol, trade.TradePrice, trade.TradeVolume, trade.AskBid,
  292. trade.PrevClosingPrice, trade.Change, trade.ChangePrice,
  293. trade.TradeDate, trade.TradeTime, trade.TradeTimestamp,
  294. trade.SequentialId, trade.Timestamp, trade.StreamType,
  295. trade.BestAskPrice, trade.BestAskSize, trade.BestBidPrice, trade.BestBidSize
  296. ), ct);
  297. }
  298. catch (Exception ex)
  299. {
  300. logger.LogDebug(ex, "SignalR trade 전송 실패: {Market}", market);
  301. }
  302. }
  303. // ─── Orderbook ──────────────────────────────────────────────────
  304. // SIMPLE 필드: cd, tas, tbs, obu[{ap, bp, as, bs}], tms, lv, st
  305. private async Task ProcessOrderbookMessageAsync(JsonElement root, CancellationToken ct)
  306. {
  307. var market = root.TryGetProperty("cd", out var cd) ? cd.GetString() ?? "" : "";
  308. if (string.IsNullOrEmpty(market))
  309. {
  310. return;
  311. }
  312. var symbol = market.Split('-')[^1];
  313. var totalAskSize = root.TryGetProperty("tas", out var tas) ? tas.GetDecimal() : 0m;
  314. var totalBidSize = root.TryGetProperty("tbs", out var tbs) ? tbs.GetDecimal() : 0m;
  315. var timestamp = root.TryGetProperty("tms", out var tms) ? tms.GetInt64() : 0L;
  316. var level = root.TryGetProperty("lv", out var lv) ? lv.GetDecimal() : 0m;
  317. var streamType = root.TryGetProperty("st", out var st) ? st.GetString() ?? "" : "";
  318. var units = new List<LiveOrderbookUnit>();
  319. if (root.TryGetProperty("obu", out var obu) && obu.ValueKind == JsonValueKind.Array)
  320. {
  321. foreach (var u in obu.EnumerateArray())
  322. {
  323. var askPrice = u.TryGetProperty("ap", out var ap) ? ap.GetDecimal() : 0m;
  324. var bidPrice = u.TryGetProperty("bp", out var bp) ? bp.GetDecimal() : 0m;
  325. var askSize = u.TryGetProperty("as", out var asVal) ? asVal.GetDecimal() : 0m;
  326. var bidSize = u.TryGetProperty("bs", out var bs) ? bs.GetDecimal() : 0m;
  327. units.Add(new LiveOrderbookUnit(askPrice, bidPrice, askSize, bidSize));
  328. }
  329. }
  330. var orderbook = new LiveOrderbook(totalAskSize, totalBidSize, units, timestamp, level, streamType);
  331. await cache.SetAsync(CacheKeys.CryptoOrderbookLive(market), orderbook, ct);
  332. try
  333. {
  334. await hub.SendOrderbookAsync(new CryptoHubData.OrderbookData(
  335. market, symbol, orderbook.TotalAskSize, orderbook.TotalBidSize,
  336. [..orderbook.Units.Select(u => new CryptoHubData.OrderbookUnitData(u.AskPrice, u.BidPrice, u.AskSize, u.BidSize))],
  337. orderbook.Timestamp, orderbook.Level, orderbook.StreamType
  338. ), ct);
  339. }
  340. catch (Exception ex)
  341. {
  342. logger.LogDebug(ex, "SignalR orderbook 전송 실패: {Market}", market);
  343. }
  344. }
  345. // ─── Candle ─────────────────────────────────────────────────────
  346. // SIMPLE 필드: cd, cdttmu, cdttmk, op, hp, lp, tp, catv, catp, tms, st
  347. private async Task ProcessCandleMessageAsync(JsonElement root, CancellationToken ct)
  348. {
  349. var market = root.TryGetProperty("cd", out var cd) ? cd.GetString() ?? "" : "";
  350. if (string.IsNullOrEmpty(market))
  351. {
  352. return;
  353. }
  354. var symbol = market.Split('-')[^1];
  355. var candle = new LiveCandle(
  356. root.TryGetProperty("cdttmu", out var cdttmu) ? cdttmu.GetString() ?? "" : "",
  357. root.TryGetProperty("cdttmk", out var cdttmk) ? cdttmk.GetString() ?? "" : "",
  358. root.TryGetProperty("op", out var op) ? op.GetDecimal() : 0m,
  359. root.TryGetProperty("hp", out var hp) ? hp.GetDecimal() : 0m,
  360. root.TryGetProperty("lp", out var lp) ? lp.GetDecimal() : 0m,
  361. root.TryGetProperty("tp", out var tp) ? tp.GetDecimal() : 0m,
  362. root.TryGetProperty("catv", out var catv) ? catv.GetDecimal() : 0m,
  363. root.TryGetProperty("catp", out var catp) ? catp.GetDecimal() : 0m,
  364. root.TryGetProperty("tms", out var tms) ? tms.GetInt64() : 0L,
  365. root.TryGetProperty("st", out var st) ? st.GetString() ?? "" : ""
  366. );
  367. await cache.SetAsync(CacheKeys.CryptoCandleLive(market, "m1"), candle, ct);
  368. try
  369. {
  370. await hub.SendCandleAsync(new CryptoHubData.CandleData(
  371. market, symbol, candle.CandleDateTimeUtc, candle.CandleDateTimeKst,
  372. candle.OpeningPrice, candle.HighPrice, candle.LowPrice, candle.TradePrice,
  373. candle.CandleAccTradeVolume, candle.CandleAccTradePrice,
  374. candle.Timestamp, candle.StreamType
  375. ), ct);
  376. }
  377. catch (Exception ex)
  378. {
  379. logger.LogDebug(ex, "SignalR candle 전송 실패: {Market}", market);
  380. }
  381. }
  382. // ─── PING ───────────────────────────────────────────────────────
  383. private static async Task PingLoopAsync(ClientWebSocket ws, CancellationToken ct)
  384. {
  385. try
  386. {
  387. while (!ct.IsCancellationRequested && ws.State == WebSocketState.Open)
  388. {
  389. await Task.Delay(_pingInterval, ct);
  390. if (ws.State == WebSocketState.Open)
  391. {
  392. var pingBytes = "PING"u8.ToArray();
  393. await ws.SendAsync(new ArraySegment<byte>(pingBytes), WebSocketMessageType.Text, true, ct);
  394. }
  395. }
  396. }
  397. catch (OperationCanceledException)
  398. {
  399. // 정상 종료
  400. }
  401. }
  402. // ─── JSON 안전 파싱 헬퍼 ────────────────────────────────────────
  403. private static string GetSafeString(JsonElement root, string prop, string fallback = "")
  404. {
  405. if (!root.TryGetProperty(prop, out var el))
  406. {
  407. return fallback;
  408. }
  409. return el.ValueKind == JsonValueKind.String ? el.GetString() ?? fallback : fallback;
  410. }
  411. private static string? GetSafeNullableString(JsonElement root, string prop)
  412. {
  413. if (!root.TryGetProperty(prop, out var el))
  414. {
  415. return null;
  416. }
  417. return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
  418. }
  419. // ─── Live 데이터 Redis 저장용 Records ──────────────────────────
  420. internal sealed record LiveTrade(
  421. decimal TradePrice,
  422. decimal TradeVolume,
  423. string AskBid,
  424. decimal PrevClosingPrice,
  425. string Change,
  426. decimal ChangePrice,
  427. string TradeDate,
  428. string TradeTime,
  429. long TradeTimestamp,
  430. long SequentialId,
  431. long Timestamp,
  432. string StreamType,
  433. decimal BestAskPrice,
  434. decimal BestAskSize,
  435. decimal BestBidPrice,
  436. decimal BestBidSize
  437. );
  438. internal sealed record LiveOrderbook(
  439. decimal TotalAskSize,
  440. decimal TotalBidSize,
  441. List<LiveOrderbookUnit> Units,
  442. long Timestamp,
  443. decimal Level,
  444. string StreamType
  445. );
  446. internal sealed record LiveOrderbookUnit(
  447. decimal AskPrice,
  448. decimal BidPrice,
  449. decimal AskSize,
  450. decimal BidSize
  451. );
  452. internal sealed record LiveCandle(
  453. string CandleDateTimeUtc,
  454. string CandleDateTimeKst,
  455. decimal OpeningPrice,
  456. decimal HighPrice,
  457. decimal LowPrice,
  458. decimal TradePrice,
  459. decimal CandleAccTradeVolume,
  460. decimal CandleAccTradePrice,
  461. long Timestamp,
  462. string StreamType
  463. );
  464. }