분봉 선택저장 기능 추가

This commit is contained in:
2026-04-27 18:25:16 +09:00
parent 191a3ba987
commit e8e9fc9be1
10 changed files with 455 additions and 310 deletions

View File

@@ -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<List<Candle>> 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
/// <summary>
/// 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 — 이진 탐색 (인터벌 정밀도)
/// </summary>
public async Task<DateTimeOffset?> 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
// 내부 헬퍼
// ════════════════════════════════════════════════════════════════
/// <summary>인터벌을 Bitget API granularity 문자열로 변환</summary>
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);
}

View File

@@ -15,25 +15,26 @@ namespace Saver.Adapters
/// <summary>
/// 캔들 데이터 조회.
/// from ~ to 구간의 1분봉을 최대 limit개 반환합니다.
/// 거래소마다 가장 오래된 데이터부터 반환하는지 최신부터 반환하는지 다를 수 있으므로
/// from ~ to 구간의 지정 인터벌 캔들을 최대 limit개 반환합니다.
/// 구현체에서 오름차순(OpenTime ASC)으로 정렬하여 반환해야 합니다.
/// </summary>
Task<List<Candle>> GetCandlesAsync(
string symbol,
string productType,
string interval,
DateTimeOffset from,
DateTimeOffset to,
int limit,
CancellationToken ct = default);
/// <summary>
/// 해당 심볼의 가장 오래된 캔들 시각 조회.
/// 해당 심볼·인터벌의 가장 오래된 캔들 시각 조회.
/// 거래소 API가 지원하지 않으면 null 반환 → 기본 시작일 사용.
/// </summary>
Task<DateTimeOffset?> GetOldestCandleTimeAsync(
string symbol,
string productType,
string interval,
CancellationToken ct = default);
}
}

View File

@@ -64,5 +64,16 @@ namespace Saver.Config
/// <summary>상품 타입 (USDT-FUTURES | SPOT | COIN-FUTURES)</summary>
public string Type { get; set; } = "";
public bool Enabled { get; set; } = true;
/// <summary>
/// 수집할 캔들 인터벌 목록.
/// 지원값: "1m", "3m", "5m", "15m", "30m", "1h", "4h", "6h", "12h", "1d"
/// 기본값: 1분봉만 수집.
/// </summary>
/// <summary>
/// 비어있으면 SaverService에서 "1m" 기본값으로 처리합니다.
/// configuration.Bind()가 기존 리스트에 append하므로 기본값은 빈 리스트로 유지합니다.
/// </summary>
public List<string> Intervals { get; set; } = new();
}
}

View File

@@ -6,7 +6,7 @@ namespace Saver.Core
{
/// <summary>
/// 캔들 데이터 SQLite 저장소.
/// 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Year}.db
/// 경로 구조: {RootPath}/{Exchange}/{ProductType}/{Symbol}/{Interval}/{Year}.db
/// </summary>
public class CandleRepository
{
@@ -27,16 +27,15 @@ namespace Saver.Core
/// <summary>캔들 목록을 해당 연도 파일에 저장 (Upsert)</summary>
public async Task SaveCandlesAsync(
string exchange, string productType, string symbol,
string exchange, string productType, string symbol, string interval,
IEnumerable<Candle> 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
/// 연도 파일 단위로 처리하며 삭제된 총 행 수를 반환합니다.
/// </summary>
public async Task<int> 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
/// <summary>저장된 가장 마지막 캔들 시각 조회 (없으면 null)</summary>
public async Task<DateTimeOffset?> 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
/// <summary>저장된 가장 첫 캔들 시각 조회 (없으면 null)</summary>
public async Task<DateTimeOffset?> 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
}
/// <summary>
/// 지정 구간 내 누락된 1분봉 구간 목록 반환.
/// 지정 구간 내 누락된 봉 구간 목록 반환.
/// 연도 파일이 없는 경우도 갭으로 처리합니다.
/// </summary>
public async Task<List<TimeGap>> GetGapsAsync(
string exchange, string productType, string symbol,
string exchange, string productType, string symbol, string interval,
DateTimeOffset from, DateTimeOffset to)
{
var gaps = new List<TimeGap>();
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<List<TimeGap>> FindGapsInRangeAsync(
SqliteConnection conn, DateTimeOffset from, DateTimeOffset to)
SqliteConnection conn, DateTimeOffset from, DateTimeOffset to,
TimeSpan intervalDuration)
{
var gaps = new List<TimeGap>();
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<SqliteConnection> 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();
}
/// <summary>인터벌에 맞는 파일 검색 패턴</summary>
private static string DbPattern(string interval)
{
string norm = CandleInterval.Normalize(interval);
return norm == "1m" ? "????.db" : $"????_{norm}.db";
}
/// <summary>파일명에서 연도 추출</summary>
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)

View File

@@ -11,21 +11,26 @@ namespace Saver.Core
/// </summary>
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);
}
/// <summary>
@@ -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<Candle> 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);
}
/// <summary>
/// 여러 심볼을 순차적으로 채웁니다.
/// </summary>
/// <summary>여러 심볼을 순차적으로 채웁니다.</summary>
public async Task FillAllAsync(
string productType, IEnumerable<string> 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);
}
}
}

View File

@@ -5,63 +5,66 @@ using Serilog;
namespace Saver.Core
{
/// <summary>
/// 실시간 1분봉 수집기.
/// 매분 새로 완성된 봉을 감지하여 저장합니다.
/// 1시간마다 갭 탐지를 실행하여 누락 봉을 자동 보완합니다.
/// 실시간 캔들 수집기.
/// 매 인터벌마다 새로 완성된 봉을 감지하여 저장합니다.
/// 주기적으로 갭 탐지를 실행하여 누락 봉을 자동 보완합니다.
/// </summary>
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);
}
/// <summary>
/// 실시간 수집 루프 (블로킹).
/// 매분 전체 심볼에서 새 봉을 수집합니다.
/// 매 인터벌마다 전체 심볼에서 새 봉을 수집합니다.
/// </summary>
public async Task RunAsync(
string productType,
IReadOnlyList<string> 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);
}
/// <summary>갭 탐지 및 자동 보완 (백그라운드)</summary>
private async Task RunGapCheckAsync(
string productType, IReadOnlyList<string> 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()
/// <summary>다음 인터벌 경계 시각 계산</summary>
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);
}
/// <summary>방금 완성된 봉의 시작 시각 계산</summary>
private DateTimeOffset GetLastCompletedBar()
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long intervalMs = (long)_intervalDuration.TotalMilliseconds;
long barMs = (nowMs / intervalMs) * intervalMs - intervalMs;
return DateTimeOffset.FromUnixTimeMilliseconds(barMs);
}
}
}

View File

@@ -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)으로 전환합니다.
/// </summary>
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<string> symbols, CancellationToken ct = default)
string productType, IReadOnlyList<string> 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<string> symbols, CancellationToken ct)
string productType, IReadOnlyList<string> 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<string> symbols, CancellationToken ct)
string productType, IReadOnlyList<string> 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);
}
}
}

View File

@@ -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
/// <summary>
/// 전체 수집 서비스 오케스트레이터.
///
/// 실행 순서:
/// 실행 순서 (인터벌별):
/// 0) 복구 모드 (Recovery.Mode != 0 일 때)
/// - 모드1: 특정 시각 이후 삭제 후 재수집
/// - 모드2: 전체 기간 빈 1분봉 채우기
/// 1) 심볼 목록 갱신 (거래소 API → 화이트/블랙리스트 적용)
/// 2) 과거 데이터 채우기 (최초 데이터 ~ 현재)
/// 3) 실시간 수집 (매분 폴링 + 1시간 갭 탐지)
/// 1) 과거 데이터 채우기 (최초 데이터 ~ 현재)
/// 2) 실시간 수집 — 모든 인터벌 동시 실행
/// </summary>
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<string> { "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);
}
}
/// <summary>
/// appsettings.json의 Recovery.Mode를 0으로 초기화합니다.
/// 복구 완료 후 재시작 시 중복 실행을 방지합니다.
/// </summary>
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();

View File

@@ -0,0 +1,46 @@
namespace Saver.Models
{
/// <summary>
/// 캔들 인터벌 유틸리티.
/// 인터벌은 문자열로 관리합니다 ("1m", "5m", "1h" 등).
/// </summary>
public static class CandleInterval
{
/// <summary>지원하는 인터벌 목록</summary>
public static readonly string[] Supported =
{ "1m", "3m", "5m", "15m", "30m", "1h", "4h", "6h", "12h", "1d" };
/// <summary>인터벌 문자열 → TimeSpan 변환</summary>
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}")
};
/// <summary>갭 탐지 주기: 인터벌에 비례, 최소 1시간, 최대 24시간</summary>
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);
}
/// <summary>유효한 인터벌인지 확인</summary>
public static bool IsValid(string interval)
=> Supported.Contains(interval.ToLower());
/// <summary>소문자 정규화</summary>
public static string Normalize(string interval) => interval.ToLower();
}
}

View File

@@ -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": []
}
]
}