فهرست منبع

refactor(market): 초기 KRX daily 서비스 5종에 3년 백필 적용 (균일화)

index·bondindex·stock·etp·warrant SyncService 를 "전일만" → KrxBackfill.RunAsync
(today-3y ~ 전영업일, 최신일 역순, 누락일만 fetch, 재개가능·쿼터제한)로 전환.
existsForDate = 각 테이블 TradeDate AnyAsync(다중엔드포인트 단일테이블 규약).
Stock.UpdateLastPrice 는 과거일 early-return 이라 최신 denorm 보존.
스키마·마이그레이션·localdb 무관. 빌드0·219/219.

→ KRX 31/31 전 데이터셋 균일 3년 백필 완비.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 هفته پیش
والد
کامیت
c75814667d

+ 44 - 13
Infrastructure/StockData/BondIndexPriceSyncService.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Data;
+using Application.Helpers;
 using Domain.Entities.Stocks;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.DependencyInjection;
@@ -11,8 +12,10 @@ namespace Infrastructure.StockData;
 /// <summary>
 /// 채권지수 일별시세 수집 — KRX OpenAPI (idx/bon_dd_trd, AUTH_KEY 헤더).
 /// 응답 shape(총수익·순가격·재투자·시장가격 지수 + 듀레이션·컨벡시티·YTM)이 주식/파생 지수와 달라 IndexPriceSyncService 와 분리한다.
-/// 기본 18:35 KST 실행(지수 수집 18:30 뒤), basDd=직전 영업일 1개 엔드포인트 전량 수집 → BondIndexDailyPrice upsert (UQ = GroupName+TradeDate).
-/// 미반영(0건)이면 2시간 간격 2회 재시도. ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
+/// 기본 18:35 KST 실행(지수 수집 18:30 뒤). 공용 KrxBackfill 로 최근 BackfillYears(기본 3)년치를 endDate(직전 영업일)부터
+/// 과거로 훑으며 미적재일만 채운다. quota 보호를 위해 1회 실행당 BackfillMaxPerRun(기본 60)일까지만 fetch →
+/// 여러 날에 걸쳐 3년치를 메우고 이후엔 최신만 유지한다. 각 날짜는 1개 엔드포인트를 수집해 BondIndexDailyPrice upsert
+/// (UQ = GroupName+TradeDate). ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
 /// </summary>
 internal sealed class BondIndexPriceSyncService(
     IServiceScopeFactory scopeFactory,
@@ -44,21 +47,51 @@ internal sealed class BondIndexPriceSyncService(
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
 
-        var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var endDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
+        var startDate = todayKst.AddYears(-years);
 
-        var url = $"{cfg.BaseUrl.TrimEnd('/')}{EndpointPath}?basDd={targetDate:yyyyMMdd}";
-        var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+        // 백필 창 전체의 휴장일을 한 번에 로드 (KrxBackfill 은 주말은 자동 제외, 휴장일만 필요)
+        var holidays = (await db.MarketHoliday.AsNoTracking()
+            .Where(c => c.Date >= startDate && c.Date <= endDate)
+            .Select(c => c.Date)
+            .ToListAsync(ct)).ToHashSet();
+
+        var maxPerRun = cfg.BackfillMaxPerRun > 0 ? cfg.BackfillMaxPerRun : 60;
+
+        var fetched = await KrxBackfill.RunAsync(
+            existsForDate: (day, token) => db.BondIndexDailyPrice.AsNoTracking().AnyAsync(c => c.TradeDate == day, token),
+            fetchAndUpsertForDate: (day, token) => FetchAndUpsertAsync(db, client, cfg.BaseUrl, cfg.ApiKey, day, token),
+            startDate: startDate,
+            endDate: endDate,
+            holidays: holidays,
+            maxPerRun: maxPerRun,
+            delayMs: 300,
+            ct: ct);
+
+        Logger.LogInformation("[{Job}] 완료 — 창=[{Start}~{End}], 이번 실행 fetch={Fetched}일 (maxPerRun={Max})",
+            JobName, startDate, endDate, fetched, maxPerRun);
+
+        // fetch 가 0 이어도(이미 최신까지 적재됨) 정상 완료 — 재시도 불필요
+        return true;
+    }
+
+    /// <summary>한 날짜에 대해 채권지수 엔드포인트를 수집하고 BondIndexDailyPrice upsert.</summary>
+    private async Task FetchAndUpsertAsync(IAppDbContext db, HttpClient client, string baseUrl, string apiKey, DateOnly day, CancellationToken ct)
+    {
+        var url = $"{baseUrl.TrimEnd('/')}{EndpointPath}?basDd={day:yyyyMMdd}";
+        var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, apiKey, Logger, ct);
         var rows = KrxBondIndexParser.ParseBondIndexPrices(json);
 
-        Logger.LogInformation("[{Job}] basDd={TargetDate} rows={Rows}", JobName, targetDate, rows.Count);
+        Logger.LogInformation("[{Job}] basDd={Day} rows={Rows}", JobName, day, rows.Count);
 
         if (rows.Count == 0)
         {
-            Logger.LogInformation("[{Job}] basDd={TargetDate} 채권지수 미반영 (0건)", JobName, targetDate);
-            return false;
+            Logger.LogInformation("[{Job}] basDd={Day} 채권지수 미반영 (0건)", JobName, day);
+            return;
         }
 
-        var existing = await db.BondIndexDailyPrice.Where(c => c.TradeDate == targetDate).ToListAsync(ct);
+        var existing = await db.BondIndexDailyPrice.Where(c => c.TradeDate == day).ToListAsync(ct);
         var existingByKey = existing.ToDictionary(c => c.GroupName);
         var inserted = 0;
         var updated = 0;
@@ -81,9 +114,7 @@ internal sealed class BondIndexPriceSyncService(
 
         await db.SaveChangesAsync(ct);
 
-        Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}",
-            JobName, targetDate, rows.Count, inserted, updated);
-
-        return true;
+        Logger.LogInformation("[{Job}] basDd={Day} 적재 — rows={Rows}, inserted={Inserted}, updated={Updated}",
+            JobName, day, rows.Count, inserted, updated);
     }
 }

+ 46 - 13
Infrastructure/StockData/IndexPriceSyncService.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Data;
+using Application.Helpers;
 using Domain.Entities.Stocks;
 using Domain.Entities.Stocks.ValueObject;
 using Microsoft.EntityFrameworkCore;
@@ -11,10 +12,13 @@ namespace Infrastructure.StockData;
 
 /// <summary>
 /// 지수(시장) 일별시세 수집 — KRX OpenAPI (KOSPI/KOSDAQ/KRX + 파생상품지수 시리즈, AUTH_KEY 헤더).
-/// 기본 18:30 KST 실행(장 마감 후 확정), basDd=직전 영업일 4개 엔드포인트 전량 수집 → IndexDailyPrice upsert (UQ = Series+IndexName+TradeDate).
+/// 기본 18:30 KST 실행(장 마감 후 확정). 공용 KrxBackfill 로 최근 BackfillYears(기본 3)년치를 endDate(직전 영업일)부터
+/// 과거로 훑으며 미적재일만 채운다. quota 보호를 위해 1회 실행당 BackfillMaxPerRun(기본 60)일까지만 fetch →
+/// 여러 날에 걸쳐 3년치를 메우고 이후엔 최신만 유지한다. 각 날짜는 4개 엔드포인트를 모두 수집해 IndexDailyPrice upsert
+/// (UQ = Series+IndexName+TradeDate).
 /// 파생상품지수(idx/drvprod_dd_trd)는 BAS_DD/IDX_NM/CLSPRC_IDX/… OHLC shape 이 지수와 동일하여 재사용(거래량/거래대금/시총 미제공 → 0/null).
 /// 채권지수(idx/bon_dd_trd)는 응답 shape 이 달라 별도 BondIndexPriceSyncService 로 분리한다.
-/// 미반영(0건)이면 2시간 간격 2회 재시도. ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책).
+/// ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
 /// </summary>
 internal sealed class IndexPriceSyncService(
     IServiceScopeFactory scopeFactory,
@@ -53,26 +57,57 @@ internal sealed class IndexPriceSyncService(
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
 
-        var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var endDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
+        var startDate = todayKst.AddYears(-years);
+
+        // 백필 창 전체의 휴장일을 한 번에 로드 (KrxBackfill 은 주말은 자동 제외, 휴장일만 필요)
+        var holidays = (await db.MarketHoliday.AsNoTracking()
+            .Where(c => c.Date >= startDate && c.Date <= endDate)
+            .Select(c => c.Date)
+            .ToListAsync(ct)).ToHashSet();
+
+        var maxPerRun = cfg.BackfillMaxPerRun > 0 ? cfg.BackfillMaxPerRun : 60;
+
+        var fetched = await KrxBackfill.RunAsync(
+            existsForDate: (day, token) => db.IndexDailyPrice.AsNoTracking().AnyAsync(c => c.TradeDate == day, token),
+            fetchAndUpsertForDate: (day, token) => FetchAndUpsertAsync(db, client, cfg.BaseUrl, cfg.ApiKey, day, token),
+            startDate: startDate,
+            endDate: endDate,
+            holidays: holidays,
+            maxPerRun: maxPerRun,
+            delayMs: 300,
+            ct: ct);
+
+        Logger.LogInformation("[{Job}] 완료 — 창=[{Start}~{End}], 이번 실행 fetch={Fetched}일 (maxPerRun={Max})",
+            JobName, startDate, endDate, fetched, maxPerRun);
+
+        // fetch 가 0 이어도(이미 최신까지 적재됨) 정상 완료 — 재시도 불필요
+        return true;
+    }
+
+    /// <summary>한 날짜에 대해 4개 지수 엔드포인트를 모두 수집하고 IndexDailyPrice upsert.</summary>
+    private async Task FetchAndUpsertAsync(IAppDbContext db, HttpClient client, string baseUrl, string apiKey, DateOnly day, CancellationToken ct)
+    {
         var rows = new List<KrxIndexParser.IndexRow>();
 
         foreach (var (series, path) in Endpoints)
         {
-            var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={targetDate:yyyyMMdd}";
-            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+            var url = $"{baseUrl.TrimEnd('/')}{path}?basDd={day:yyyyMMdd}";
+            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, apiKey, Logger, ct);
             var parsed = KrxIndexParser.ParseIndexPrices(json, series);
 
-            Logger.LogInformation("[{Job}] {Series} basDd={TargetDate} rows={Rows}", JobName, series, targetDate, parsed.Count);
+            Logger.LogInformation("[{Job}] {Series} basDd={Day} rows={Rows}", JobName, series, day, parsed.Count);
             rows.AddRange(parsed);
         }
 
         if (rows.Count == 0)
         {
-            Logger.LogInformation("[{Job}] basDd={TargetDate} 지수 미반영 (0건)", JobName, targetDate);
-            return false;
+            Logger.LogInformation("[{Job}] basDd={Day} 지수 미반영 (0건)", JobName, day);
+            return;
         }
 
-        var existing = await db.IndexDailyPrice.Where(c => c.TradeDate == targetDate).ToListAsync(ct);
+        var existing = await db.IndexDailyPrice.Where(c => c.TradeDate == day).ToListAsync(ct);
         var existingByKey = existing.ToDictionary(c => (c.Series, c.IndexName));
         var inserted = 0;
         var updated = 0;
@@ -95,9 +130,7 @@ internal sealed class IndexPriceSyncService(
 
         await db.SaveChangesAsync(ct);
 
-        Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}",
-            JobName, targetDate, rows.Count, inserted, updated);
-
-        return true;
+        Logger.LogInformation("[{Job}] basDd={Day} 적재 — rows={Rows}, inserted={Inserted}, updated={Updated}",
+            JobName, day, rows.Count, inserted, updated);
     }
 }

+ 47 - 14
Infrastructure/StockData/KrxDailyPriceSyncService.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Data;
+using Application.Helpers;
 using Domain.Entities.Stocks;
 using Domain.Entities.Stocks.ValueObject;
 using Microsoft.EntityFrameworkCore;
@@ -11,10 +12,13 @@ namespace Infrastructure.StockData;
 
 /// <summary>
 /// 일별 마감 시세 수집 (KRX OpenAPI) — stk_bydd_trd(KOSPI) + ksq_bydd_trd(KOSDAQ) + knx_bydd_trd(KONEX) 일별매매정보.
-/// 기본 18:10 KST 실행(장 마감 15:30 이후 확정), basDd=직전 영업일 전량 수집 →
+/// 기본 18:10 KST 실행(장 마감 15:30 이후 확정). 공용 KrxBackfill 로 최근 BackfillYears(기본 3)년치를 endDate(직전 영업일)부터
+/// 과거로 훑으며 미적재일만 채운다. quota 보호를 위해 1회 실행당 BackfillMaxPerRun(기본 60)일까지만 fetch →
+/// 여러 날에 걸쳐 3년치를 메우고 이후엔 최신만 유지한다. 각 날짜는 3개 시장 엔드포인트를 모두 수집해
 /// StockDailyPrice upsert (StockID+TradingDate) + Stock.UpdateLastPrice denorm(최근 종가/등락률/시총) 갱신.
-/// 마스터 미동기화 코드는 skip (다음 마스터 동기화 후 자연 반영). 미반영(0건)이면 2시간 간격 2회 재시도.
-/// ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책).
+/// (denorm 은 Stock.UpdateLastPrice 가 최신일보다 과거일이면 무시하므로 과거 백필이 최신값을 덮지 않는다.)
+/// 마스터 미동기화 코드는 skip (다음 마스터 동기화 후 자연 반영).
+/// ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
 /// KONEX 응답 필드셋은 KOSPI/KOSDAQ 과 동일(ISU_CD/TDD_*/ACC_*/MKTCAP/LIST_SHRS) — KrxStockParser 재사용.
 /// </summary>
 internal sealed class KrxDailyPriceSyncService(
@@ -53,27 +57,58 @@ internal sealed class KrxDailyPriceSyncService(
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
 
-        var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var endDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
+        var startDate = todayKst.AddYears(-years);
+
+        // 백필 창 전체의 휴장일을 한 번에 로드 (KrxBackfill 은 주말은 자동 제외, 휴장일만 필요)
+        var holidays = (await db.MarketHoliday.AsNoTracking()
+            .Where(c => c.Date >= startDate && c.Date <= endDate)
+            .Select(c => c.Date)
+            .ToListAsync(ct)).ToHashSet();
+
+        var maxPerRun = cfg.BackfillMaxPerRun > 0 ? cfg.BackfillMaxPerRun : 60;
+
+        var fetched = await KrxBackfill.RunAsync(
+            existsForDate: (day, token) => db.StockDailyPrice.AsNoTracking().AnyAsync(c => c.TradingDate == day, token),
+            fetchAndUpsertForDate: (day, token) => FetchAndUpsertAsync(db, client, cfg.BaseUrl, cfg.ApiKey, day, token),
+            startDate: startDate,
+            endDate: endDate,
+            holidays: holidays,
+            maxPerRun: maxPerRun,
+            delayMs: 300,
+            ct: ct);
+
+        Logger.LogInformation("[{Job}] 완료 — 창=[{Start}~{End}], 이번 실행 fetch={Fetched}일 (maxPerRun={Max})",
+            JobName, startDate, endDate, fetched, maxPerRun);
+
+        // fetch 가 0 이어도(이미 최신까지 적재됨) 정상 완료 — 재시도 불필요
+        return true;
+    }
+
+    /// <summary>한 날짜에 대해 3개 시장 엔드포인트를 모두 수집하고 StockDailyPrice upsert + Stock denorm 갱신.</summary>
+    private async Task FetchAndUpsertAsync(IAppDbContext db, HttpClient client, string baseUrl, string apiKey, DateOnly day, CancellationToken ct)
+    {
         var rows = new List<KrxStockParser.DailyPriceItem>();
 
         foreach (var (market, path) in Endpoints)
         {
-            var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={targetDate:yyyyMMdd}";
-            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+            var url = $"{baseUrl.TrimEnd('/')}{path}?basDd={day:yyyyMMdd}";
+            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, apiKey, Logger, ct);
             var parsed = KrxStockParser.ParseDailyPrices(json, market);
 
-            Logger.LogInformation("[{Job}] {Market} basDd={TargetDate} rows={Rows}", JobName, market, targetDate, parsed.Count);
+            Logger.LogInformation("[{Job}] {Market} basDd={Day} rows={Rows}", JobName, market, day, parsed.Count);
             rows.AddRange(parsed);
         }
 
         if (rows.Count == 0)
         {
-            Logger.LogInformation("[{Job}] basDd={TargetDate} 시세 미반영 (0건)", JobName, targetDate);
-            return false;
+            Logger.LogInformation("[{Job}] basDd={Day} 시세 미반영 (0건)", JobName, day);
+            return;
         }
 
         var stockByCode = await db.Stock.ToDictionaryAsync(c => c.Code, ct);
-        var existingByStockID = await db.StockDailyPrice.Where(c => c.TradingDate == targetDate).ToDictionaryAsync(c => c.StockID, ct);
+        var existingByStockID = await db.StockDailyPrice.Where(c => c.TradingDate == day).ToDictionaryAsync(c => c.StockID, ct);
         var inserted = 0;
         var updated = 0;
         var unknown = 0;
@@ -105,9 +140,7 @@ internal sealed class KrxDailyPriceSyncService(
 
         await db.SaveChangesAsync(ct);
 
-        Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}, unknownCode={Unknown}",
-            JobName, targetDate, rows.Count, inserted, updated, unknown);
-
-        return true;
+        Logger.LogInformation("[{Job}] basDd={Day} 적재 — rows={Rows}, inserted={Inserted}, updated={Updated}, unknownCode={Unknown}",
+            JobName, day, rows.Count, inserted, updated, unknown);
     }
 }

+ 46 - 15
Infrastructure/StockData/KrxEtpSyncService.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Data;
+using Application.Helpers;
 using Domain.Entities.Stocks;
 using Domain.Entities.Stocks.ValueObject;
 using Microsoft.EntityFrameworkCore;
@@ -10,10 +11,11 @@ using SharedKernel;
 namespace Infrastructure.StockData;
 
 /// <summary>
-/// 증권상품(ETF/ETN/ELW) 일별매매 수집 (KRX OpenAPI) — etf_bydd_trd + etn_bydd_trd + elw_bydd_trd. "KRX 증권상품(ETF/ETN/ELW) 수집".
-/// 기본 18:20 KST 실행(장 마감 15:30 이후 확정, 주식 수집 18:10 뒤), basDd=직전 영업일 3개 엔드포인트 전량 수집 →
-/// EtpDailyTrade upsert (UQ = EtpType+Code+TradeDate). 미반영(0건)이면 2시간 간격 2회 재시도.
-/// ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
+/// 증권상품(ETF/ETN/ELW) 일별매매 수집 (KRX OpenAPI) — etf_bydd_trd + etn_bydd_trd + elw_bydd_trd.
+/// 기본 18:20 KST 실행(장 마감 15:30 이후 확정, 주식 수집 18:10 뒤). 공용 KrxBackfill 로 최근 BackfillYears(기본 3)년치를
+/// endDate(직전 영업일)부터 과거로 훑으며 미적재일만 채운다. quota 보호를 위해 1회 실행당 BackfillMaxPerRun(기본 60)일까지만 fetch →
+/// 여러 날에 걸쳐 3년치를 메우고 이후엔 최신만 유지한다. 각 날짜는 3개 유형 엔드포인트를 모두 수집해 EtpDailyTrade upsert
+/// (UQ = EtpType+Code+TradeDate). ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
 /// </summary>
 internal sealed class KrxEtpSyncService(
     IServiceScopeFactory scopeFactory,
@@ -51,26 +53,57 @@ internal sealed class KrxEtpSyncService(
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
 
-        var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var endDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
+        var startDate = todayKst.AddYears(-years);
+
+        // 백필 창 전체의 휴장일을 한 번에 로드 (KrxBackfill 은 주말은 자동 제외, 휴장일만 필요)
+        var holidays = (await db.MarketHoliday.AsNoTracking()
+            .Where(c => c.Date >= startDate && c.Date <= endDate)
+            .Select(c => c.Date)
+            .ToListAsync(ct)).ToHashSet();
+
+        var maxPerRun = cfg.BackfillMaxPerRun > 0 ? cfg.BackfillMaxPerRun : 60;
+
+        var fetched = await KrxBackfill.RunAsync(
+            existsForDate: (day, token) => db.EtpDailyTrade.AsNoTracking().AnyAsync(c => c.TradeDate == day, token),
+            fetchAndUpsertForDate: (day, token) => FetchAndUpsertAsync(db, client, cfg.BaseUrl, cfg.ApiKey, day, token),
+            startDate: startDate,
+            endDate: endDate,
+            holidays: holidays,
+            maxPerRun: maxPerRun,
+            delayMs: 300,
+            ct: ct);
+
+        Logger.LogInformation("[{Job}] 완료 — 창=[{Start}~{End}], 이번 실행 fetch={Fetched}일 (maxPerRun={Max})",
+            JobName, startDate, endDate, fetched, maxPerRun);
+
+        // fetch 가 0 이어도(이미 최신까지 적재됨) 정상 완료 — 재시도 불필요
+        return true;
+    }
+
+    /// <summary>한 날짜에 대해 3개 유형 엔드포인트를 모두 수집하고 EtpDailyTrade upsert.</summary>
+    private async Task FetchAndUpsertAsync(IAppDbContext db, HttpClient client, string baseUrl, string apiKey, DateOnly day, CancellationToken ct)
+    {
         var rows = new List<KrxEtpParser.EtpRow>();
 
         foreach (var (type, path) in Endpoints)
         {
-            var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={targetDate:yyyyMMdd}";
-            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+            var url = $"{baseUrl.TrimEnd('/')}{path}?basDd={day:yyyyMMdd}";
+            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, apiKey, Logger, ct);
             var parsed = KrxEtpParser.ParseDaily(json, type);
 
-            Logger.LogInformation("[{Job}] {Type} basDd={TargetDate} rows={Rows}", JobName, type, targetDate, parsed.Count);
+            Logger.LogInformation("[{Job}] {Type} basDd={Day} rows={Rows}", JobName, type, day, parsed.Count);
             rows.AddRange(parsed);
         }
 
         if (rows.Count == 0)
         {
-            Logger.LogInformation("[{Job}] basDd={TargetDate} 증권상품 미반영 (0건)", JobName, targetDate);
-            return false;
+            Logger.LogInformation("[{Job}] basDd={Day} 증권상품 미반영 (0건)", JobName, day);
+            return;
         }
 
-        var existing = await db.EtpDailyTrade.Where(c => c.TradeDate == targetDate).ToListAsync(ct);
+        var existing = await db.EtpDailyTrade.Where(c => c.TradeDate == day).ToListAsync(ct);
         var existingByKey = existing.ToDictionary(c => (c.EtpType, c.Code));
         var inserted = 0;
         var updated = 0;
@@ -93,9 +126,7 @@ internal sealed class KrxEtpSyncService(
 
         await db.SaveChangesAsync(ct);
 
-        Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}",
-            JobName, targetDate, rows.Count, inserted, updated);
-
-        return true;
+        Logger.LogInformation("[{Job}] basDd={Day} 적재 — rows={Rows}, inserted={Inserted}, updated={Updated}",
+            JobName, day, rows.Count, inserted, updated);
     }
 }

+ 47 - 14
Infrastructure/StockData/KrxWarrantSyncService.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Data;
+using Application.Helpers;
 using Domain.Entities.Stocks;
 using Domain.Entities.Stocks.ValueObject;
 using Microsoft.EntityFrameworkCore;
@@ -11,9 +12,12 @@ namespace Infrastructure.StockData;
 
 /// <summary>
 /// 신주인수권증권/증서 일별매매 수집 (KRX OpenAPI) — sw_bydd_trd(증권) + sr_bydd_trd(증서).
-/// 기본 18:25 KST 실행(장 마감 15:30 이후 확정, 증권상품 수집 18:20 뒤), basDd=직전 영업일 2개 엔드포인트 전량 수집 →
-/// WarrantDailyTrade upsert (UQ = WarrantType+Code+TradeDate). 미반영(0건)이면 2시간 간격 2회 재시도.
-/// 증서(sr)는 유상증자 시에만 상장되는 단기 상품이라 대부분의 영업일에 0건이 정상 — 두 엔드포인트 합계로 0 판정한다.
+/// 기본 18:25 KST 실행(장 마감 15:30 이후 확정, 증권상품 수집 18:20 뒤). 공용 KrxBackfill 로 최근 BackfillYears(기본 3)년치를
+/// endDate(직전 영업일)부터 과거로 훑으며 미적재일만 채운다. quota 보호를 위해 1회 실행당 BackfillMaxPerRun(기본 60)일까지만 fetch →
+/// 여러 날에 걸쳐 3년치를 메우고 이후엔 최신만 유지한다. 각 날짜는 2개 유형 엔드포인트를 모두 수집해 WarrantDailyTrade upsert
+/// (UQ = WarrantType+Code+TradeDate).
+/// 증서(sr)는 유상증자 시에만 상장되는 단기 상품이라 대부분의 영업일에 0건이 정상 — 두 엔드포인트 합계로 0 판정하고,
+/// existsForDate 도 유형 구분 없이 그날 한 건이라도 있으면 적재됨으로 간주(증서 0건 날의 영구 재수집 방지).
 /// ApiKey 미설정 시 로그만 남기고 skip (다른 KRX 배치와 동일 정책).
 /// </summary>
 internal sealed class KrxWarrantSyncService(
@@ -51,26 +55,57 @@ internal sealed class KrxWarrantSyncService(
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
 
-        var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var endDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
+        var startDate = todayKst.AddYears(-years);
+
+        // 백필 창 전체의 휴장일을 한 번에 로드 (KrxBackfill 은 주말은 자동 제외, 휴장일만 필요)
+        var holidays = (await db.MarketHoliday.AsNoTracking()
+            .Where(c => c.Date >= startDate && c.Date <= endDate)
+            .Select(c => c.Date)
+            .ToListAsync(ct)).ToHashSet();
+
+        var maxPerRun = cfg.BackfillMaxPerRun > 0 ? cfg.BackfillMaxPerRun : 60;
+
+        var fetched = await KrxBackfill.RunAsync(
+            existsForDate: (day, token) => db.WarrantDailyTrade.AsNoTracking().AnyAsync(c => c.TradeDate == day, token),
+            fetchAndUpsertForDate: (day, token) => FetchAndUpsertAsync(db, client, cfg.BaseUrl, cfg.ApiKey, day, token),
+            startDate: startDate,
+            endDate: endDate,
+            holidays: holidays,
+            maxPerRun: maxPerRun,
+            delayMs: 300,
+            ct: ct);
+
+        Logger.LogInformation("[{Job}] 완료 — 창=[{Start}~{End}], 이번 실행 fetch={Fetched}일 (maxPerRun={Max})",
+            JobName, startDate, endDate, fetched, maxPerRun);
+
+        // fetch 가 0 이어도(이미 최신까지 적재됨) 정상 완료 — 재시도 불필요
+        return true;
+    }
+
+    /// <summary>한 날짜에 대해 2개 유형 엔드포인트를 모두 수집하고 WarrantDailyTrade upsert.</summary>
+    private async Task FetchAndUpsertAsync(IAppDbContext db, HttpClient client, string baseUrl, string apiKey, DateOnly day, CancellationToken ct)
+    {
         var rows = new List<KrxWarrantParser.WarrantRow>();
 
         foreach (var (type, path) in Endpoints)
         {
-            var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={targetDate:yyyyMMdd}";
-            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+            var url = $"{baseUrl.TrimEnd('/')}{path}?basDd={day:yyyyMMdd}";
+            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, apiKey, Logger, ct);
             var parsed = KrxWarrantParser.ParseDaily(json, type);
 
-            Logger.LogInformation("[{Job}] {Type} basDd={TargetDate} rows={Rows}", JobName, type, targetDate, parsed.Count);
+            Logger.LogInformation("[{Job}] {Type} basDd={Day} rows={Rows}", JobName, type, day, parsed.Count);
             rows.AddRange(parsed);
         }
 
         if (rows.Count == 0)
         {
-            Logger.LogInformation("[{Job}] basDd={TargetDate} 신주인수권 미반영 (0건)", JobName, targetDate);
-            return false;
+            Logger.LogInformation("[{Job}] basDd={Day} 신주인수권 미반영 (0건)", JobName, day);
+            return;
         }
 
-        var existing = await db.WarrantDailyTrade.Where(c => c.TradeDate == targetDate).ToListAsync(ct);
+        var existing = await db.WarrantDailyTrade.Where(c => c.TradeDate == day).ToListAsync(ct);
         var existingByKey = existing.ToDictionary(c => (c.WarrantType, c.Code));
         var inserted = 0;
         var updated = 0;
@@ -93,9 +128,7 @@ internal sealed class KrxWarrantSyncService(
 
         await db.SaveChangesAsync(ct);
 
-        Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}",
-            JobName, targetDate, rows.Count, inserted, updated);
-
-        return true;
+        Logger.LogInformation("[{Job}] basDd={Day} 적재 — rows={Rows}, inserted={Inserted}, updated={Updated}",
+            JobName, day, rows.Count, inserted, updated);
     }
 }