분봉 선택저장 기능 추가
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
46
Saver/Models/CandleInterval.cs
Normal file
46
Saver/Models/CandleInterval.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user