SeibroSupplySyncService.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. using Application.Abstractions.Data;
  2. using Application.Helpers;
  3. using Domain.Entities.Stocks.ValueObject;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Logging;
  7. using Microsoft.Extensions.Options;
  8. using SharedKernel;
  9. namespace Infrastructure.StockData;
  10. /// <summary>
  11. /// SEIBro 수급 이벤트 수집 (Wave 2 ★P1) — 기본 07:00 KST(IssuerSync 06:30·Dividend 06:40 다음 슬롯), `Seibro:SupplySync` 게이트.
  12. /// 다섯 단계가 하나의 주식(Stock) 카테고리 예산(SeibroQuota, Seibro:StockBudget)을 공유한다.
  13. ///
  14. /// a) SecuritiesLending (getSlbDealingByIsin, per-ISIN×일): 당일(최신 영업일) 전 상장종목 대차를 최우선 배분(~2,700콜).
  15. /// 잔여 예산으로만 3년 백필 = 시총상위 LendingBackfillTopN + 커뮤니티 활성종목(StockBoardStats.Posts>0) 대상 과거 날짜 rolling.
  16. /// b) LockupEvent 예수 (getSafeDpDutyDepoStatus BIZ_TPCD:1, 월 윈도우 스윕): BEGIN_DT~EXPRY_DT(≤1개월) → 예수일(SafedpDt) window delete+insert.
  17. /// ⚠️ 범위(BEGIN_DT+EXPRY_DT)와 BIZ_TPCD 둘 다 필수 — 단일일이거나 BIZ_TPCD 누락 시 SEIBro 가 003(서버오류) 반환(2026-07-10 라이브 확인). getStkListInfo 와 동일 월 윈도우 패턴.
  18. /// 반환(BIZ_TPCD:2, OCCR_SEQ=2)은 현재 미수집 — LockupEvent UQ 에 반환일이 없어 다중 부분반환이 UQ 충돌. 저장하려면 스키마 변경(키에 반환일) 필요(후속).
  19. /// c) StockIssuanceChange (getStkIncdecDetails, rolling per-회사번호): Stock.IssucoCustno non-null 중 미수집·stale 우선.
  20. /// d) ListingChangeEvent (getStkListInfo, 월 윈도우 스윕): ALT_BEGIN_DT~ALT_EXPRY_DT 최대 1개월/콜 3년 → 적용일 window delete+insert.
  21. /// e) UnlistedCirculation (getUnlistCirclInfo, 날짜 스윕): STD_DT 만 필수 = 그날 전체 비상장 유통 종목 반환(per-종목 불필요) → 전 캘린더일 스윕, StdDt 스탬핑.
  22. ///
  23. /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip.
  24. /// ⚠️ 콜 수가 큼(대차 전종목 ~2,700/일) — 당일 대차를 최우선 배분하고, 백필류(대차 과거·보호예수·증감·유통변경·비상장)는 잔여 예산으로만 진행한다.
  25. /// </summary>
  26. internal sealed class SeibroSupplySyncService(
  27. IServiceScopeFactory scopeFactory,
  28. IHttpClientFactory httpClientFactory,
  29. SeibroQuota quota,
  30. IOptions<AppSettings> settings,
  31. ILogger<SeibroSupplySyncService> logger
  32. ) : DailyScheduledService(logger)
  33. {
  34. private static readonly StockMarket[] LendingMarkets = [StockMarket.KOSPI, StockMarket.KOSDAQ, StockMarket.KONEX];
  35. // 마커 job 키 (리뷰 결함 #1) — 조용한 날/정상 0행 재조회·starvation 해소
  36. private const string LockupJobKey = "supply-lockup"; // month-window sweep, 예수(BIZ_TPCD:1)/SafedpDt 축 (SweptDate, Date=windowBegin)
  37. private const string ListingJobKey = "supply-listing"; // date-sweep, 월 윈도우 (SweptDate, Date=windowBegin)
  38. private const string UnlistedJobKey = "supply-unlisted"; // date-sweep (SweptDate)
  39. private const string IncdecJobKey = "stk-incdec"; // rolling (FetchMarker)
  40. protected override string JobName => "SeibroSupplySync";
  41. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.SupplySyncTime, new TimeOnly(7, 0));
  42. protected override int MaxRetryCount => 2;
  43. protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
  44. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  45. {
  46. var cfg = settings.Value.Seibro;
  47. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  48. {
  49. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  50. return true;
  51. }
  52. using var scope = scopeFactory.CreateScope();
  53. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  54. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  55. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
  56. var startDate = todayKst.AddYears(-years);
  57. // SEIBro 대차/수급은 T+1 — 당일(todayKst)은 아직 미반영이라 직전 영업일을 "최신 데이터일"로 사용
  58. var latestBizDay = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst, ct);
  59. // 단계별 독립 실행 — 한 단계의 SEIBro 오류(예: getSafeDpDutyDepoStatus 003)가 다른 단계를 막지 않도록 개별 try/catch.
  60. // 반환 false = 예산 소진(전체 중단). 파싱/HTTP 오류 = 해당 단계만 skip(마커 미기록 → 다음 실행 재시도).
  61. async Task<bool> Phase(string name, Func<CancellationToken, Task<bool>> body)
  62. {
  63. try
  64. {
  65. return await body(ct);
  66. }
  67. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  68. {
  69. Logger.LogWarning(ex, "[{Job}] {Phase} SEIBro 오류 — 단계 skip(다음 실행 재시도)", JobName, name);
  70. return true;
  71. }
  72. }
  73. // a) 당일 대차 최우선 → b) 보호예수 → c) 증감내역 → d) 유통변경 → e) 비상장 유통 → f) 대차 백필
  74. if (!await Phase("당일대차", t => SweepTodayLendingAsync(db, client, cfg, latestBizDay, t))) { return true; }
  75. if (!await Phase("보호예수", t => SweepLockupAsync(db, client, cfg, startDate, latestBizDay, t))) { return true; }
  76. if (!await Phase("증감내역", t => RollIssuanceChangesAsync(db, client, cfg, t))) { return true; }
  77. if (!await Phase("유통형태변경", t => SweepListingChangesAsync(db, client, cfg, startDate, latestBizDay, t))) { return true; }
  78. if (!await Phase("비상장유통", t => SweepUnlistedAsync(db, client, cfg, startDate, latestBizDay, t))) { return true; }
  79. // f) 대차 3년 백필 (void 반환) — 개별 try/catch
  80. await Phase("대차백필", async t => { await BackfillLendingAsync(db, client, cfg, startDate, latestBizDay, t); return true; });
  81. return true;
  82. }
  83. /// <summary>a) 당일 대차 — 전 상장종목(KOSPI/KOSDAQ/KONEX, ISIN 있음) 중 당일 미적재. 예산 소진 시 false.</summary>
  84. private async Task<bool> SweepTodayLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly day, CancellationToken ct)
  85. {
  86. var existing = await db.SecuritiesLending.AsNoTracking().Where(c => c.StdDt == day).Select(c => c.Isin).ToListAsync(ct);
  87. var have = existing.ToHashSet();
  88. var isins = await db.Stock.AsNoTracking()
  89. .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market))
  90. .OrderByDescending(c => c.MarketCap)
  91. .Select(c => c.ISIN!)
  92. .ToListAsync(ct);
  93. var targets = isins.Where(c => !have.Contains(c)).ToList();
  94. var quotaExhausted = false;
  95. var fetched = 0;
  96. foreach (var isin in targets)
  97. {
  98. ct.ThrowIfCancellationRequested();
  99. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  100. {
  101. quotaExhausted = true;
  102. break;
  103. }
  104. if (fetched > 0 && cfg.DelayMs > 0)
  105. {
  106. await Task.Delay(cfg.DelayMs, ct);
  107. }
  108. await FetchLendingAsync(db, client, cfg, isin, day, ct);
  109. fetched++;
  110. }
  111. Logger.LogInformation("[{Job}] 당일 대차 스윕 — 기준일={Day}, 대상={Targets}, 수집={Fetched}", JobName, day, targets.Count, fetched);
  112. return !quotaExhausted;
  113. }
  114. /// <summary>대차 1콜 수집 후 upsert (Isin+StdDt 스탬핑).</summary>
  115. private async Task FetchLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, string isin, DateOnly day, CancellationToken ct)
  116. {
  117. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSlbDealingByIsin", [new("ISIN", isin), new("STD_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  118. var rows = SeibroSlbDealingParser.Parse(SeibroXml.Parse(xml));
  119. await SeibroSupplyImport.UpsertLendingAsync(db, isin, day, rows, ct);
  120. }
  121. /// <summary>f) 대차 3년 백필 — 시총상위 TopN + 활성종목(StockBoardStats.Posts>0). 종목별 미적재 과거 영업일 recent-first, 잔여 예산.</summary>
  122. private async Task BackfillLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly latestBizDay, CancellationToken ct)
  123. {
  124. var remaining = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
  125. if (remaining <= 0)
  126. {
  127. Logger.LogInformation("[{Job}] 대차 백필 skip — 당일 수집으로 주식 예산({Budget}) 소진", JobName, cfg.StockBudget);
  128. return;
  129. }
  130. var topN = cfg.LendingBackfillTopN > 0 ? cfg.LendingBackfillTopN : 300;
  131. var topIsins = await db.Stock.AsNoTracking()
  132. .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market))
  133. .OrderByDescending(c => c.MarketCap)
  134. .Take(topN)
  135. .Select(c => c.ISIN!)
  136. .ToListAsync(ct);
  137. var activeCodes = await db.StockBoardStats.AsNoTracking().Where(c => c.Posts > 0).Select(c => c.StockCode).ToListAsync(ct);
  138. var activeIsins = await db.Stock.AsNoTracking()
  139. .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market) && activeCodes.Contains(c.Code))
  140. .Select(c => c.ISIN!)
  141. .ToListAsync(ct);
  142. var universe = topIsins.Concat(activeIsins).Distinct().ToList();
  143. if (universe.Count == 0)
  144. {
  145. return;
  146. }
  147. // 백필 창(주말/휴장 제외)의 휴장일 로드
  148. var holidays = (await db.MarketHoliday.AsNoTracking().Where(c => c.Date >= startDate && c.Date <= latestBizDay).Select(c => c.Date).ToListAsync(ct)).ToHashSet();
  149. var fetched = 0;
  150. // 종목을 순회하며 각 종목의 미적재 과거일을 recent-first 로 채운다. 예산 소진 시 즉시 종료.
  151. foreach (var isin in universe)
  152. {
  153. ct.ThrowIfCancellationRequested();
  154. var have = (await db.SecuritiesLending.AsNoTracking().Where(c => c.Isin == isin && c.StdDt >= startDate && c.StdDt <= latestBizDay).Select(c => c.StdDt).ToListAsync(ct)).ToHashSet();
  155. for (var day = latestBizDay; day >= startDate; day = day.AddDays(-1))
  156. {
  157. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day) || have.Contains(day))
  158. {
  159. continue;
  160. }
  161. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  162. {
  163. Logger.LogInformation("[{Job}] 대차 백필 종료 — 주식 예산 소진 (백필 수집={Fetched})", JobName, fetched);
  164. return;
  165. }
  166. if (cfg.DelayMs > 0)
  167. {
  168. await Task.Delay(cfg.DelayMs, ct);
  169. }
  170. await FetchLendingAsync(db, client, cfg, isin, day, ct);
  171. fetched++;
  172. }
  173. }
  174. Logger.LogInformation("[{Job}] 대차 백필 완료 — 대상종목={Universe}, 수집={Fetched}", JobName, universe.Count, fetched);
  175. }
  176. /// <summary>b) 보호예수 월 윈도우 스윕(최신→과거, 최대 1개월/콜) — 미적재 월만. BEGIN_DT~EXPRY_DT 범위 + BIZ_TPCD:1(예수) 필수(단일일·무BIZ 는 SEIBro 003). 예수일 window delete+insert. 예산 소진 시 false.</summary>
  177. private async Task<bool> SweepLockupAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  178. {
  179. var fetched = 0;
  180. var first = true;
  181. // 월 단위 창: [monthStart, monthEnd] 최대 1개월. 최신 달부터 과거로. getSafeDpDutyDepoStatus 는 범위+BIZ_TPCD 필수(단일일/무BIZ = 003).
  182. var windowEnd = today;
  183. while (windowEnd >= startDate)
  184. {
  185. ct.ThrowIfCancellationRequested();
  186. var monthStart = new DateOnly(windowEnd.Year, windowEnd.Month, 1);
  187. var windowBegin = monthStart < startDate ? startDate : monthStart;
  188. // 당월(현재 진행 중 달)은 아직 예수가 추가될 수 있어 매 실행 재수집(월중 신규 누락 방지, 리뷰 finding#2). 과거 달만 데이터 존재/마커로 스킵.
  189. var isCurrentMonth = monthStart.Year == today.Year && monthStart.Month == today.Month;
  190. var alreadyDone = !isCurrentMonth
  191. && (await db.LockupEvent.AsNoTracking().AnyAsync(c => c.SafedpDt >= windowBegin && c.SafedpDt <= windowEnd, ct)
  192. || await SeibroMarkers.IsSweptAsync(db, LockupJobKey, windowBegin, null, ct));
  193. if (!alreadyDone)
  194. {
  195. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  196. {
  197. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getSafeDpDutyDepoStatus {Begin}~{End} 부터 중단", JobName, cfg.StockBudget, windowBegin, windowEnd);
  198. return false;
  199. }
  200. if (!first && cfg.DelayMs > 0)
  201. {
  202. await Task.Delay(cfg.DelayMs, ct);
  203. }
  204. first = false;
  205. // getSafeDpDutyDepoStatus 는 정상 파라미터에도 간헐적 003(서버측)을 반환 — 해당 월만 skip(마커 미기록 → 다음 실행 재시도)하고
  206. // 나머지 과거 월은 계속 진행한다(한 달 transient 오류가 전체 백필을 막지 않도록, SeibroRollingSweep per-key 철학과 동일).
  207. try
  208. {
  209. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSafeDpDutyDepoStatus", [new("BEGIN_DT", windowBegin.ToString("yyyyMMdd")), new("EXPRY_DT", windowEnd.ToString("yyyyMMdd")), new("BIZ_TPCD", "1")], Logger, ct);
  210. var rows = SeibroLockupParser.Parse(SeibroXml.Parse(xml));
  211. var (inserted, deleted) = await SeibroSupplyImport.ReplaceLockupWindowAsync(db, windowBegin, windowEnd, rows, ct);
  212. await SeibroMarkers.MarkSweptAsync(db, LockupJobKey, windowBegin, null, ct); // 0행 포함 조회완료 기록
  213. Logger.LogInformation("[{Job}] getSafeDpDutyDepoStatus {Begin}~{End} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, windowBegin, windowEnd, rows.Count, inserted, deleted);
  214. fetched++;
  215. }
  216. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  217. {
  218. Logger.LogWarning(ex, "[{Job}] getSafeDpDutyDepoStatus {Begin}~{End} SEIBro 오류(간헐 003 등) — 이 달만 skip(다음 실행 재시도)", JobName, windowBegin, windowEnd);
  219. }
  220. }
  221. windowEnd = monthStart.AddDays(-1); // 이전 달 말일
  222. }
  223. Logger.LogInformation("[{Job}] 보호예수 월 윈도우 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}월", JobName, startDate, today, fetched);
  224. return true;
  225. }
  226. /// <summary>c) 증감내역 rolling — Stock.IssucoCustno non-null 중 미수집·stale 우선. SHOTN_ISIN(=Code)으로 조회. 예산 소진 시 false.</summary>
  227. private async Task<bool> RollIssuanceChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  228. {
  229. var stocks = await db.Stock.AsNoTracking().Where(c => c.IssucoCustno != null && c.ISIN != null).Select(c => new { c.Code, c.ISIN }).ToListAsync(ct);
  230. if (stocks.Count == 0)
  231. {
  232. Logger.LogInformation("[{Job}] getStkIncdecDetails 대상 없음 (Stock.IssucoCustno 미스탬핑)", JobName);
  233. return true;
  234. }
  235. // 조회 시도(0행 포함) 마커 기준 정렬(키=단축코드) — 증감 없는 종목도 재조회 회피(#1)
  236. var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, IncdecJobKey, ct);
  237. var targets = stocks.Select(s => (Key: s.Code, LastUpdatedAt: markers.TryGetValue(s.Code, out var last) ? (DateTime?)last : null)).ToList();
  238. var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
  239. var quotaExhausted = false;
  240. var processed = await SeibroRollingSweep.RunAsync(
  241. targets: targets,
  242. fetchAndUpsert: async (code, token) => {
  243. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  244. {
  245. quotaExhausted = true;
  246. return;
  247. }
  248. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStkIncdecDetails", [new("SHOTN_ISIN", code)], Logger, token);
  249. var rows = SeibroStkIncdecParser.Parse(SeibroXml.Parse(xml));
  250. var (inserted, updated) = await SeibroSupplyImport.UpsertIssuanceChangesAsync(db, rows, token);
  251. await SeibroMarkers.TouchFetchMarkerAsync(db, IncdecJobKey, code, token);
  252. Logger.LogInformation("[{Job}] getStkIncdecDetails SHOTN_ISIN={Code} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, code, rows.Count, inserted, updated);
  253. },
  254. maxPerRun: maxPerRun,
  255. delayMs: cfg.DelayMs,
  256. ct: ct);
  257. Logger.LogInformation("[{Job}] getStkIncdecDetails rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  258. return !quotaExhausted;
  259. }
  260. /// <summary>d) 유통형태변경 월 윈도우 스윕(최신→과거, 최대 1개월/콜) — 미적재 월만. 적용일 window delete+insert. 예산 소진 시 false.</summary>
  261. private async Task<bool> SweepListingChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  262. {
  263. var fetched = 0;
  264. var first = true;
  265. // 월 단위 창: [monthStart, monthEnd] 최대 1개월. 최신 달부터 과거로.
  266. var windowEnd = today;
  267. while (windowEnd >= startDate)
  268. {
  269. ct.ThrowIfCancellationRequested();
  270. var monthStart = new DateOnly(windowEnd.Year, windowEnd.Month, 1);
  271. var windowBegin = monthStart < startDate ? startDate : monthStart;
  272. // 월 윈도우 시작일을 마커 Date 로 사용 — 데이터 존재 OR 조회완료 마커 → 스킵 (변경 없는 달도 재조회 회피, 리뷰 결함 #1)
  273. if (!await db.ListingChangeEvent.AsNoTracking().AnyAsync(c => c.ApliDt >= windowBegin && c.ApliDt <= windowEnd, ct) && !await SeibroMarkers.IsSweptAsync(db, ListingJobKey, windowBegin, null, ct))
  274. {
  275. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  276. {
  277. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getStkListInfo {Begin}~{End} 부터 중단", JobName, cfg.StockBudget, windowBegin, windowEnd);
  278. return false;
  279. }
  280. if (!first && cfg.DelayMs > 0)
  281. {
  282. await Task.Delay(cfg.DelayMs, ct);
  283. }
  284. first = false;
  285. 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);
  286. var rows = SeibroStkListParser.Parse(SeibroXml.Parse(xml));
  287. var (inserted, deleted) = await SeibroSupplyImport.ReplaceListingChangeWindowAsync(db, windowBegin, windowEnd, rows, ct);
  288. await SeibroMarkers.MarkSweptAsync(db, ListingJobKey, windowBegin, null, ct); // 0행 포함 조회완료 기록
  289. Logger.LogInformation("[{Job}] getStkListInfo {Begin}~{End} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, windowBegin, windowEnd, rows.Count, inserted, deleted);
  290. fetched++;
  291. }
  292. windowEnd = monthStart.AddDays(-1); // 이전 달 말일
  293. }
  294. Logger.LogInformation("[{Job}] 유통형태변경 월 윈도우 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}월", JobName, startDate, today, fetched);
  295. return true;
  296. }
  297. /// <summary>e) 비상장 유통 전 캘린더일 스윕(최신→과거) — STD_DT 만으로 그날 전체 종목 반환. 미적재일만. 예산 소진 시 false.</summary>
  298. private async Task<bool> SweepUnlistedAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  299. {
  300. var fetched = 0;
  301. var first = true;
  302. for (var day = today; day >= startDate; day = day.AddDays(-1))
  303. {
  304. ct.ThrowIfCancellationRequested();
  305. // 데이터 존재 OR 조회완료 마커 → 스킵 (유통 없는 날도 재조회 회피, 리뷰 결함 #1)
  306. if (await db.UnlistedCirculation.AsNoTracking().AnyAsync(c => c.StdDt == day, ct) || await SeibroMarkers.IsSweptAsync(db, UnlistedJobKey, day, null, ct))
  307. {
  308. continue;
  309. }
  310. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  311. {
  312. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getUnlistCirclInfo {Day} 부터 중단", JobName, cfg.StockBudget, day);
  313. return false;
  314. }
  315. if (!first && cfg.DelayMs > 0)
  316. {
  317. await Task.Delay(cfg.DelayMs, ct);
  318. }
  319. first = false;
  320. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getUnlistCirclInfo", [new("STD_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  321. var rows = SeibroUnlistCirclParser.Parse(SeibroXml.Parse(xml));
  322. var (inserted, updated) = await SeibroSupplyImport.UpsertUnlistedAsync(db, day, rows, ct);
  323. await SeibroMarkers.MarkSweptAsync(db, UnlistedJobKey, day, null, ct); // 0행 포함 조회완료 기록
  324. Logger.LogInformation("[{Job}] getUnlistCirclInfo {Day} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, rows.Count, inserted, updated);
  325. fetched++;
  326. }
  327. Logger.LogInformation("[{Job}] 비상장 유통 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched);
  328. return true;
  329. }
  330. }