using Microsoft.Data.Sqlite; namespace Simulator.Services { /// /// Saver SQLite DB에서 1분봉을 읽어 요청한 granularity로 집계합니다. /// /// DB 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Year}.db /// public class CandleReader : IDisposable { private readonly string _rootPath; private readonly Dictionary _connections = new(); public CandleReader(string rootPath) { _rootPath = Path.GetFullPath(rootPath); } // ════════════════════════════════════════════════════════════════ // 공개 메서드 // ════════════════════════════════════════════════════════════════ /// /// 가상 시각 기준으로 최근 개 봉을 반환합니다. /// 마지막 봉(candles[^1])은 현재 진행 중인 봉(미완성)입니다. /// public List GetCandles( string exchange, string productType, string symbol, string granularity, DateTimeOffset virtualNow, int limit) { int intervalMinutes = ParseGranularityMinutes(granularity); // 현재 진행 중인 봉의 시작 시각 var currentBarOpen = FloorToInterval(virtualNow, intervalMinutes); // limit개 봉의 시작 시각 var fetchFrom = currentBarOpen.AddMinutes(-(limit - 1) * intervalMinutes); var fetchTo = virtualNow; // 현재 진행 봉은 virtualNow까지만 집계 var raw = ReadRawCandles(exchange, productType, symbol, fetchFrom, fetchTo); return AggregateCandles(raw, fetchFrom, virtualNow, intervalMinutes, limit); } /// 가상 시각 직전 1분봉의 종가를 현재 가격으로 반환합니다. public double GetCurrentPrice( string exchange, string productType, string symbol, DateTimeOffset virtualNow) { var raw = ReadRawCandles(exchange, productType, symbol, virtualNow.AddMinutes(-2), virtualNow.AddMinutes(1)); return raw.Count > 0 ? raw[^1].c : 0; } // ════════════════════════════════════════════════════════════════ // 집계 // ════════════════════════════════════════════════════════════════ private static List AggregateCandles( List raw, DateTimeOffset from, DateTimeOffset virtualNow, int intervalMinutes, int limit) { var result = new List(); for (int i = 0; i < limit; i++) { var periodStart = from.AddMinutes((long)i * intervalMinutes); var periodEnd = periodStart.AddMinutes(intervalMinutes); // 현재 진행 봉은 virtualNow까지만 var effectiveEnd = periodEnd > virtualNow ? virtualNow : periodEnd; long startMs = periodStart.ToUnixTimeMilliseconds(); long endMs = effectiveEnd.ToUnixTimeMilliseconds(); var slice = raw .Where(c => c.tsMs >= startMs && c.tsMs < endMs) .ToList(); if (slice.Count == 0) continue; result.Add(new SimCandle { OpenTime = periodStart.ToUnixTimeMilliseconds(), Open = slice[0].o, High = slice.Max(c => c.h), Low = slice.Min(c => c.l), Close = slice[^1].c, Volume = slice.Sum(c => c.v), }); } return result; } // ════════════════════════════════════════════════════════════════ // SQLite 읽기 // ════════════════════════════════════════════════════════════════ private List ReadRawCandles( string exchange, string productType, string symbol, DateTimeOffset from, DateTimeOffset to) { var result = new List(); long fromMs = from.ToUnixTimeMilliseconds(); long toMs = to.ToUnixTimeMilliseconds(); for (int year = from.Year; year <= to.Year; year++) { var conn = GetConnection(exchange, productType, symbol, year); if (conn == null) continue; using var cmd = conn.CreateCommand(); cmd.CommandText = @" SELECT open_time, open, high, low, close, volume FROM candles WHERE open_time >= @from AND open_time < @to ORDER BY open_time ASC"; cmd.Parameters.AddWithValue("@from", fromMs); cmd.Parameters.AddWithValue("@to", toMs); using var reader = cmd.ExecuteReader(); while (reader.Read()) { result.Add(new RawCandle( reader.GetInt64(0), reader.GetDouble(1), reader.GetDouble(2), reader.GetDouble(3), reader.GetDouble(4), reader.GetDouble(5) )); } } return result; } private SqliteConnection? GetConnection( string exchange, string productType, string symbol, int year) { string path = Path.Combine(_rootPath, exchange, productType, symbol, $"{year}.db"); if (!File.Exists(path)) return null; if (_connections.TryGetValue(path, out var existing)) return existing; var conn = new SqliteConnection($"Data Source={path};Mode=ReadOnly"); conn.Open(); _connections[path] = conn; return conn; } // ════════════════════════════════════════════════════════════════ // 헬퍼 // ════════════════════════════════════════════════════════════════ private static DateTimeOffset FloorToInterval(DateTimeOffset time, int intervalMinutes) { long totalMinutes = (long)(time - DateTimeOffset.UnixEpoch).TotalMinutes; long floored = (totalMinutes / intervalMinutes) * intervalMinutes; return DateTimeOffset.UnixEpoch.AddMinutes(floored); } public static int ParseGranularityMinutes(string granularity) => granularity.ToUpperInvariant() switch { "1M" => 1, "3M" => 3, "5M" => 5, "15M" => 15, "30M" => 30, "1H" => 60, "2H" => 120, "4H" => 240, "6H" => 360, "12H" => 720, "1D" => 1440, "1W" => 10080, _ => 30 }; public void Dispose() { foreach (var c in _connections.Values) c.Dispose(); _connections.Clear(); } } // ════════════════════════════════════════════════════════════════ // 모델 // ════════════════════════════════════════════════════════════════ public record RawCandle(long tsMs, double o, double h, double l, double c, double v); public class SimCandle { public long OpenTime { get; set; } public double Open { get; set; } public double High { get; set; } public double Low { get; set; } public double Close { get; set; } public double Volume { get; set; } } }