diff --git a/Saver/Adapters/BitgetAdapter.cs b/Saver/Adapters/BitgetAdapter.cs index 21c5b34..db2f482 100644 --- a/Saver/Adapters/BitgetAdapter.cs +++ b/Saver/Adapters/BitgetAdapter.cs @@ -13,7 +13,7 @@ namespace Saver.Adapters { public string Name => "Bitget"; - private readonly HttpClient _http; + private readonly HttpClient _http; private readonly ExchangeConfig _cfg; private const string BaseUrl = "https://api.bitget.com"; @@ -50,10 +50,7 @@ namespace Saver.Adapters foreach (var item in data.EnumerateArray()) { - string? symbol = productType.ToUpperInvariant() == "SPOT" - ? item.TryGetProperty("symbol", out var s) ? s.GetString() : null - : item.TryGetProperty("symbol", out var f) ? f.GetString() : null; - + string? symbol = item.TryGetProperty("symbol", out var s) ? s.GetString() : null; if (!string.IsNullOrWhiteSpace(symbol)) symbols.Add(symbol); } @@ -67,12 +64,12 @@ namespace Saver.Adapters // ════════════════════════════════════════════════════════════════ public async Task> GetCandlesAsync( - string symbol, string productType, + string symbol, string productType, string interval, DateTimeOffset from, DateTimeOffset to, int limit, CancellationToken ct = default) { - string url = BuildCandleUrl(symbol, productType, from, to, limit); + string url = BuildCandleUrl(symbol, productType, interval, from, to, limit); Log.Debug("[Bitget] 캔들 요청 URL: {Url}", url); string body = await GetWithRetryAsync(url, ct); @@ -117,8 +114,8 @@ namespace Saver.Adapters if (candles.Count == 0) { - Log.Debug("[Bitget] 빈 응답: Symbol={Symbol} From={From:yyyy-MM-dd HH:mm} To={To:yyyy-MM-dd HH:mm}", - symbol, from, to); + Log.Debug("[Bitget] 빈 응답: Symbol={Symbol} Interval={Interval} From={From:yyyy-MM-dd HH:mm} To={To:yyyy-MM-dd HH:mm}", + symbol, interval, from, to); } // 오름차순 정렬 보장 @@ -133,27 +130,20 @@ namespace Saver.Adapters /// /// 3단계 탐색으로 최초 캔들 시각을 효율적으로 찾습니다. /// - /// Bitget 1m 캔들 API 제약: startTime~endTime 범위가 너무 크면 400 반환. - /// 안전 창 = 28일 이내. - /// - /// Phase 1 — 연도 탐침 (각 연도의 12월 28일 구간, 최대 ~9회) - /// 데이터가 존재하는 최초 연도를 빠르게 확정합니다. - /// Phase 2 — 월 슬라이딩 스캔 (28일 창, 최대 ~13회) - /// 해당 연도 안에서 데이터가 있는 28일 구간을 찾습니다. - /// Phase 3 — 이진 탐색 (1시간 정밀도, 최대 ~10회) - /// 28일 구간 내에서 절반씩 좁혀 정확한 시작 시각을 확정합니다. + /// Phase 1 — 연도 탐침 (각 연도의 12월 28일 구간) + /// Phase 2 — 28일 슬라이딩 스캔 + /// Phase 3 — 이진 탐색 (인터벌 정밀도) /// public async Task GetOldestCandleTimeAsync( - string symbol, string productType, CancellationToken ct = default) + string symbol, string productType, string interval, CancellationToken ct = default) { - Log.Information("[Bitget] {Symbol} 최초 캔들 시각 탐색 (연도 탐침 → 월 스캔 → 이진 탐색)", symbol); + Log.Information("[Bitget] {Symbol} [{Interval}] 최초 캔들 시각 탐색", symbol, interval); - const int SafeWindowDays = 28; // Bitget 1m API 안전 범위 + const int SafeWindowDays = 28; + var intervalDuration = CandleInterval.ToDuration(interval); var now = DateTimeOffset.UtcNow; // ── Phase 1: 연도 탐침 ─────────────────────────────────────────── - // 각 연도의 12월(28일 창)을 탐침 → 해당 연도에 데이터가 있는지 확인. - // 12월을 탐침하는 이유: 연도 어딘가에 데이터가 있으면 12월에도 존재하기 때문. int foundYear = -1; for (int year = 2018; year <= now.Year; year++) { @@ -167,29 +157,24 @@ namespace Saver.Adapters } else { - // 현재 연도: 최근 28일 probeEnd = now; probeStart = now.AddDays(-SafeWindowDays); } - var probe = await GetCandlesAsync(symbol, productType, probeStart, probeEnd, 1, ct); - Log.Debug("[Bitget] {Symbol} 연도 탐침: {Year}년 → {Count}개", symbol, year, probe.Count); + var probe = await GetCandlesAsync(symbol, productType, interval, probeStart, probeEnd, 1, ct); + Log.Debug("[Bitget] {Symbol} [{Interval}] 연도 탐침: {Year}년 → {Count}개", + symbol, interval, year, probe.Count); - if (probe.Count > 0) - { - foundYear = year; - break; - } + if (probe.Count > 0) { foundYear = year; break; } } if (foundYear < 0) { - Log.Warning("[Bitget] {Symbol} 데이터 없음 (2018 ~ 현재)", symbol); + Log.Warning("[Bitget] {Symbol} [{Interval}] 데이터 없음 (2018 ~ 현재)", symbol, interval); return null; } // ── Phase 2: 해당 연도 내 28일 슬라이딩 스캔 ─────────────────── - // 데이터가 실제로 시작하는 28일 구간을 찾습니다. var yearStart = new DateTimeOffset(foundYear, 1, 1, 0, 0, 0, TimeSpan.Zero); var yearEnd = foundYear < now.Year ? new DateTimeOffset(foundYear + 1, 1, 1, 0, 0, 0, TimeSpan.Zero) @@ -206,9 +191,9 @@ namespace Saver.Adapters windowHi = windowLo.AddDays(SafeWindowDays); if (windowHi > yearEnd) windowHi = yearEnd; - var probe = await GetCandlesAsync(symbol, productType, windowLo, windowHi, 1, ct); - Log.Debug("[Bitget] {Symbol} 월 탐침: {Lo:yyyy-MM-dd} ~ {Hi:yyyy-MM-dd} → {Count}개", - symbol, windowLo, windowHi, probe.Count); + var probe = await GetCandlesAsync(symbol, productType, interval, windowLo, windowHi, 1, ct); + Log.Debug("[Bitget] {Symbol} [{Interval}] 월 탐침: {Lo:yyyy-MM-dd} ~ {Hi:yyyy-MM-dd} → {Count}개", + symbol, interval, windowLo, windowHi, probe.Count); if (probe.Count > 0) { windowFound = true; break; } @@ -217,45 +202,37 @@ namespace Saver.Adapters if (!windowFound) { - Log.Warning("[Bitget] {Symbol} 월 탐침 실패", symbol); + Log.Warning("[Bitget] {Symbol} [{Interval}] 월 탐침 실패", symbol, interval); return null; } - // ── Phase 3: 28일 구간 내 이진 탐색 (1시간 정밀도) ───────────── - // 이 구간은 28일 이하이므로 재귀 분할 시 항상 안전 범위를 유지합니다. + // ── Phase 3: 이진 탐색 (인터벌 정밀도) ───────────────────────── var lo = windowLo; var hi = windowHi; DateTimeOffset? result = null; - while ((hi - lo).TotalHours > 1) + while ((hi - lo) > intervalDuration) { if (ct.IsCancellationRequested) return result; var mid = lo + TimeSpan.FromTicks((hi - lo).Ticks / 2); - var candles = await GetCandlesAsync(symbol, productType, lo, mid, 1, ct); + var candles = await GetCandlesAsync(symbol, productType, interval, lo, mid, 1, ct); - Log.Debug("[Bitget] {Symbol} 이진 탐색: {Lo:MM-dd HH:mm} ~ {Mid:MM-dd HH:mm} → {Count}개", - symbol, lo, mid, candles.Count); + Log.Debug("[Bitget] {Symbol} [{Interval}] 이진 탐색: {Lo:MM-dd HH:mm} ~ {Mid:MM-dd HH:mm} → {Count}개", + symbol, interval, lo, mid, candles.Count); - if (candles.Count > 0) - { - result = candles[0].OpenTime; - hi = mid; - } - else - { - lo = mid; - } + if (candles.Count > 0) { result = candles[0].OpenTime; hi = mid; } + else { lo = mid; } } - // 최종 1시간 구간 확인 if (result == null) { - var final = await GetCandlesAsync(symbol, productType, lo, hi, 1, ct); + var final = await GetCandlesAsync(symbol, productType, interval, lo, hi, 1, ct); result = final.Count > 0 ? final[0].OpenTime : null; } - Log.Information("[Bitget] {Symbol} 최초 캔들 확정: {Time:yyyy-MM-dd HH:mm} UTC", symbol, result); + Log.Information("[Bitget] {Symbol} [{Interval}] 최초 캔들 확정: {Time:yyyy-MM-dd HH:mm} UTC", + symbol, interval, result); return result; } @@ -263,19 +240,52 @@ namespace Saver.Adapters // 내부 헬퍼 // ════════════════════════════════════════════════════════════════ + /// 인터벌을 Bitget API granularity 문자열로 변환 + private static string ToGranularity(string interval, string productType) + { + bool isSpot = productType.ToUpperInvariant() == "SPOT"; + return (interval.ToLower(), isSpot) switch + { + ("1m", false) => "1m", + ("3m", false) => "3m", + ("5m", false) => "5m", + ("15m", false) => "15m", + ("30m", false) => "30m", + ("1h", false) => "1H", + ("4h", false) => "4H", + ("6h", false) => "6H", + ("12h", false) => "12H", + ("1d", false) => "1D", + + ("1m", true) => "1min", + ("3m", true) => "3min", + ("5m", true) => "5min", + ("15m", true) => "15min", + ("30m", true) => "30min", + ("1h", true) => "1h", + ("4h", true) => "4h", + ("6h", true) => "6h", + ("12h", true) => "12h", + ("1d", true) => "1day", + + _ => throw new ArgumentException($"지원하지 않는 인터벌: {interval}") + }; + } + private string BuildCandleUrl( - string symbol, string productType, + string symbol, string productType, string interval, DateTimeOffset from, DateTimeOffset to, int limit) { - long startMs = from.ToUnixTimeMilliseconds(); - long endMs = to.ToUnixTimeMilliseconds(); + long startMs = from.ToUnixTimeMilliseconds(); + long endMs = to.ToUnixTimeMilliseconds(); + string granularity = ToGranularity(interval, productType); return productType.ToUpperInvariant() == "SPOT" ? $"{BaseUrl}/api/v2/spot/market/candles" + - $"?symbol={symbol}&granularity=1min" + + $"?symbol={symbol}&granularity={granularity}" + $"&startTime={startMs}&endTime={endMs}&limit={limit}" : $"{BaseUrl}/api/v2/mix/market/candles" + - $"?symbol={symbol}&productType={productType}&granularity=1m" + + $"?symbol={symbol}&productType={productType}&granularity={granularity}" + $"&startTime={startMs}&endTime={endMs}&limit={limit}"; } @@ -294,7 +304,6 @@ namespace Saver.Adapters await Task.Delay(_cfg.RetryDelayMs, ct); } } - // 마지막 시도 (예외 그대로 전파) return await _http.GetStringAsync(url, ct); } diff --git a/Saver/Adapters/IExchangeAdapter.cs b/Saver/Adapters/IExchangeAdapter.cs index 2368e3d..efb656f 100644 --- a/Saver/Adapters/IExchangeAdapter.cs +++ b/Saver/Adapters/IExchangeAdapter.cs @@ -15,25 +15,26 @@ namespace Saver.Adapters /// /// 캔들 데이터 조회. - /// from ~ to 구간의 1분봉을 최대 limit개 반환합니다. - /// 거래소마다 가장 오래된 데이터부터 반환하는지 최신부터 반환하는지 다를 수 있으므로 + /// from ~ to 구간의 지정 인터벌 캔들을 최대 limit개 반환합니다. /// 구현체에서 오름차순(OpenTime ASC)으로 정렬하여 반환해야 합니다. /// Task> GetCandlesAsync( string symbol, string productType, + string interval, DateTimeOffset from, DateTimeOffset to, int limit, CancellationToken ct = default); /// - /// 해당 심볼의 가장 오래된 캔들 시각 조회. + /// 해당 심볼·인터벌의 가장 오래된 캔들 시각 조회. /// 거래소 API가 지원하지 않으면 null 반환 → 기본 시작일 사용. /// Task GetOldestCandleTimeAsync( string symbol, string productType, + string interval, CancellationToken ct = default); } } diff --git a/Saver/Config/SaverConfig.cs b/Saver/Config/SaverConfig.cs index 5accabc..5b2894e 100644 --- a/Saver/Config/SaverConfig.cs +++ b/Saver/Config/SaverConfig.cs @@ -64,5 +64,16 @@ namespace Saver.Config /// 상품 타입 (USDT-FUTURES | SPOT | COIN-FUTURES) public string Type { get; set; } = ""; public bool Enabled { get; set; } = true; + + /// + /// 수집할 캔들 인터벌 목록. + /// 지원값: "1m", "3m", "5m", "15m", "30m", "1h", "4h", "6h", "12h", "1d" + /// 기본값: 1분봉만 수집. + /// + /// + /// 비어있으면 SaverService에서 "1m" 기본값으로 처리합니다. + /// configuration.Bind()가 기존 리스트에 append하므로 기본값은 빈 리스트로 유지합니다. + /// + public List Intervals { get; set; } = new(); } } diff --git a/Saver/Core/CandleRepository.cs b/Saver/Core/CandleRepository.cs index d7e4c3a..a9ad2ed 100644 --- a/Saver/Core/CandleRepository.cs +++ b/Saver/Core/CandleRepository.cs @@ -6,7 +6,7 @@ namespace Saver.Core { /// /// 캔들 데이터 SQLite 저장소. - /// 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Year}.db + /// 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Interval}/{Year}.db /// public class CandleRepository { @@ -27,16 +27,15 @@ namespace Saver.Core /// 캔들 목록을 해당 연도 파일에 저장 (Upsert) public async Task SaveCandlesAsync( - string exchange, string productType, string symbol, + string exchange, string productType, string symbol, string interval, IEnumerable candles, CancellationToken ct = default) { - // 연도별로 그룹핑하여 각 파일에 저장 var byYear = candles.GroupBy(c => c.OpenTime.Year); foreach (var group in byYear) { - var conn = await GetConnectionAsync(exchange, productType, symbol, group.Key); + var conn = await GetConnectionAsync(exchange, productType, symbol, interval, group.Key); await _lock.WaitAsync(ct); try @@ -80,7 +79,7 @@ namespace Saver.Core /// 연도 파일 단위로 처리하며 삭제된 총 행 수를 반환합니다. /// public async Task DeleteCandlesFromAsync( - string exchange, string productType, string symbol, + string exchange, string productType, string symbol, string interval, DateTimeOffset from, CancellationToken ct = default) { @@ -89,10 +88,10 @@ namespace Saver.Core for (int year = from.Year; year <= DateTime.UtcNow.Year; year++) { - string dbPath = GetDbPath(exchange, productType, symbol, year); + string dbPath = GetDbPath(exchange, productType, symbol, interval, year); if (!File.Exists(dbPath)) continue; - var conn = await GetConnectionAsync(exchange, productType, symbol, year); + var conn = await GetConnectionAsync(exchange, productType, symbol, interval, year); await _lock.WaitAsync(ct); try @@ -117,12 +116,12 @@ namespace Saver.Core /// 저장된 가장 마지막 캔들 시각 조회 (없으면 null) public async Task GetLastCandleTimeAsync( - string exchange, string productType, string symbol) + string exchange, string productType, string symbol, string interval) { - int latestYear = GetLatestYear(exchange, productType, symbol); + int latestYear = GetLatestYear(exchange, productType, symbol, interval); if (latestYear == 0) return null; - var conn = await GetConnectionAsync(exchange, productType, symbol, latestYear); + var conn = await GetConnectionAsync(exchange, productType, symbol, interval, latestYear); await _lock.WaitAsync(); try @@ -141,12 +140,12 @@ namespace Saver.Core /// 저장된 가장 첫 캔들 시각 조회 (없으면 null) public async Task GetFirstCandleTimeAsync( - string exchange, string productType, string symbol) + string exchange, string productType, string symbol, string interval) { - int earliestYear = GetEarliestYear(exchange, productType, symbol); + int earliestYear = GetEarliestYear(exchange, productType, symbol, interval); if (earliestYear == 0) return null; - var conn = await GetConnectionAsync(exchange, productType, symbol, earliestYear); + var conn = await GetConnectionAsync(exchange, productType, symbol, interval, earliestYear); await _lock.WaitAsync(); try @@ -164,34 +163,33 @@ namespace Saver.Core } /// - /// 지정 구간 내 누락된 1분봉 구간 목록 반환. + /// 지정 구간 내 누락된 봉 구간 목록 반환. /// 연도 파일이 없는 경우도 갭으로 처리합니다. /// public async Task> GetGapsAsync( - string exchange, string productType, string symbol, + string exchange, string productType, string symbol, string interval, DateTimeOffset from, DateTimeOffset to) { var gaps = new List(); + var intervalDuration = CandleInterval.ToDuration(interval); - // 1분 단위로 순회하기엔 범위가 너무 크므로 연도별로 처리 for (int year = from.Year; year <= to.Year; year++) { var yearStart = new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero); - var yearEnd = yearStart.AddYears(1).AddMinutes(-1); + var yearEnd = yearStart.AddYears(1).Add(-intervalDuration); var rangeFrom = year == from.Year ? from : yearStart; var rangeTo = year == to.Year ? to : yearEnd; - string dbPath = GetDbPath(exchange, productType, symbol, year); + string dbPath = GetDbPath(exchange, productType, symbol, interval, year); if (!File.Exists(dbPath)) { - // 해당 연도 파일 자체가 없으면 전체 구간이 갭 gaps.Add(new TimeGap(rangeFrom, rangeTo)); continue; } - var conn = await GetConnectionAsync(exchange, productType, symbol, year); - var yearGaps = await FindGapsInRangeAsync(conn, rangeFrom, rangeTo); + var conn = await GetConnectionAsync(exchange, productType, symbol, interval, year); + var yearGaps = await FindGapsInRangeAsync(conn, rangeFrom, rangeTo, intervalDuration); gaps.AddRange(yearGaps); } @@ -203,15 +201,17 @@ namespace Saver.Core // ════════════════════════════════════════════════════════════════ private async Task> FindGapsInRangeAsync( - SqliteConnection conn, DateTimeOffset from, DateTimeOffset to) + SqliteConnection conn, DateTimeOffset from, DateTimeOffset to, + TimeSpan intervalDuration) { var gaps = new List(); await _lock.WaitAsync(); try { - long fromMs = from.ToUnixTimeMilliseconds(); - long toMs = to.ToUnixTimeMilliseconds(); + long fromMs = from.ToUnixTimeMilliseconds(); + long toMs = to.ToUnixTimeMilliseconds(); + long intervalMs = (long)intervalDuration.TotalMilliseconds; using var cmd = conn.CreateCommand(); cmd.CommandText = @" @@ -234,26 +234,25 @@ namespace Saver.Core // 시작 갭 var firstTime = DateTimeOffset.FromUnixTimeMilliseconds(times[0]); - if (firstTime > from.AddMinutes(1)) - gaps.Add(new TimeGap(from, firstTime.AddMinutes(-1))); + if (firstTime > from.Add(intervalDuration)) + gaps.Add(new TimeGap(from, firstTime.Add(-intervalDuration))); - // 중간 갭 (1분 초과 = 2분 이상 차이 → 사이에 누락된 봉 존재) - const long oneMin = 60_000L; + // 중간 갭 (인터벌 초과 = 사이에 누락된 봉 존재) for (int i = 1; i < times.Count; i++) { long diff = times[i] - times[i - 1]; - if (diff > oneMin) + if (diff > intervalMs) { - var gapFrom = DateTimeOffset.FromUnixTimeMilliseconds(times[i - 1] + oneMin); - var gapTo = DateTimeOffset.FromUnixTimeMilliseconds(times[i] - oneMin); + var gapFrom = DateTimeOffset.FromUnixTimeMilliseconds(times[i - 1] + intervalMs); + var gapTo = DateTimeOffset.FromUnixTimeMilliseconds(times[i] - intervalMs); gaps.Add(new TimeGap(gapFrom, gapTo)); } } // 끝 갭 var lastTime = DateTimeOffset.FromUnixTimeMilliseconds(times[^1]); - if (lastTime < to.AddMinutes(-1)) - gaps.Add(new TimeGap(lastTime.AddMinutes(1), to)); + if (lastTime < to.Add(-intervalDuration)) + gaps.Add(new TimeGap(lastTime.Add(intervalDuration), to)); } finally { @@ -264,20 +263,18 @@ namespace Saver.Core } private async Task GetConnectionAsync( - string exchange, string productType, string symbol, int year) + string exchange, string productType, string symbol, string interval, int year) { - string path = GetDbPath(exchange, productType, symbol, year); + string path = GetDbPath(exchange, productType, symbol, interval, year); if (_connections.TryGetValue(path, out var existing)) return existing; - // 디렉토리 생성 Directory.CreateDirectory(Path.GetDirectoryName(path)!); var conn = new SqliteConnection($"Data Source={path}"); conn.Open(); - // 테이블 초기화 using var cmd = conn.CreateCommand(); cmd.CommandText = @" CREATE TABLE IF NOT EXISTS candles ( @@ -297,36 +294,64 @@ namespace Saver.Core } private string GetDbPath( - string exchange, string productType, string symbol, int year) + string exchange, string productType, string symbol, string interval, int year) { - // 경로: data/Bitget/USDT-FUTURES/BTCUSDT/2025.db - return Path.Combine(_rootPath, exchange, productType, symbol, $"{year}.db"); + // 1m: data/Bitget/USDT-FUTURES/BTCUSDT/2025.db (기존 호환) + // 기타: data/Bitget/USDT-FUTURES/BTCUSDT/2025_30m.db + string norm = CandleInterval.Normalize(interval); + string fileName = norm == "1m" ? $"{year}.db" : $"{year}_{norm}.db"; + return Path.Combine(_rootPath, exchange, productType, symbol, fileName); } - private int GetLatestYear(string exchange, string productType, string symbol) + private int GetLatestYear(string exchange, string productType, string symbol, string interval) { string dir = Path.Combine(_rootPath, exchange, productType, symbol); if (!Directory.Exists(dir)) return 0; - return Directory.GetFiles(dir, "*.db") - .Select(f => int.TryParse(Path.GetFileNameWithoutExtension(f), out int y) ? y : 0) + return Directory.GetFiles(dir, DbPattern(interval)) + .Select(f => ParseYearFromFile(f, interval)) .Where(y => y > 0) .DefaultIfEmpty(0) .Max(); } - private int GetEarliestYear(string exchange, string productType, string symbol) + private int GetEarliestYear(string exchange, string productType, string symbol, string interval) { string dir = Path.Combine(_rootPath, exchange, productType, symbol); if (!Directory.Exists(dir)) return 0; - return Directory.GetFiles(dir, "*.db") - .Select(f => int.TryParse(Path.GetFileNameWithoutExtension(f), out int y) ? y : 0) + return Directory.GetFiles(dir, DbPattern(interval)) + .Select(f => ParseYearFromFile(f, interval)) .Where(y => y > 0) .DefaultIfEmpty(0) .Min(); } + /// 인터벌에 맞는 파일 검색 패턴 + private static string DbPattern(string interval) + { + string norm = CandleInterval.Normalize(interval); + return norm == "1m" ? "????.db" : $"????_{norm}.db"; + } + + /// 파일명에서 연도 추출 + private static int ParseYearFromFile(string filePath, string interval) + { + string name = Path.GetFileNameWithoutExtension(filePath); // "2025" or "2025_30m" + string norm = CandleInterval.Normalize(interval); + + // 1m: 파일명 = "2025" + // 기타: 파일명 = "2025_30m" → "_30m" 접미사 제거 + if (norm != "1m") + { + string suffix = $"_{norm}"; + if (!name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return 0; + name = name[..^suffix.Length]; + } + + return int.TryParse(name, out int y) ? y : 0; + } + public void Dispose() { foreach (var conn in _connections.Values) diff --git a/Saver/Core/HistoricalFiller.cs b/Saver/Core/HistoricalFiller.cs index c4a96b4..4a632bf 100644 --- a/Saver/Core/HistoricalFiller.cs +++ b/Saver/Core/HistoricalFiller.cs @@ -11,21 +11,26 @@ namespace Saver.Core /// public class HistoricalFiller { - private readonly IExchangeAdapter _adapter; - private readonly CandleRepository _repo; - private readonly RateLimiter _rateLimiter; - private readonly int _batchSize; + private readonly IExchangeAdapter _adapter; + private readonly CandleRepository _repo; + private readonly RateLimiter _rateLimiter; + private readonly int _batchSize; + private readonly string _interval; + private readonly TimeSpan _intervalDuration; public HistoricalFiller( IExchangeAdapter adapter, CandleRepository repo, RateLimiter rateLimiter, - int batchSize = 200) + int batchSize = 200, + string interval = "1m") { - _adapter = adapter; - _repo = repo; - _rateLimiter = rateLimiter; - _batchSize = batchSize; + _adapter = adapter; + _repo = repo; + _rateLimiter = rateLimiter; + _batchSize = batchSize; + _interval = CandleInterval.Normalize(interval); + _intervalDuration = CandleInterval.ToDuration(_interval); } /// @@ -40,76 +45,79 @@ namespace Saver.Core DateTimeOffset? forceStartFrom = null, CancellationToken ct = default) { - var now = DateTimeOffset.UtcNow.AddMinutes(-1); // 현재 진행 중인 봉 제외 + // 현재 진행 중인 봉 제외 + var now = DateTimeOffset.UtcNow.Add(-_intervalDuration); // ── 수집 시작점 결정 ───────────────────────────────────── DateTimeOffset startFrom; if (forceStartFrom.HasValue) { - // GapCheck 등에서 특정 구간 보완 시 사용 startFrom = forceStartFrom.Value; - Log.Information("[Filler] {Symbol} 갭 보완: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", - symbol, startFrom, now); + Log.Information("[Filler/{Interval}] {Symbol} 갭 보완: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", + _interval, symbol, startFrom, now); } else { - var lastSaved = await _repo.GetLastCandleTimeAsync(_adapter.Name, productType, symbol); + var lastSaved = await _repo.GetLastCandleTimeAsync( + _adapter.Name, productType, symbol, _interval); + if (lastSaved.HasValue) { - // 이어서 수집 - startFrom = lastSaved.Value.AddMinutes(1); - Log.Information("[Filler] {Symbol} 이어서 수집: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", - symbol, startFrom, now); + startFrom = lastSaved.Value.Add(_intervalDuration); + Log.Information("[Filler/{Interval}] {Symbol} 이어서 수집: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", + _interval, symbol, startFrom, now); } else { - // 최초 수집 — 연도 탐침 + 이진 탐색으로 최초 데이터 시각 확인 - var oldest = await _adapter.GetOldestCandleTimeAsync(symbol, productType, ct); + var oldest = await _adapter.GetOldestCandleTimeAsync( + symbol, productType, _interval, ct); + if (!oldest.HasValue) { - Log.Warning("[Filler] {Symbol} 거래소에 데이터 없음 — 수집 건너뜀", symbol); + Log.Warning("[Filler/{Interval}] {Symbol} 거래소에 데이터 없음 — 수집 건너뜀", + _interval, symbol); return; } startFrom = oldest.Value; - Log.Information("[Filler] {Symbol} 최초 수집: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", - symbol, startFrom, now); + Log.Information("[Filler/{Interval}] {Symbol} 최초 수집: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", + _interval, symbol, startFrom, now); } } if (startFrom >= now) { - Log.Debug("[Filler] {Symbol} 수집할 데이터 없음 (최신 상태)", symbol); + Log.Debug("[Filler/{Interval}] {Symbol} 수집할 데이터 없음 (최신 상태)", _interval, symbol); return; } // ── 배치 단위로 수집 ───────────────────────────────────── var cursor = startFrom; long total = 0; - const int EmptyRetryMax = 3; // 빈 응답 최대 재시도 횟수 - const int EmptyRetryDelay = 2000; // 재시도 대기 (ms) + const int EmptyRetryMax = 3; + const int EmptyRetryDelay = 2000; while (cursor < now && !ct.IsCancellationRequested) { - var batchEnd = cursor.AddMinutes(_batchSize - 1); + // 정확히 _batchSize개 슬롯만 포함되도록 끝을 설정 + var batchEnd = cursor.Add(_intervalDuration * (_batchSize - 1)); if (batchEnd > now) batchEnd = now; await _rateLimiter.WaitAsync(ct); - Log.Information("[Filler] {Symbol} 요청: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", - symbol, cursor.ToLocalTime(), batchEnd.ToLocalTime()); + Log.Information("[Filler/{Interval}] {Symbol} 요청: {From:yyyy-MM-dd HH:mm} ~ {To:yyyy-MM-dd HH:mm}", + _interval, symbol, cursor.ToLocalTime(), batchEnd.ToLocalTime()); List candles; try { candles = await _adapter.GetCandlesAsync( - symbol, productType, cursor, batchEnd, _batchSize, ct); + symbol, productType, _interval, cursor, batchEnd, _batchSize, ct); } catch (Exception ex) { - Log.Error(ex, "[Filler] {Symbol} 캔들 조회 실패: {From:yyyy-MM-dd HH:mm}", - symbol, cursor); - // 잠시 대기 후 같은 구간 재시도 + Log.Error(ex, "[Filler/{Interval}] {Symbol} 캔들 조회 실패: {From:yyyy-MM-dd HH:mm}", + _interval, symbol, cursor); await Task.Delay(3000, ct); continue; } @@ -117,19 +125,19 @@ namespace Saver.Core if (candles.Count > 0) { await _repo.SaveCandlesAsync( - _adapter.Name, productType, symbol, candles, ct); + _adapter.Name, productType, symbol, _interval, candles, ct); total += candles.Count; - Log.Debug("[Filler] {Symbol} 저장 {Count}개 ({From:MM-dd HH:mm}~{To:MM-dd HH:mm}) 누적={Total}", - symbol, candles.Count, + Log.Debug("[Filler/{Interval}] {Symbol} 저장 {Count}개 ({From:MM-dd HH:mm}~{To:MM-dd HH:mm}) 누적={Total}", + _interval, symbol, candles.Count, candles[0].OpenTime, candles[^1].OpenTime, total); - cursor = candles[^1].OpenTime.AddMinutes(1); + cursor = candles[^1].OpenTime.Add(_intervalDuration); } else { - // 빈 응답 — 일시적 API 오류일 수 있으므로 재시도 후 스킵 + // 빈 응답 — 재시도 후 스킵 bool skipped = false; for (int retry = 1; retry <= EmptyRetryMax; retry++) { @@ -137,49 +145,46 @@ namespace Saver.Core await _rateLimiter.WaitAsync(ct); var retryCandles = await _adapter.GetCandlesAsync( - symbol, productType, cursor, batchEnd, _batchSize, ct); + symbol, productType, _interval, cursor, batchEnd, _batchSize, ct); if (retryCandles.Count > 0) { await _repo.SaveCandlesAsync( - _adapter.Name, productType, symbol, retryCandles, ct); + _adapter.Name, productType, symbol, _interval, retryCandles, ct); total += retryCandles.Count; - Log.Debug("[Filler] {Symbol} 재시도 성공({Retry}회) 저장 {Count}개 누적={Total}", - symbol, retry, retryCandles.Count, total); + Log.Debug("[Filler/{Interval}] {Symbol} 재시도 성공({Retry}회) 저장 {Count}개 누적={Total}", + _interval, symbol, retry, retryCandles.Count, total); - cursor = retryCandles[^1].OpenTime.AddMinutes(1); + cursor = retryCandles[^1].OpenTime.Add(_intervalDuration); skipped = false; break; } - Log.Debug("[Filler] {Symbol} 빈 응답 재시도 {Retry}/{Max}: {From:MM-dd HH:mm}", - symbol, retry, EmptyRetryMax, cursor); + Log.Debug("[Filler/{Interval}] {Symbol} 빈 응답 재시도 {Retry}/{Max}: {From:MM-dd HH:mm}", + _interval, symbol, retry, EmptyRetryMax, cursor); skipped = true; } if (skipped) { - // 재시도 후에도 빈 응답 → 실제 데이터 없는 구간으로 판단하고 스킵 - Log.Debug("[Filler] {Symbol} 데이터 없는 구간 스킵: {From:MM-dd HH:mm} ~ {To:MM-dd HH:mm}", - symbol, cursor, batchEnd); - cursor = batchEnd.AddMinutes(1); + Log.Debug("[Filler/{Interval}] {Symbol} 데이터 없는 구간 스킵: {From:MM-dd HH:mm} ~ {To:MM-dd HH:mm}", + _interval, symbol, cursor, batchEnd); + cursor = batchEnd.Add(_intervalDuration); } } } - Log.Information("[Filler] {Symbol} 수집 완료: 총 {Total}개", symbol, total); + Log.Information("[Filler/{Interval}] {Symbol} 수집 완료: 총 {Total}개", _interval, symbol, total); } - /// - /// 여러 심볼을 순차적으로 채웁니다. - /// + /// 여러 심볼을 순차적으로 채웁니다. public async Task FillAllAsync( string productType, IEnumerable symbols, CancellationToken ct = default) { var list = symbols.ToList(); - Log.Information("[Filler] 과거 데이터 수집 시작: {ProductType} {Count}개 심볼", - productType, list.Count); + Log.Information("[Filler/{Interval}] 과거 데이터 수집 시작: {ProductType} {Count}개 심볼", + _interval, productType, list.Count); int done = 0; foreach (var symbol in list) @@ -191,13 +196,14 @@ namespace Saver.Core } catch (Exception ex) { - Log.Error(ex, "[Filler] {Symbol} 수집 중 오류 — 다음 심볼로 계속", symbol); + Log.Error(ex, "[Filler/{Interval}] {Symbol} 수집 중 오류 — 다음 심볼로 계속", + _interval, symbol); } done++; - Log.Information("[Filler] 진행: {Done}/{Total}", done, list.Count); + Log.Information("[Filler/{Interval}] 진행: {Done}/{Total}", _interval, done, list.Count); } - Log.Information("[Filler] 전체 과거 데이터 수집 완료"); + Log.Information("[Filler/{Interval}] 전체 과거 데이터 수집 완료", _interval); } } } diff --git a/Saver/Core/LivePoller.cs b/Saver/Core/LivePoller.cs index 1339593..5f100bd 100644 --- a/Saver/Core/LivePoller.cs +++ b/Saver/Core/LivePoller.cs @@ -5,63 +5,66 @@ using Serilog; namespace Saver.Core { /// - /// 실시간 1분봉 수집기. - /// 매분 새로 완성된 봉을 감지하여 저장합니다. - /// 1시간마다 갭 탐지를 실행하여 누락 봉을 자동 보완합니다. + /// 실시간 캔들 수집기. + /// 매 인터벌마다 새로 완성된 봉을 감지하여 저장합니다. + /// 주기적으로 갭 탐지를 실행하여 누락 봉을 자동 보완합니다. /// public class LivePoller { - private readonly IExchangeAdapter _adapter; - private readonly CandleRepository _repo; - private readonly RateLimiter _rateLimiter; - private readonly HistoricalFiller _filler; + private readonly IExchangeAdapter _adapter; + private readonly CandleRepository _repo; + private readonly RateLimiter _rateLimiter; + private readonly HistoricalFiller _filler; + private readonly string _interval; + private readonly TimeSpan _intervalDuration; - // 갭 탐지 주기 (기본 1시간) - private readonly TimeSpan _gapCheckInterval = TimeSpan.FromHours(1); + // 갭 탐지 주기 (인터벌에 비례, 최소 1h, 최대 24h) + private readonly TimeSpan _gapCheckInterval; private DateTime _lastGapCheck = DateTime.MinValue; public LivePoller( IExchangeAdapter adapter, CandleRepository repo, RateLimiter rateLimiter, - HistoricalFiller filler) + HistoricalFiller filler, + string interval = "1m") { - _adapter = adapter; - _repo = repo; - _rateLimiter = rateLimiter; - _filler = filler; + _adapter = adapter; + _repo = repo; + _rateLimiter = rateLimiter; + _filler = filler; + _interval = CandleInterval.Normalize(interval); + _intervalDuration = CandleInterval.ToDuration(_interval); + _gapCheckInterval = CandleInterval.GapCheckInterval(_interval); } /// /// 실시간 수집 루프 (블로킹). - /// 매분 전체 심볼에서 새 봉을 수집합니다. + /// 매 인터벌마다 전체 심볼에서 새 봉을 수집합니다. /// public async Task RunAsync( string productType, IReadOnlyList symbols, CancellationToken ct = default) { - Log.Information("[Live] 실시간 수집 시작: {ProductType} {Count}개 심볼", - productType, symbols.Count); + Log.Information("[Live/{Interval}] 실시간 수집 시작: {ProductType} {Count}개 심볼", + _interval, productType, symbols.Count); while (!ct.IsCancellationRequested) { - // 다음 분의 5초 후에 수집 (봉 확정 여유) - var nextMinute = GetNextMinuteMark().AddSeconds(5); - var waitMs = (int)(nextMinute - DateTimeOffset.UtcNow).TotalMilliseconds; + // 다음 인터벌 시작 + 5초 후에 수집 (봉 확정 여유) + var nextMark = GetNextIntervalMark().AddSeconds(5); + var waitMs = (int)(nextMark - DateTimeOffset.UtcNow).TotalMilliseconds; if (waitMs > 0) await Task.Delay(waitMs, ct); if (ct.IsCancellationRequested) break; - // 방금 완성된 봉의 시각 (현재 분 - 1) - var targetMinute = DateTimeOffset.UtcNow.AddMinutes(-1); - targetMinute = new DateTimeOffset( - targetMinute.Year, targetMinute.Month, targetMinute.Day, - targetMinute.Hour, targetMinute.Minute, 0, TimeSpan.Zero); + // 방금 완성된 봉 시각 (현재 인터벌 시작 - 1 인터벌) + var targetBar = GetLastCompletedBar(); - Log.Debug("[Live] 봉 수집: {Time:HH:mm} 심볼={Count}개", - targetMinute, symbols.Count); + Log.Debug("[Live/{Interval}] 봉 수집: {Time:HH:mm} 심볼={Count}개", + _interval, targetBar, symbols.Count); int saved = 0; foreach (var symbol in symbols) @@ -71,34 +74,33 @@ namespace Saver.Core { await _rateLimiter.WaitAsync(ct); - // endTime을 targetMinute 자체로 설정하여 진행 중인 봉 제외 var candles = await _adapter.GetCandlesAsync( - symbol, productType, - targetMinute, targetMinute, + symbol, productType, _interval, + targetBar, targetBar, limit: 1, ct: ct); - // 완성된 봉(targetMinute)만 저장 — 혹시 모를 다른 시각 봉 필터링 + // 완성된 봉(targetBar)만 저장 var completed = candles - .Where(c => c.OpenTime == targetMinute) + .Where(c => c.OpenTime == targetBar) .ToList(); if (completed.Count > 0) { await _repo.SaveCandlesAsync( - _adapter.Name, productType, symbol, completed, ct); + _adapter.Name, productType, symbol, _interval, completed, ct); saved++; } } catch (Exception ex) { - Log.Warning(ex, "[Live] {Symbol} 봉 수집 실패", symbol); + Log.Warning(ex, "[Live/{Interval}] {Symbol} 봉 수집 실패", _interval, symbol); } } - Log.Information("[Live] {Time:HH:mm} 수집 완료: {Saved}/{Total}개 심볼 저장", - targetMinute, saved, symbols.Count); + Log.Information("[Live/{Interval}] {Time:HH:mm} 수집 완료: {Saved}/{Total}개 심볼 저장", + _interval, targetBar, saved, symbols.Count); - // ── 갭 탐지 (1시간 주기) ────────────────────────────── + // ── 갭 탐지 (주기적) ────────────────────────────── if (DateTime.UtcNow - _lastGapCheck >= _gapCheckInterval) { _lastGapCheck = DateTime.UtcNow; @@ -106,14 +108,14 @@ namespace Saver.Core } } - Log.Information("[Live] 실시간 수집 종료"); + Log.Information("[Live/{Interval}] 실시간 수집 종료", _interval); } /// 갭 탐지 및 자동 보완 (백그라운드) private async Task RunGapCheckAsync( string productType, IReadOnlyList symbols, CancellationToken ct) { - Log.Information("[GapCheck] 갭 탐지 시작: {Count}개 심볼", symbols.Count); + Log.Information("[GapCheck/{Interval}] 갭 탐지 시작: {Count}개 심볼", _interval, symbols.Count); int totalGaps = 0; foreach (var symbol in symbols) @@ -121,45 +123,58 @@ namespace Saver.Core if (ct.IsCancellationRequested) break; try { - var last = await _repo.GetLastCandleTimeAsync(_adapter.Name, productType, symbol); + var last = await _repo.GetLastCandleTimeAsync( + _adapter.Name, productType, symbol, _interval); if (!last.HasValue) continue; - // 최근 2시간 구간 검사 - var checkFrom = last.Value.AddHours(-2); - var checkTo = DateTimeOffset.UtcNow.AddMinutes(-1); + // 최근 인터벌 기준으로 적절한 구간 검사 (최소 2시간, 최대 2일) + var checkSpan = TimeSpan.FromTicks( + Math.Clamp(_intervalDuration.Ticks * 120, + TimeSpan.FromHours(2).Ticks, + TimeSpan.FromDays(2).Ticks)); + + var checkFrom = last.Value.Add(-checkSpan); + var checkTo = DateTimeOffset.UtcNow.Add(-_intervalDuration); var gaps = await _repo.GetGapsAsync( - _adapter.Name, productType, symbol, checkFrom, checkTo); + _adapter.Name, productType, symbol, _interval, checkFrom, checkTo); if (gaps.Count > 0) { totalGaps += gaps.Count; - // 갭 중 가장 이른 시작 시각부터 채움 - // (FillAsync에 forceStartFrom을 넘겨 갭 구간을 직접 보완) var firstGapStart = gaps.Min(g => g.From); - Log.Warning("[GapCheck] {Symbol} 갭 {Count}개 — {From:MM-dd HH:mm}부터 보완", - symbol, gaps.Count, firstGapStart); + Log.Warning("[GapCheck/{Interval}] {Symbol} 갭 {Count}개 — {From:MM-dd HH:mm}부터 보완", + _interval, symbol, gaps.Count, firstGapStart); await _filler.FillAsync(productType, symbol, firstGapStart, ct); } } catch (Exception ex) { - Log.Warning(ex, "[GapCheck] {Symbol} 갭 탐지 실패", symbol); + Log.Warning(ex, "[GapCheck/{Interval}] {Symbol} 갭 탐지 실패", _interval, symbol); } } - Log.Information("[GapCheck] 완료: 총 {Total}개 갭 보완", totalGaps); + Log.Information("[GapCheck/{Interval}] 완료: 총 {Total}개 갭 보완", _interval, totalGaps); } - private static DateTimeOffset GetNextMinuteMark() + /// 다음 인터벌 경계 시각 계산 + private DateTimeOffset GetNextIntervalMark() { - var now = DateTimeOffset.UtcNow; - var next = new DateTimeOffset( - now.Year, now.Month, now.Day, - now.Hour, now.Minute, 0, TimeSpan.Zero).AddMinutes(1); - return next; + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long intervalMs = (long)_intervalDuration.TotalMilliseconds; + long nextMs = (nowMs / intervalMs + 1) * intervalMs; + return DateTimeOffset.FromUnixTimeMilliseconds(nextMs); + } + + /// 방금 완성된 봉의 시작 시각 계산 + private DateTimeOffset GetLastCompletedBar() + { + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long intervalMs = (long)_intervalDuration.TotalMilliseconds; + long barMs = (nowMs / intervalMs) * intervalMs - intervalMs; + return DateTimeOffset.FromUnixTimeMilliseconds(barMs); } } } diff --git a/Saver/Core/RecoveryService.cs b/Saver/Core/RecoveryService.cs index cd694b9..e5cbb64 100644 --- a/Saver/Core/RecoveryService.cs +++ b/Saver/Core/RecoveryService.cs @@ -10,22 +10,20 @@ namespace Saver.Core /// /// 모드 1 — 특정 시각 이후 삭제 후 재수집 /// appsettings.json: "Recovery": { "Mode": 1, "Mode1FromTime": "2026-04-01T00:00:00Z" } - /// 지정 시각 이후 저장된 캔들을 삭제하고 거래소에서 다시 수집합니다. /// - /// 모드 2 — 전체 기간 빈 1분봉 채우기 + /// 모드 2 — 전체 기간 빈 봉 채우기 /// appsettings.json: "Recovery": { "Mode": 2 } - /// 각 심볼의 첫 캔들 ~ 현재까지 전체 구간을 스캔하여 누락된 봉을 채웁니다. /// /// 복구 완료 후 호출부에서 정상 수집(SaverService)으로 전환합니다. /// public class RecoveryService { - private readonly ExchangeConfig _cfg; - private readonly IExchangeAdapter _adapter; - private readonly CandleRepository _repo; - private readonly RateLimiter _rateLimiter; - private readonly HistoricalFiller _filler; - private readonly RecoveryConfig _recovery; + private readonly ExchangeConfig _cfg; + private readonly IExchangeAdapter _adapter; + private readonly CandleRepository _repo; + private readonly RateLimiter _rateLimiter; + private readonly HistoricalFiller _filler; + private readonly RecoveryConfig _recovery; public RecoveryService( ExchangeConfig cfg, @@ -44,15 +42,16 @@ namespace Saver.Core } public async Task RunAsync( - string productType, IReadOnlyList symbols, CancellationToken ct = default) + string productType, IReadOnlyList symbols, string interval, + CancellationToken ct = default) { switch (_recovery.Mode) { case 1: - await RunMode1Async(productType, symbols, ct); + await RunMode1Async(productType, symbols, interval, ct); break; case 2: - await RunMode2Async(productType, symbols, ct); + await RunMode2Async(productType, symbols, interval, ct); break; default: Log.Warning("[Recovery] 알 수 없는 모드: {Mode}", _recovery.Mode); @@ -65,7 +64,7 @@ namespace Saver.Core // ════════════════════════════════════════════════════════════════ private async Task RunMode1Async( - string productType, IReadOnlyList symbols, CancellationToken ct) + string productType, IReadOnlyList symbols, string interval, CancellationToken ct) { if (!_recovery.Mode1FromTime.HasValue) { @@ -74,11 +73,12 @@ namespace Saver.Core } var fromTime = _recovery.Mode1FromTime.Value; - Log.Information("[Recovery] ══════════════════════════════════════════"); - Log.Information("[Recovery] 모드1 시작: {From:yyyy-MM-dd HH:mm} UTC 이후 삭제 후 재수집", - fromTime); - Log.Information("[Recovery] 대상: {ProductType} {Count}개 심볼", productType, symbols.Count); - Log.Information("[Recovery] ══════════════════════════════════════════"); + Log.Information("[Recovery/{Interval}] ══════════════════════════════════════════", interval); + Log.Information("[Recovery/{Interval}] 모드1 시작: {From:yyyy-MM-dd HH:mm} UTC 이후 삭제 후 재수집", + interval, fromTime); + Log.Information("[Recovery/{Interval}] 대상: {ProductType} {Count}개 심볼", + interval, productType, symbols.Count); + Log.Information("[Recovery/{Interval}] ══════════════════════════════════════════", interval); int done = 0; foreach (var symbol in symbols) @@ -86,41 +86,43 @@ namespace Saver.Core if (ct.IsCancellationRequested) break; try { - // ── 1. 해당 시각 이후 데이터 삭제 ────────────────── int deleted = await _repo.DeleteCandlesFromAsync( - _adapter.Name, productType, symbol, fromTime, ct); + _adapter.Name, productType, symbol, interval, fromTime, ct); - Log.Information("[Recovery] {Symbol} 삭제: {Count}개 (>= {From:MM-dd HH:mm})", - symbol, deleted, fromTime); + Log.Information("[Recovery/{Interval}] {Symbol} 삭제: {Count}개 (>= {From:MM-dd HH:mm})", + interval, symbol, deleted, fromTime); - // ── 2. 삭제 구간부터 재수집 ───────────────────────── await _filler.FillAsync(productType, symbol, fromTime, ct); } catch (Exception ex) { - Log.Error(ex, "[Recovery] {Symbol} 모드1 오류 — 다음 심볼로 계속", symbol); + Log.Error(ex, "[Recovery/{Interval}] {Symbol} 모드1 오류 — 다음 심볼로 계속", + interval, symbol); } done++; - Log.Information("[Recovery] 진행: {Done}/{Total}", done, symbols.Count); + Log.Information("[Recovery/{Interval}] 진행: {Done}/{Total}", interval, done, symbols.Count); } - Log.Information("[Recovery] 모드1 완료"); + Log.Information("[Recovery/{Interval}] 모드1 완료", interval); } // ════════════════════════════════════════════════════════════════ - // 모드 2 — 전체 기간 빈 1분봉 스캔 후 채우기 + // 모드 2 — 전체 기간 빈 봉 스캔 후 채우기 // ════════════════════════════════════════════════════════════════ private async Task RunMode2Async( - string productType, IReadOnlyList symbols, CancellationToken ct) + string productType, IReadOnlyList symbols, string interval, CancellationToken ct) { - Log.Information("[Recovery] ══════════════════════════════════════════"); - Log.Information("[Recovery] 모드2 시작: 전체 기간 갭 스캔"); - Log.Information("[Recovery] 대상: {ProductType} {Count}개 심볼", productType, symbols.Count); - Log.Information("[Recovery] ══════════════════════════════════════════"); + var intervalDuration = CandleInterval.ToDuration(interval); - var now = DateTimeOffset.UtcNow.AddMinutes(-1); + Log.Information("[Recovery/{Interval}] ══════════════════════════════════════════", interval); + Log.Information("[Recovery/{Interval}] 모드2 시작: 전체 기간 갭 스캔", interval); + Log.Information("[Recovery/{Interval}] 대상: {ProductType} {Count}개 심볼", + interval, productType, symbols.Count); + Log.Information("[Recovery/{Interval}] ══════════════════════════════════════════", interval); + + var now = DateTimeOffset.UtcNow.Add(-intervalDuration); int done = 0; int totalGapSymbols = 0; @@ -129,52 +131,56 @@ namespace Saver.Core if (ct.IsCancellationRequested) break; try { - // 저장된 첫 캔들 시각부터 현재까지 전체 스캔 - var first = await _repo.GetFirstCandleTimeAsync(_adapter.Name, productType, symbol); + var first = await _repo.GetFirstCandleTimeAsync( + _adapter.Name, productType, symbol, interval); + if (!first.HasValue) { - Log.Debug("[Recovery] {Symbol} 저장 데이터 없음 — 건너뜀", symbol); + Log.Debug("[Recovery/{Interval}] {Symbol} 저장 데이터 없음 — 건너뜀", + interval, symbol); done++; continue; } - Log.Debug("[Recovery] {Symbol} 갭 스캔: {From:MM-dd HH:mm} ~ {To:MM-dd HH:mm}", - symbol, first.Value, now); + Log.Debug("[Recovery/{Interval}] {Symbol} 갭 스캔: {From:MM-dd HH:mm} ~ {To:MM-dd HH:mm}", + interval, symbol, first.Value, now); var gaps = await _repo.GetGapsAsync( - _adapter.Name, productType, symbol, first.Value, now); + _adapter.Name, productType, symbol, interval, first.Value, now); if (gaps.Count == 0) { - Log.Debug("[Recovery] {Symbol} 갭 없음", symbol); + Log.Debug("[Recovery/{Interval}] {Symbol} 갭 없음", interval, symbol); done++; continue; } totalGapSymbols++; + + long intervalMs = (long)intervalDuration.TotalMilliseconds; int totalMissing = gaps.Sum(g => - (int)(g.To - g.From).TotalMinutes + 1); + (int)((g.To - g.From).TotalMilliseconds / intervalMs) + 1); - Log.Warning("[Recovery] {Symbol} 갭 {GapCount}개 ({Missing}분 누락) — 보완 시작", - symbol, gaps.Count, totalMissing); + Log.Warning("[Recovery/{Interval}] {Symbol} 갭 {GapCount}개 ({Missing}봉 누락) — 보완 시작", + interval, symbol, gaps.Count, totalMissing); - // 가장 이른 갭 시작점부터 현재까지 재수집 - // (여러 갭이 있어도 FillAsync가 순차적으로 모두 채움) var firstGapStart = gaps.Min(g => g.From); await _filler.FillAsync(productType, symbol, firstGapStart, ct); - Log.Information("[Recovery] {Symbol} 보완 완료", symbol); + Log.Information("[Recovery/{Interval}] {Symbol} 보완 완료", interval, symbol); } catch (Exception ex) { - Log.Error(ex, "[Recovery] {Symbol} 모드2 오류 — 다음 심볼로 계속", symbol); + Log.Error(ex, "[Recovery/{Interval}] {Symbol} 모드2 오류 — 다음 심볼로 계속", + interval, symbol); } done++; - Log.Information("[Recovery] 진행: {Done}/{Total}", done, symbols.Count); + Log.Information("[Recovery/{Interval}] 진행: {Done}/{Total}", interval, done, symbols.Count); } - Log.Information("[Recovery] 모드2 완료: 갭 발견 심볼 {Count}개", totalGapSymbols); + Log.Information("[Recovery/{Interval}] 모드2 완료: 갭 발견 심볼 {Count}개", + interval, totalGapSymbols); } } } diff --git a/Saver/Core/SaverService.cs b/Saver/Core/SaverService.cs index c4a9095..6777f48 100644 --- a/Saver/Core/SaverService.cs +++ b/Saver/Core/SaverService.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using Saver.Adapters; using Saver.Config; +using Saver.Models; using Serilog; namespace Saver.Core @@ -8,21 +9,18 @@ namespace Saver.Core /// /// 전체 수집 서비스 오케스트레이터. /// - /// 실행 순서: + /// 실행 순서 (인터벌별): /// 0) 복구 모드 (Recovery.Mode != 0 일 때) - /// - 모드1: 특정 시각 이후 삭제 후 재수집 - /// - 모드2: 전체 기간 빈 1분봉 채우기 - /// 1) 심볼 목록 갱신 (거래소 API → 화이트/블랙리스트 적용) - /// 2) 과거 데이터 채우기 (최초 데이터 ~ 현재) - /// 3) 실시간 수집 (매분 폴링 + 1시간 갭 탐지) + /// 1) 과거 데이터 채우기 (최초 데이터 ~ 현재) + /// 2) 실시간 수집 — 모든 인터벌 동시 실행 /// public class SaverService : IDisposable { - private readonly ExchangeConfig _cfg; - private readonly IExchangeAdapter _adapter; - private readonly CandleRepository _repo; - private readonly RateLimiter _rateLimiter; - private readonly RecoveryConfig _recovery; + private readonly ExchangeConfig _cfg; + private readonly IExchangeAdapter _adapter; + private readonly CandleRepository _repo; + private readonly RateLimiter _rateLimiter; + private readonly RecoveryConfig _recovery; public SaverService( ExchangeConfig cfg, @@ -50,16 +48,23 @@ namespace Saver.Core { if (ct.IsCancellationRequested) break; - Log.Information("[Saver] 상품 처리 시작: {Type}", product.Type); + // 유효한 인터벌만 필터링 (미설정 시 "1m" 기본값) + var intervals = product.Intervals + .Where(CandleInterval.IsValid) + .Select(CandleInterval.Normalize) + .Distinct() + .ToList(); - // ── 1. 심볼 목록 갱신 ──────────────────────────────── + if (intervals.Count == 0) + intervals = new List { "1m" }; + + Log.Information("[Saver] 상품 처리 시작: {Type} / 인터벌: [{Intervals}]", + product.Type, string.Join(", ", intervals)); + + // ── 심볼 목록 갱신 ───────────────────────────────── var allSymbols = await _adapter.GetSymbolsAsync(product.Type, ct); - var blacklist = _cfg.Blacklist - .Select(s => s.ToUpperInvariant()) - .ToHashSet(); - var whitelist = _cfg.Whitelist - .Select(s => s.ToUpperInvariant()) - .ToHashSet(); + var blacklist = _cfg.Blacklist.Select(s => s.ToUpperInvariant()).ToHashSet(); + var whitelist = _cfg.Whitelist.Select(s => s.ToUpperInvariant()).ToHashSet(); var symbols = allSymbols .Where(s => !blacklist.Contains(s.ToUpperInvariant())) @@ -80,34 +85,55 @@ namespace Saver.Core continue; } - var filler = new HistoricalFiller( - _adapter, _repo, _rateLimiter, _cfg.MaxCandlesPerRequest); + // ── 인터벌별 복구 + 과거 데이터 채우기 ──────────── + bool recoveryRan = false; - // ── 0. 복구 모드 (설정된 경우) ─────────────────────── - if (_recovery.Mode != 0) + foreach (var interval in intervals) { - var recovery = new RecoveryService( - _cfg, _adapter, _repo, _rateLimiter, filler, _recovery); + if (ct.IsCancellationRequested) break; - await recovery.RunAsync(product.Type, symbols, ct); + var filler = new HistoricalFiller( + _adapter, _repo, _rateLimiter, _cfg.MaxCandlesPerRequest, interval); - // 복구 성공 → appsettings.json Mode를 0으로 초기화 + // 복구 모드 + if (_recovery.Mode != 0) + { + var recovery = new RecoveryService( + _cfg, _adapter, _repo, _rateLimiter, filler, _recovery); + await recovery.RunAsync(product.Type, symbols, interval, ct); + recoveryRan = true; + } + + // 과거 데이터 채우기 + await filler.FillAllAsync(product.Type, symbols, ct); + } + + // 복구 완료 후 Mode 초기화 + if (recoveryRan) + { ResetRecoveryMode(); Log.Information("[Saver] 복구 완료 → 정상 수집으로 전환"); } - // ── 2. 과거 데이터 채우기 ──────────────────────────── - await filler.FillAllAsync(product.Type, symbols, ct); + // ── 실시간 수집 — 인터벌별 병렬 실행 ─────────────── + Log.Information("[Saver] 실시간 수집 시작: {Count}개 인터벌 동시 실행", + intervals.Count); - // ── 3. 실시간 수집 ─────────────────────────────────── - var poller = new LivePoller(_adapter, _repo, _rateLimiter, filler); - await poller.RunAsync(product.Type, symbols, ct); + var pollerTasks = intervals.Select(interval => + { + var filler = new HistoricalFiller( + _adapter, _repo, _rateLimiter, _cfg.MaxCandlesPerRequest, interval); + var poller = new LivePoller( + _adapter, _repo, _rateLimiter, filler, interval); + return poller.RunAsync(product.Type, symbols, ct); + }).ToList(); + + await Task.WhenAll(pollerTasks); } } /// /// appsettings.json의 Recovery.Mode를 0으로 초기화합니다. - /// 복구 완료 후 재시작 시 중복 실행을 방지합니다. /// private static void ResetRecoveryMode() { @@ -126,7 +152,6 @@ namespace Saver.Core if (node is JsonObject root) { - // Recovery 섹션이 없으면 생성 if (root["Recovery"] is not JsonObject recoveryNode) { recoveryNode = new JsonObject(); diff --git a/Saver/Models/CandleInterval.cs b/Saver/Models/CandleInterval.cs new file mode 100644 index 0000000..de874ab --- /dev/null +++ b/Saver/Models/CandleInterval.cs @@ -0,0 +1,46 @@ +namespace Saver.Models +{ + /// + /// 캔들 인터벌 유틸리티. + /// 인터벌은 문자열로 관리합니다 ("1m", "5m", "1h" 등). + /// + public static class CandleInterval + { + /// 지원하는 인터벌 목록 + public static readonly string[] Supported = + { "1m", "3m", "5m", "15m", "30m", "1h", "4h", "6h", "12h", "1d" }; + + /// 인터벌 문자열 → TimeSpan 변환 + public static TimeSpan ToDuration(string interval) => interval.ToLower() switch + { + "1m" => TimeSpan.FromMinutes(1), + "3m" => TimeSpan.FromMinutes(3), + "5m" => TimeSpan.FromMinutes(5), + "15m" => TimeSpan.FromMinutes(15), + "30m" => TimeSpan.FromMinutes(30), + "1h" => TimeSpan.FromHours(1), + "4h" => TimeSpan.FromHours(4), + "6h" => TimeSpan.FromHours(6), + "12h" => TimeSpan.FromHours(12), + "1d" => TimeSpan.FromDays(1), + _ => throw new ArgumentException($"지원하지 않는 인터벌: {interval}") + }; + + /// 갭 탐지 주기: 인터벌에 비례, 최소 1시간, 최대 24시간 + public static TimeSpan GapCheckInterval(string interval) + { + var d = ToDuration(interval); + var ticks = Math.Clamp(d.Ticks * 60, + TimeSpan.FromHours(1).Ticks, + TimeSpan.FromHours(24).Ticks); + return TimeSpan.FromTicks(ticks); + } + + /// 유효한 인터벌인지 확인 + public static bool IsValid(string interval) + => Supported.Contains(interval.ToLower()); + + /// 소문자 정규화 + public static string Normalize(string interval) => interval.ToLower(); + } +} diff --git a/Saver/appsettings.json b/Saver/appsettings.json index 7070336..fcbef6f 100644 --- a/Saver/appsettings.json +++ b/Saver/appsettings.json @@ -4,7 +4,7 @@ "RootPath": "C:\\data" }, "Recovery": { - "Mode": 2 + "Mode": 0 }, "Exchanges": [ { @@ -17,7 +17,8 @@ "Products": [ { "Type": "USDT-FUTURES", - "Enabled": true + "Enabled": true, + "Intervals": [ "30m" ] }, { "Type": "SPOT", @@ -29,7 +30,7 @@ } ], "Blacklist": [], - "Whitelist": ["0GUSDT"] + "Whitelist": [] } ] }