Files
PineSript/Saver/Core/RecoveryService.cs

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);
}
}
}