187 lines
8.4 KiB
C#
187 lines
8.4 KiB
C#
using Saver.Adapters;
|
|
using Saver.Config;
|
|
using Saver.Models;
|
|
using Serilog;
|
|
|
|
namespace Saver.Core
|
|
{
|
|
/// <summary>
|
|
/// 복구 서비스.
|
|
///
|
|
/// 모드 1 — 특정 시각 이후 삭제 후 재수집
|
|
/// appsettings.json: "Recovery": { "Mode": 1, "Mode1FromTime": "2026-04-01T00:00:00Z" }
|
|
///
|
|
/// 모드 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;
|
|
|
|
public RecoveryService(
|
|
ExchangeConfig cfg,
|
|
IExchangeAdapter adapter,
|
|
CandleRepository repo,
|
|
RateLimiter rateLimiter,
|
|
HistoricalFiller filler,
|
|
RecoveryConfig recovery)
|
|
{
|
|
_cfg = cfg;
|
|
_adapter = adapter;
|
|
_repo = repo;
|
|
_rateLimiter = rateLimiter;
|
|
_filler = filler;
|
|
_recovery = recovery;
|
|
}
|
|
|
|
public async Task RunAsync(
|
|
string productType, IReadOnlyList<string> symbols, string interval,
|
|
CancellationToken ct = default)
|
|
{
|
|
switch (_recovery.Mode)
|
|
{
|
|
case 1:
|
|
await RunMode1Async(productType, symbols, interval, ct);
|
|
break;
|
|
case 2:
|
|
await RunMode2Async(productType, symbols, interval, ct);
|
|
break;
|
|
default:
|
|
Log.Warning("[Recovery] 알 수 없는 모드: {Mode}", _recovery.Mode);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 모드 1 — 특정 시각 이후 삭제 후 재수집
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
private async Task RunMode1Async(
|
|
string productType, IReadOnlyList<string> symbols, string interval, CancellationToken ct)
|
|
{
|
|
if (!_recovery.Mode1FromTime.HasValue)
|
|
{
|
|
Log.Error("[Recovery] 모드1: Mode1FromTime이 설정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
var fromTime = _recovery.Mode1FromTime.Value;
|
|
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)
|
|
{
|
|
if (ct.IsCancellationRequested) break;
|
|
try
|
|
{
|
|
int deleted = await _repo.DeleteCandlesFromAsync(
|
|
_adapter.Name, productType, symbol, interval, fromTime, ct);
|
|
|
|
Log.Information("[Recovery/{Interval}] {Symbol} 삭제: {Count}개 (>= {From:MM-dd HH:mm})",
|
|
interval, symbol, deleted, fromTime);
|
|
|
|
await _filler.FillAsync(productType, symbol, fromTime, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "[Recovery/{Interval}] {Symbol} 모드1 오류 — 다음 심볼로 계속",
|
|
interval, symbol);
|
|
}
|
|
|
|
done++;
|
|
Log.Information("[Recovery/{Interval}] 진행: {Done}/{Total}", interval, done, symbols.Count);
|
|
}
|
|
|
|
Log.Information("[Recovery/{Interval}] 모드1 완료", interval);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════
|
|
// 모드 2 — 전체 기간 빈 봉 스캔 후 채우기
|
|
// ════════════════════════════════════════════════════════════════
|
|
|
|
private async Task RunMode2Async(
|
|
string productType, IReadOnlyList<string> symbols, string interval, CancellationToken ct)
|
|
{
|
|
var intervalDuration = CandleInterval.ToDuration(interval);
|
|
|
|
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;
|
|
|
|
foreach (var symbol in symbols)
|
|
{
|
|
if (ct.IsCancellationRequested) break;
|
|
try
|
|
{
|
|
var first = await _repo.GetFirstCandleTimeAsync(
|
|
_adapter.Name, productType, symbol, interval);
|
|
|
|
if (!first.HasValue)
|
|
{
|
|
Log.Debug("[Recovery/{Interval}] {Symbol} 저장 데이터 없음 — 건너뜀",
|
|
interval, symbol);
|
|
done++;
|
|
continue;
|
|
}
|
|
|
|
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, interval, first.Value, now);
|
|
|
|
if (gaps.Count == 0)
|
|
{
|
|
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).TotalMilliseconds / intervalMs) + 1);
|
|
|
|
Log.Warning("[Recovery/{Interval}] {Symbol} 갭 {GapCount}개 ({Missing}봉 누락) — 보완 시작",
|
|
interval, symbol, gaps.Count, totalMissing);
|
|
|
|
var firstGapStart = gaps.Min(g => g.From);
|
|
await _filler.FillAsync(productType, symbol, firstGapStart, ct);
|
|
|
|
Log.Information("[Recovery/{Interval}] {Symbol} 보완 완료", interval, symbol);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "[Recovery/{Interval}] {Symbol} 모드2 오류 — 다음 심볼로 계속",
|
|
interval, symbol);
|
|
}
|
|
|
|
done++;
|
|
Log.Information("[Recovery/{Interval}] 진행: {Done}/{Total}", interval, done, symbols.Count);
|
|
}
|
|
|
|
Log.Information("[Recovery/{Interval}] 모드2 완료: 갭 발견 심볼 {Count}개",
|
|
interval, totalGapSymbols);
|
|
}
|
|
}
|
|
}
|