using Application.Abstractions.Data; using Application.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.StockData; /// /// SEIBro 파생결합증권 ELS/DLS 수집 (Wave 5) — 기본 08:00 KST(BondSync 07:40 다음 슬롯), `Seibro:DerivSync` 게이트. /// 전 단계가 파생(Deriv) 예산(Seibro:DerivBudget)을 공유한다. /// /// a) DerivIssuance: getDerivCombiIssuInfo 날짜 스윕(ISSU_DT) × 종류 SECN_KACD{4101,4102,4301,4302} 3년 → 발견(다수 종목/일). /// b) DerivMaster rolling: 대상 = DerivIssuance.Isin 미수집·stale → getDerivCombiIsinInfo(ISIN) → PK Isin upsert. /// c) DerivAsset / DerivAssetExercise / DerivRedemptionCondition rolling: DerivMaster 있는 ISIN → getAssetInfo/getAssetXrcInfo/getRedCondiInfo(ISIN) → upsert. /// d) DerivRedemption: getRedIsinInfo 날짜 스윕(RED_DT) × 종류{4101,4102,4301,4302} 3년 → 상환일×종류 window delete+insert. /// e) ElsUnredeemedScale: getBassetUnredScale 일별 13기초자산(STND_BASSET_CD, KSD02* 13종) × SECN_TPCD{41,43} = 최대 26콜/일 3년 스윕(KrxBackfill 영업일). /// /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip. /// ⚠️ getDerivCombiIssuInfo 요청 파라미터명 미해결 — 가이드에 SECN_TPCD:41 vs SECN_KACD:4101 혼재. 스펙 기준(SECN_KACD 종류코드)으로 구현했으나 /// **라이브 1콜로 파라미터명 확정 필요**(4101/4102/4301/4302). getRedIsinInfo 는 SECN_KACD(4101 등), getBassetUnredScale 는 SECN_TPCD(41/43 2자리) — API별 문서대로. /// ⚠️ 기초자산코드 2체계: getBassetUnredScale 입력 STND_BASSET_CD=KSD02*(13종) vs getAssetInfo 반환 KSD31* — 매핑 흡수 없이 원문 저장(엔티티 주석 참조). /// ⚠️ 파생결합 유니버스 큼(일 수십~수백 발행) → rolling 최근발행 우선 + resumable(여러 날 자동 완성). /// internal sealed class SeibroDerivSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, SeibroQuota quota, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { // 종목종류코드 (SECN_KACD) — 4101ELS 4102ELB 4301DLS 4302DLB. getDerivCombiIssuInfo·getRedIsinInfo 요청 파라미터. // ⚠️ getDerivCombiIssuInfo 는 파라미터명이 SECN_TPCD vs SECN_KACD 미해결 — 스펙(종류코드 4101…) 기준, 라이브 1콜로 확정 필요. private static readonly string[] DerivSecnKacds = ["4101", "4102", "4301", "4302"]; // 미상환규모 종목구분 (SECN_TPCD) — 41ELS 43DLS. getBassetUnredScale 요청 파라미터(2자리). private static readonly string[] UnredSecnTpcds = ["41", "43"]; // 주요 기초자산코드 (STND_BASSET_CD, 13종) — getBassetUnredScale 입력 KSD02* 체계 (파생결합증권.md §헤더 확정). private static readonly string[] Basset13 = [ "KSD090000101", // Brent "KSD090000201", // WTI "KSD100000101", // 금 "KSD100000201", // 은 "KSD010000101", // KOSPI200 "KSD020000101", // EURO STOXX 50 "KSD020000201", // HSCEI(항셍H) "KSD020000301", // S&P500 "KSD020000401", // FTSE100 "KSD020000501", // NIKKEI225 "KSD020000601", // DOW JONES "KSD020000901", // DAX "KSD020001901" // HSI ]; // 마커 job 키 (리뷰 결함 #1) — 정상 0행/조용한 날 재조회·starvation 해소 private const string IssuanceJobKey = "deriv-issuance"; // date-sweep (SweptDate, Discriminator=kacd) private const string RedemptionJobKey = "deriv-redemption"; // date-sweep (SweptDate, Discriminator=kacd) private const string UnredScaleJobKey = "deriv-unredscale"; // date-sweep (SweptDate, per-day) private const string MasterJobKey = "deriv-master"; // rolling (FetchMarker, key=ISIN) private const string AssetJobKey = "deriv-asset"; // rolling (FetchMarker, key=ISIN) private const string AssetXrcJobKey = "deriv-assetxrc"; // rolling (FetchMarker, key=ISIN) private const string RedCondiJobKey = "deriv-redcondi"; // rolling (FetchMarker, key=ISIN) protected override string JobName => "SeibroDerivSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.DerivSyncTime, new TimeOnly(8, 0)); protected override int MaxRetryCount => 2; protected override TimeSpan RetryDelay => TimeSpan.FromHours(2); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.Seibro; using var scope = scopeFactory.CreateScope(); var collectorSettings = scope.ServiceProvider.GetRequiredService(); if (!await collectorSettings.IsEnabledAsync(CollectorFlag.SeibroDeriv, ct)) { return true; } cfg = cfg with { ApiKey = await collectorSettings.GetKeyAsync(CollectorKey.Seibro, ct) ?? cfg.ApiKey }; if (string.IsNullOrWhiteSpace(cfg.ApiKey)) { Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName); return true; } var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(SeibroHttp.ClientName); var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3; var startDate = todayKst.AddYears(-years); // 단계별 독립 실행 — 한 단계의 SEIBro 오류가 다른 단계를 막지 않도록 개별 try/catch. // 반환 false = 예산 소진(전체 중단). 파싱/HTTP 오류 = 해당 단계만 skip(마커 미기록 → 다음 실행 재시도). async Task Phase(string name, Func> body) { try { return await body(ct); } catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException) { Logger.LogWarning(ex, "[{Job}] {Phase} SEIBro 오류 — 단계 skip(다음 실행 재시도)", JobName, name); return true; } } // a) 발행 → b) 마스터 → c) 기초자산/행사/상환조건 → d) 상환종목 → e) 미상환규모 if (!await Phase("파생발행", t => SweepIssuancesAsync(db, client, cfg, startDate, todayKst, t))) { return true; } if (!await Phase("파생마스터", t => RollMastersAsync(db, client, cfg, t))) { return true; } if (!await Phase("기초자산", t => RollAssetsAsync(db, client, cfg, t))) { return true; } if (!await Phase("기초자산행사", t => RollAssetExercisesAsync(db, client, cfg, t))) { return true; } if (!await Phase("상환조건", t => RollRedemptionConditionsAsync(db, client, cfg, t))) { return true; } if (!await Phase("상환종목", t => SweepRedemptionsAsync(db, client, cfg, startDate, todayKst, t))) { return true; } // e) 미상환규모 (void 반환) — 개별 try/catch await Phase("미상환규모", async t => { await SweepUnredeemedScaleAsync(db, client, cfg, startDate, todayKst, t); return true; }); return true; } /// /// a) 파생결합 발행 날짜 스윕(ISSU_DT, 최신→과거) × 종류 SECN_KACD{4101,4102,4301,4302} — 미적재 (발행일,종류)만. /// 발행일당 다수 종목 upsert(UQ Isin, SECN_KACD·ISSU_DT 스탬핑). 예산 소진 시 false. 발견분이 (b) 마스터 rolling 대상. /// ⚠️ 발행일은 영업일 성격이나 스펙 확정 불가라 전 캘린더일 스윕(보수적, 기존 SEIBro 배치 판단과 동일). /// ⚠️ SECN_KACD 파라미터명 미해결 — 라이브 1콜로 확정 필요(가이드 SECN_TPCD:41 vs SECN_KACD:4101 혼재). /// private async Task SweepIssuancesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var fetched = 0; var first = true; for (var day = today; day >= startDate; day = day.AddDays(-1)) { foreach (var kacd in DerivSecnKacds) { ct.ThrowIfCancellationRequested(); // 데이터 존재 OR 조회완료 마커(축=kacd) → 스킵 (발행 없는 (일,종류)도 재조회 회피, 리뷰 결함 #1) if (await db.DerivIssuance.AsNoTracking().AnyAsync(c => c.IssuDt == day && c.SecnKacd == kacd, ct) || await SeibroMarkers.IsSweptAsync(db, IssuanceJobKey, day, kacd, ct)) { continue; } if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { Logger.LogWarning("[{Job}] 파생 예산({Budget}) 소진 — getDerivCombiIssuInfo {Day}/{Kacd} 부터 중단", JobName, cfg.DerivBudget, day, kacd); return false; } if (!first && cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } first = false; // SECN_KACD 파라미터명은 라이브 1콜로 확정 필요 (스펙 기준 종류코드 사용) var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDerivCombiIssuInfo", [new("ISSU_DT", day.ToString("yyyyMMdd")), new("SECN_KACD", kacd)], Logger, ct); var rows = SeibroDerivCombiIssuParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDerivImport.UpsertDerivIssuancesAsync(db, kacd, day, rows, ct); await SeibroMarkers.MarkSweptAsync(db, IssuanceJobKey, day, kacd, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getDerivCombiIssuInfo {Day}/{Kacd} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, kacd, rows.Count, inserted, updated); fetched++; } } Logger.LogInformation("[{Job}] 파생 발행 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched); return true; } /// /// b) DerivMaster rolling — DerivIssuance.Isin 중 DerivMaster 미수집·stale 우선. /// getDerivCombiIsinInfo(ISIN) 1콜(Deriv) → PK Isin upsert(ISIN 요청값 스탬핑). 예산 소진 시 false. /// private async Task RollMastersAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var issuedIsins = await db.DerivIssuance.AsNoTracking().Select(c => c.Isin).Distinct().ToListAsync(ct); if (issuedIsins.Count == 0) { Logger.LogInformation("[{Job}] getDerivCombiIsinInfo 대상 없음 (DerivIssuance 비어 있음)", JobName); return true; } // 조회 시도(0행 포함) 마커 기준 정렬 — 정보 없는 ISIN 도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, MasterJobKey, ct); var targets = issuedIsins.Select(isin => (Key: isin, LastUpdatedAt: markers.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (isin, token) => { if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDerivCombiIsinInfo", [new("ISIN", isin)], Logger, token); var rows = SeibroDerivCombiIsinParser.Parse(SeibroXml.Parse(xml)); var ok = await SeibroDerivImport.UpsertDerivMasterAsync(db, isin, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, MasterJobKey, isin, token); Logger.LogInformation("[{Job}] getDerivCombiIsinInfo ISIN={Isin} rows={Rows} — upsert={Ok}", JobName, isin, rows.Count, ok); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 파생 마스터 rolling 완료 — 발견={Issued}, 처리={Processed} (maxPerRun={Max})", JobName, issuedIsins.Count, processed, maxPerRun); return !quotaExhausted; } /// /// c-1) 기초자산 rolling — DerivMaster 있는 ISIN 중 DerivAsset 미수집·stale 우선. /// getAssetInfo(ISIN) 1콜(Deriv) → UQ(Isin,AssetSeq) upsert(ISIN 요청값 스탬핑, StndBassetCd 원문). 예산 소진 시 false. /// stale 판정: 해당 ISIN 의 DerivAsset 최신 UpdatedAt(없으면 미수집 → 최우선). /// private async Task RollAssetsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var masterIsins = await db.DerivMaster.AsNoTracking().Select(c => c.Isin).ToListAsync(ct); if (masterIsins.Count == 0) { Logger.LogInformation("[{Job}] getAssetInfo 대상 없음 (DerivMaster 비어 있음)", JobName); return true; } // 조회 시도(0행 포함) 마커 기준 정렬 — 기초자산 없는 ISIN 도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, AssetJobKey, ct); var targets = masterIsins.Select(isin => (Key: isin, LastUpdatedAt: markers.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (isin, token) => { if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getAssetInfo", [new("ISIN", isin)], Logger, token); var rows = SeibroAssetParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDerivImport.UpsertDerivAssetsAsync(db, isin, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, AssetJobKey, isin, token); Logger.LogInformation("[{Job}] getAssetInfo ISIN={Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, isin, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 기초자산 rolling 완료 — 대상={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// /// c-2) 기초자산 행사정보 rolling — DerivMaster 있는 ISIN 중 DerivAssetExercise 미수집·stale 우선. /// getAssetXrcInfo(ISIN) 1콜(Deriv) → UQ(Isin,AssetSeq,XrcStdSeq) upsert(ISIN 요청값 스탬핑). 예산 소진 시 false. /// private async Task RollAssetExercisesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var masterIsins = await db.DerivMaster.AsNoTracking().Select(c => c.Isin).ToListAsync(ct); if (masterIsins.Count == 0) { return true; } // 조회 시도(0행 포함) 마커 기준 정렬 — 기초자산 행사 없는 ISIN 도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, AssetXrcJobKey, ct); var targets = masterIsins.Select(isin => (Key: isin, LastUpdatedAt: markers.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (isin, token) => { if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getAssetXrcInfo", [new("ISIN", isin)], Logger, token); var rows = SeibroAssetXrcParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDerivImport.UpsertDerivAssetExercisesAsync(db, isin, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, AssetXrcJobKey, isin, token); Logger.LogInformation("[{Job}] getAssetXrcInfo ISIN={Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, isin, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 기초자산 행사 rolling 완료 — 대상={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// /// c-3) 상환조건 rolling — DerivMaster 있는 ISIN 중 DerivRedemptionCondition 미수집·stale 우선. /// getRedCondiInfo(ISIN) 1콜(Deriv) → UQ(Isin,RedCondiTpcd,ValatNtimesSeq) upsert(ISIN 요청값 스탬핑, 1000자 텍스트). 예산 소진 시 false. /// private async Task RollRedemptionConditionsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var masterIsins = await db.DerivMaster.AsNoTracking().Select(c => c.Isin).ToListAsync(ct); if (masterIsins.Count == 0) { return true; } // 조회 시도(0행 포함) 마커 기준 정렬 — 상환조건 없는 ISIN 도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, RedCondiJobKey, ct); var targets = masterIsins.Select(isin => (Key: isin, LastUpdatedAt: markers.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (isin, token) => { if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getRedCondiInfo", [new("ISIN", isin)], Logger, token); var rows = SeibroRedCondiParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroDerivImport.UpsertDerivRedemptionConditionsAsync(db, isin, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, RedCondiJobKey, isin, token); Logger.LogInformation("[{Job}] getRedCondiInfo ISIN={Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, isin, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] 상환조건 rolling 완료 — 대상={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// /// d) 상환종목 날짜 스윕(RED_DT, 최신→과거) × 종류 SECN_KACD{4101,4102,4301,4302} — 미적재 (상환일,종류)만. /// 상환일×종류 window delete+insert(UQ Isin+RedDt+DerisecExerTpcd, RED_DT·SECN_KACD 스탬핑). 예산 소진 시 false. /// ⚠️ 상환일은 영업일 성격이나 스펙 확정 불가라 전 캘린더일 스윕(보수적). resumable: 해당 (상환일,종류) 스탬핑 존재 여부. /// private async Task SweepRedemptionsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var fetched = 0; var first = true; for (var day = today; day >= startDate; day = day.AddDays(-1)) { foreach (var kacd in DerivSecnKacds) { ct.ThrowIfCancellationRequested(); // 데이터 존재 OR 조회완료 마커(축=kacd) → 스킵 (상환 없는 (일,종류)도 재조회 회피, 리뷰 결함 #1) if (await db.DerivRedemption.AsNoTracking().AnyAsync(c => c.RedDt == day && c.SecnKacd == kacd, ct) || await SeibroMarkers.IsSweptAsync(db, RedemptionJobKey, day, kacd, ct)) { continue; } if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { Logger.LogWarning("[{Job}] 파생 예산({Budget}) 소진 — getRedIsinInfo {Day}/{Kacd} 부터 중단", JobName, cfg.DerivBudget, day, kacd); return false; } if (!first && cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } first = false; var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getRedIsinInfo", [new("RED_DT", day.ToString("yyyyMMdd")), new("SECN_KACD", kacd)], Logger, ct); var rows = SeibroRedIsinParser.Parse(SeibroXml.Parse(xml)); var (inserted, deleted) = await SeibroDerivImport.ReplaceRedemptionWindowAsync(db, kacd, day, day, rows, ct); await SeibroMarkers.MarkSweptAsync(db, RedemptionJobKey, day, kacd, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getRedIsinInfo {Day}/{Kacd} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, kacd, rows.Count, inserted, deleted); fetched++; } } Logger.LogInformation("[{Job}] 상환종목 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched); return true; } /// /// e) 기초자산별 미상환규모 스윕 — 영업일(KrxBackfill) × 13기초자산(KSD02*) × SECN_TPCD{41,43} = 최대 26콜/일. /// 미상환규모는 영업일 기준(장중 지표)이라 KrxBackfill.EnumerateBusinessDaysDescending 재사용, resumable(existsForDate 스킵). /// UQ(StdDt,StndBassetCd,SecnTpcd) upsert(3종 요청값 스탬핑). 예산 소진 시 즉시 종료. /// private async Task SweepUnredeemedScaleAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var holidays = await db.MarketHoliday.AsNoTracking().Select(c => c.Date).ToListAsync(ct); var holidaySet = holidays.ToHashSet(); var fetched = 0; var first = true; for (var day = today; day >= startDate; day = day.AddDays(-1)) { if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidaySet.Contains(day)) { continue; // 주말·휴장일 스킵 (미상환규모는 영업일 지표) } // 데이터 존재 OR 조회완료 마커 → 스킵 (미상환 0행 날도 재조회 회피, 리뷰 결함 #1). 하루 26콜(13×2) 전부 완료해야 마커. if (await db.ElsUnredeemedScale.AsNoTracking().AnyAsync(c => c.StdDt == day, ct) || await SeibroMarkers.IsSweptAsync(db, UnredScaleJobKey, day, null, ct)) { continue; } foreach (var basset in Basset13) { foreach (var tpcd in UnredSecnTpcds) { ct.ThrowIfCancellationRequested(); if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget)) { Logger.LogWarning("[{Job}] 파생 예산({Budget}) 소진 — getBassetUnredScale {Day} 부터 중단", JobName, cfg.DerivBudget, day); Logger.LogInformation("[{Job}] 미상환규모 스윕 부분완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched); return; // 하루가 부분 완료면 마커를 남기지 않는다 — 다음 실행에서 그 날부터 재개 } if (!first && cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } first = false; var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getBassetUnredScale", [new("STD_DT", day.ToString("yyyyMMdd")), new("STND_BASSET_CD", basset), new("SECN_TPCD", tpcd)], Logger, ct); var rows = SeibroBassetUnredScaleParser.Parse(SeibroXml.Parse(xml)); var ok = await SeibroDerivImport.UpsertUnredeemedScaleAsync(db, day, basset, tpcd, rows, ct); Logger.LogInformation("[{Job}] getBassetUnredScale {Day}/{Basset}/{Tpcd} rows={Rows} — upsert={Ok}", JobName, day, basset, tpcd, rows.Count, ok); fetched++; } } // 하루 26콜 전부 완료(0행 포함) → 조회완료 마커 기록 (다음 실행 재조회 회피) await SeibroMarkers.MarkSweptAsync(db, UnredScaleJobKey, day, null, ct); } Logger.LogInformation("[{Job}] 미상환규모 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched); } }