using Application.Abstractions.Data; using Application.Helpers; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.StockData; /// /// SEIBro 수급 이벤트 수집 (Wave 2 ★P1) — 기본 07:00 KST(IssuerSync 06:30·Dividend 06:40 다음 슬롯), `Seibro:SupplySync` 게이트. /// 다섯 단계가 하나의 주식(Stock) 카테고리 예산(SeibroQuota, Seibro:StockBudget)을 공유한다. /// /// a) SecuritiesLending (getSlbDealingByIsin, per-ISIN×일): 당일(최신 영업일) 전 상장종목 대차를 최우선 배분(~2,700콜). /// 잔여 예산으로만 3년 백필 = 시총상위 LendingBackfillTopN + 커뮤니티 활성종목(StockBoardStats.Posts>0) 대상 과거 날짜 rolling. /// b) LockupEvent (getSafeDpDutyDepoStatus, 날짜 스윕): BEGIN_DT=day(EXPRY 생략) 3년 → 예수일 window delete+insert. /// ⚠️ KrxBackfill(영업일) 대신 전 캘린더일 스윕 — 보호예수일(SAFEDP_DT)이 권리일정성이라 주말/휴장일에도 놓일 수 있어(SeibroDividendSyncService 동일 판단), 영업일만 훑으면 누락. /// c) StockIssuanceChange (getStkIncdecDetails, rolling per-회사번호): Stock.IssucoCustno non-null 중 미수집·stale 우선. /// d) ListingChangeEvent (getStkListInfo, 월 윈도우 스윕): ALT_BEGIN_DT~ALT_EXPRY_DT 최대 1개월/콜 3년 → 적용일 window delete+insert. /// e) UnlistedCirculation (getUnlistCirclInfo, 날짜 스윕): STD_DT 만 필수 = 그날 전체 비상장 유통 종목 반환(per-종목 불필요) → 전 캘린더일 스윕, StdDt 스탬핑. /// /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip. /// ⚠️ 콜 수가 큼(대차 전종목 ~2,700/일) — 당일 대차를 최우선 배분하고, 백필류(대차 과거·보호예수·증감·유통변경·비상장)는 잔여 예산으로만 진행한다. /// internal sealed class SeibroSupplySyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, SeibroQuota quota, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { private static readonly StockMarket[] LendingMarkets = [StockMarket.KOSPI, StockMarket.KOSDAQ, StockMarket.KONEX]; // 마커 job 키 (리뷰 결함 #1) — 조용한 날/정상 0행 재조회·starvation 해소 private const string LockupJobKey = "supply-lockup"; // date-sweep (SweptDate) private const string ListingJobKey = "supply-listing"; // date-sweep, 월 윈도우 (SweptDate, Date=windowBegin) private const string UnlistedJobKey = "supply-unlisted"; // date-sweep (SweptDate) private const string IncdecJobKey = "stk-incdec"; // rolling (FetchMarker) protected override string JobName => "SeibroSupplySync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.SupplySyncTime, new TimeOnly(7, 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; if (string.IsNullOrWhiteSpace(cfg.ApiKey)) { Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName); return true; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(SeibroHttp.ClientName); var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3; var startDate = todayKst.AddYears(-years); var latestBizDay = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct); try { // a) 당일 대차 최우선 — 전 상장종목 (남은 예산 전부 쓸 수 있음) if (!await SweepTodayLendingAsync(db, client, cfg, latestBizDay, ct)) { return true; } // b) 보호예수 날짜 스윕 (전 캘린더일 window delete+insert) if (!await SweepLockupAsync(db, client, cfg, startDate, latestBizDay, ct)) { return true; } // c) 증감내역 rolling per-회사번호 if (!await RollIssuanceChangesAsync(db, client, cfg, ct)) { return true; } // d) 유통형태변경 월 윈도우 스윕 if (!await SweepListingChangesAsync(db, client, cfg, startDate, latestBizDay, ct)) { return true; } // e) 비상장 유통 날짜 스윕 if (!await SweepUnlistedAsync(db, client, cfg, startDate, latestBizDay, ct)) { return true; } // f) 대차 3년 백필 — 잔여 예산으로만 (시총상위 + 활성종목, 과거 날짜 rolling) await BackfillLendingAsync(db, client, cfg, startDate, latestBizDay, ct); return true; } catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException) { Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount); return false; } } /// a) 당일 대차 — 전 상장종목(KOSPI/KOSDAQ/KONEX, ISIN 있음) 중 당일 미적재. 예산 소진 시 false. private async Task SweepTodayLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly day, CancellationToken ct) { var existing = await db.SecuritiesLending.AsNoTracking().Where(c => c.StdDt == day).Select(c => c.Isin).ToListAsync(ct); var have = existing.ToHashSet(); var isins = await db.Stock.AsNoTracking() .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market)) .OrderByDescending(c => c.MarketCap) .Select(c => c.ISIN!) .ToListAsync(ct); var targets = isins.Where(c => !have.Contains(c)).ToList(); var quotaExhausted = false; var fetched = 0; foreach (var isin in targets) { ct.ThrowIfCancellationRequested(); if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { quotaExhausted = true; break; } if (fetched > 0 && cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } await FetchLendingAsync(db, client, cfg, isin, day, ct); fetched++; } Logger.LogInformation("[{Job}] 당일 대차 스윕 — 기준일={Day}, 대상={Targets}, 수집={Fetched}", JobName, day, targets.Count, fetched); return !quotaExhausted; } /// 대차 1콜 수집 후 upsert (Isin+StdDt 스탬핑). private async Task FetchLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, string isin, DateOnly day, CancellationToken ct) { var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSlbDealingByIsin", [new("ISIN", isin), new("STD_DT", day.ToString("yyyyMMdd"))], Logger, ct); var rows = SeibroSlbDealingParser.Parse(SeibroXml.Parse(xml)); await SeibroSupplyImport.UpsertLendingAsync(db, isin, day, rows, ct); } /// f) 대차 3년 백필 — 시총상위 TopN + 활성종목(StockBoardStats.Posts>0). 종목별 미적재 과거 영업일 recent-first, 잔여 예산. private async Task BackfillLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly latestBizDay, CancellationToken ct) { var remaining = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget); if (remaining <= 0) { Logger.LogInformation("[{Job}] 대차 백필 skip — 당일 수집으로 주식 예산({Budget}) 소진", JobName, cfg.StockBudget); return; } var topN = cfg.LendingBackfillTopN > 0 ? cfg.LendingBackfillTopN : 300; var topIsins = await db.Stock.AsNoTracking() .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market)) .OrderByDescending(c => c.MarketCap) .Take(topN) .Select(c => c.ISIN!) .ToListAsync(ct); var activeCodes = await db.StockBoardStats.AsNoTracking().Where(c => c.Posts > 0).Select(c => c.StockCode).ToListAsync(ct); var activeIsins = await db.Stock.AsNoTracking() .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market) && activeCodes.Contains(c.Code)) .Select(c => c.ISIN!) .ToListAsync(ct); var universe = topIsins.Concat(activeIsins).Distinct().ToList(); if (universe.Count == 0) { return; } // 백필 창(주말/휴장 제외)의 휴장일 로드 var holidays = (await db.MarketHoliday.AsNoTracking().Where(c => c.Date >= startDate && c.Date <= latestBizDay).Select(c => c.Date).ToListAsync(ct)).ToHashSet(); var fetched = 0; // 종목을 순회하며 각 종목의 미적재 과거일을 recent-first 로 채운다. 예산 소진 시 즉시 종료. foreach (var isin in universe) { ct.ThrowIfCancellationRequested(); var have = (await db.SecuritiesLending.AsNoTracking().Where(c => c.Isin == isin && c.StdDt >= startDate && c.StdDt <= latestBizDay).Select(c => c.StdDt).ToListAsync(ct)).ToHashSet(); for (var day = latestBizDay; day >= startDate; day = day.AddDays(-1)) { if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day) || have.Contains(day)) { continue; } if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { Logger.LogInformation("[{Job}] 대차 백필 종료 — 주식 예산 소진 (백필 수집={Fetched})", JobName, fetched); return; } if (cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } await FetchLendingAsync(db, client, cfg, isin, day, ct); fetched++; } } Logger.LogInformation("[{Job}] 대차 백필 완료 — 대상종목={Universe}, 수집={Fetched}", JobName, universe.Count, fetched); } /// b) 보호예수 전 캘린더일 스윕(최신→과거) — 미적재일만. 예수일 window delete+insert. 예산 소진 시 false. private async Task SweepLockupAsync(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)) { ct.ThrowIfCancellationRequested(); // 데이터 존재 OR 조회완료 마커 → 스킵 (예수 없는 날도 재조회 회피, 리뷰 결함 #1) if (await db.LockupEvent.AsNoTracking().AnyAsync(c => c.SafedpDt == day, ct) || await SeibroMarkers.IsSweptAsync(db, LockupJobKey, day, null, ct)) { continue; } if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getSafeDpDutyDepoStatus {Day} 부터 중단", JobName, cfg.StockBudget, day); 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, "getSafeDpDutyDepoStatus", [new("BEGIN_DT", day.ToString("yyyyMMdd"))], Logger, ct); var rows = SeibroLockupParser.Parse(SeibroXml.Parse(xml)); var (inserted, deleted) = await SeibroSupplyImport.ReplaceLockupWindowAsync(db, day, day, rows, ct); await SeibroMarkers.MarkSweptAsync(db, LockupJobKey, day, null, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getSafeDpDutyDepoStatus {Day} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, rows.Count, inserted, deleted); fetched++; } Logger.LogInformation("[{Job}] 보호예수 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched); return true; } /// c) 증감내역 rolling — Stock.IssucoCustno non-null 중 미수집·stale 우선. SHOTN_ISIN(=Code)으로 조회. 예산 소진 시 false. private async Task RollIssuanceChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct) { var stocks = await db.Stock.AsNoTracking().Where(c => c.IssucoCustno != null && c.ISIN != null).Select(c => new { c.Code, c.ISIN }).ToListAsync(ct); if (stocks.Count == 0) { Logger.LogInformation("[{Job}] getStkIncdecDetails 대상 없음 (Stock.IssucoCustno 미스탬핑)", JobName); return true; } // 조회 시도(0행 포함) 마커 기준 정렬(키=단축코드) — 증감 없는 종목도 재조회 회피(#1) var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, IncdecJobKey, ct); var targets = stocks.Select(s => (Key: s.Code, LastUpdatedAt: markers.TryGetValue(s.Code, out var last) ? (DateTime?)last : null)).ToList(); var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget); var quotaExhausted = false; var processed = await SeibroRollingSweep.RunAsync( targets: targets, fetchAndUpsert: async (code, token) => { if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { quotaExhausted = true; return; } var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStkIncdecDetails", [new("SHOTN_ISIN", code)], Logger, token); var rows = SeibroStkIncdecParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroSupplyImport.UpsertIssuanceChangesAsync(db, rows, token); await SeibroMarkers.TouchFetchMarkerAsync(db, IncdecJobKey, code, token); Logger.LogInformation("[{Job}] getStkIncdecDetails SHOTN_ISIN={Code} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, code, rows.Count, inserted, updated); }, maxPerRun: maxPerRun, delayMs: cfg.DelayMs, ct: ct); Logger.LogInformation("[{Job}] getStkIncdecDetails rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun); return !quotaExhausted; } /// d) 유통형태변경 월 윈도우 스윕(최신→과거, 최대 1개월/콜) — 미적재 월만. 적용일 window delete+insert. 예산 소진 시 false. private async Task SweepListingChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct) { var fetched = 0; var first = true; // 월 단위 창: [monthStart, monthEnd] 최대 1개월. 최신 달부터 과거로. var windowEnd = today; while (windowEnd >= startDate) { ct.ThrowIfCancellationRequested(); var monthStart = new DateOnly(windowEnd.Year, windowEnd.Month, 1); var windowBegin = monthStart < startDate ? startDate : monthStart; // 월 윈도우 시작일을 마커 Date 로 사용 — 데이터 존재 OR 조회완료 마커 → 스킵 (변경 없는 달도 재조회 회피, 리뷰 결함 #1) if (!await db.ListingChangeEvent.AsNoTracking().AnyAsync(c => c.ApliDt >= windowBegin && c.ApliDt <= windowEnd, ct) && !await SeibroMarkers.IsSweptAsync(db, ListingJobKey, windowBegin, null, ct)) { if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getStkListInfo {Begin}~{End} 부터 중단", JobName, cfg.StockBudget, windowBegin, windowEnd); 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, "getStkListInfo", [new("ALT_BEGIN_DT", windowBegin.ToString("yyyyMMdd")), new("ALT_EXPRY_DT", windowEnd.ToString("yyyyMMdd"))], Logger, ct); var rows = SeibroStkListParser.Parse(SeibroXml.Parse(xml)); var (inserted, deleted) = await SeibroSupplyImport.ReplaceListingChangeWindowAsync(db, windowBegin, windowEnd, rows, ct); await SeibroMarkers.MarkSweptAsync(db, ListingJobKey, windowBegin, null, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getStkListInfo {Begin}~{End} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, windowBegin, windowEnd, rows.Count, inserted, deleted); fetched++; } windowEnd = monthStart.AddDays(-1); // 이전 달 말일 } Logger.LogInformation("[{Job}] 유통형태변경 월 윈도우 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}월", JobName, startDate, today, fetched); return true; } /// e) 비상장 유통 전 캘린더일 스윕(최신→과거) — STD_DT 만으로 그날 전체 종목 반환. 미적재일만. 예산 소진 시 false. private async Task SweepUnlistedAsync(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)) { ct.ThrowIfCancellationRequested(); // 데이터 존재 OR 조회완료 마커 → 스킵 (유통 없는 날도 재조회 회피, 리뷰 결함 #1) if (await db.UnlistedCirculation.AsNoTracking().AnyAsync(c => c.StdDt == day, ct) || await SeibroMarkers.IsSweptAsync(db, UnlistedJobKey, day, null, ct)) { continue; } if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget)) { Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getUnlistCirclInfo {Day} 부터 중단", JobName, cfg.StockBudget, day); 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, "getUnlistCirclInfo", [new("STD_DT", day.ToString("yyyyMMdd"))], Logger, ct); var rows = SeibroUnlistCirclParser.Parse(SeibroXml.Parse(xml)); var (inserted, updated) = await SeibroSupplyImport.UpsertUnlistedAsync(db, day, rows, ct); await SeibroMarkers.MarkSweptAsync(db, UnlistedJobKey, day, null, ct); // 0행 포함 조회완료 기록 Logger.LogInformation("[{Job}] getUnlistCirclInfo {Day} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, rows.Count, inserted, updated); fetched++; } Logger.LogInformation("[{Job}] 비상장 유통 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched); return true; } }