SeibroSupplySyncService.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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, 날짜 스윕): BEGIN_DT=day(EXPRY 생략) 3년 → 예수일 window delete+insert.
  17. /// ⚠️ KrxBackfill(영업일) 대신 전 캘린더일 스윕 — 보호예수일(SAFEDP_DT)이 권리일정성이라 주말/휴장일에도 놓일 수 있어(SeibroDividendSyncService 동일 판단), 영업일만 훑으면 누락.
  18. /// c) StockIssuanceChange (getStkIncdecDetails, rolling per-회사번호): Stock.IssucoCustno non-null 중 미수집·stale 우선.
  19. /// d) ListingChangeEvent (getStkListInfo, 월 윈도우 스윕): ALT_BEGIN_DT~ALT_EXPRY_DT 최대 1개월/콜 3년 → 적용일 window delete+insert.
  20. /// e) UnlistedCirculation (getUnlistCirclInfo, 날짜 스윕): STD_DT 만 필수 = 그날 전체 비상장 유통 종목 반환(per-종목 불필요) → 전 캘린더일 스윕, StdDt 스탬핑.
  21. ///
  22. /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip.
  23. /// ⚠️ 콜 수가 큼(대차 전종목 ~2,700/일) — 당일 대차를 최우선 배분하고, 백필류(대차 과거·보호예수·증감·유통변경·비상장)는 잔여 예산으로만 진행한다.
  24. /// </summary>
  25. internal sealed class SeibroSupplySyncService(
  26. IServiceScopeFactory scopeFactory,
  27. IHttpClientFactory httpClientFactory,
  28. SeibroQuota quota,
  29. IOptions<AppSettings> settings,
  30. ILogger<SeibroSupplySyncService> logger
  31. ) : DailyScheduledService(logger)
  32. {
  33. private static readonly StockMarket[] LendingMarkets = [StockMarket.KOSPI, StockMarket.KOSDAQ, StockMarket.KONEX];
  34. protected override string JobName => "SeibroSupplySync";
  35. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.SupplySyncTime, new TimeOnly(7, 0));
  36. protected override int MaxRetryCount => 2;
  37. protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
  38. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  39. {
  40. var cfg = settings.Value.Seibro;
  41. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  42. {
  43. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  44. return true;
  45. }
  46. using var scope = scopeFactory.CreateScope();
  47. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  48. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  49. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
  50. var startDate = todayKst.AddYears(-years);
  51. var latestBizDay = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
  52. try
  53. {
  54. // a) 당일 대차 최우선 — 전 상장종목 (남은 예산 전부 쓸 수 있음)
  55. if (!await SweepTodayLendingAsync(db, client, cfg, latestBizDay, ct))
  56. {
  57. return true;
  58. }
  59. // b) 보호예수 날짜 스윕 (전 캘린더일 window delete+insert)
  60. if (!await SweepLockupAsync(db, client, cfg, startDate, latestBizDay, ct))
  61. {
  62. return true;
  63. }
  64. // c) 증감내역 rolling per-회사번호
  65. if (!await RollIssuanceChangesAsync(db, client, cfg, ct))
  66. {
  67. return true;
  68. }
  69. // d) 유통형태변경 월 윈도우 스윕
  70. if (!await SweepListingChangesAsync(db, client, cfg, startDate, latestBizDay, ct))
  71. {
  72. return true;
  73. }
  74. // e) 비상장 유통 날짜 스윕
  75. if (!await SweepUnlistedAsync(db, client, cfg, startDate, latestBizDay, ct))
  76. {
  77. return true;
  78. }
  79. // f) 대차 3년 백필 — 잔여 예산으로만 (시총상위 + 활성종목, 과거 날짜 rolling)
  80. await BackfillLendingAsync(db, client, cfg, startDate, latestBizDay, ct);
  81. return true;
  82. }
  83. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  84. {
  85. Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
  86. return false;
  87. }
  88. }
  89. /// <summary>a) 당일 대차 — 전 상장종목(KOSPI/KOSDAQ/KONEX, ISIN 있음) 중 당일 미적재. 예산 소진 시 false.</summary>
  90. private async Task<bool> SweepTodayLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly day, CancellationToken ct)
  91. {
  92. var existing = await db.SecuritiesLending.AsNoTracking().Where(c => c.StdDt == day).Select(c => c.Isin).ToListAsync(ct);
  93. var have = existing.ToHashSet();
  94. var isins = await db.Stock.AsNoTracking()
  95. .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market))
  96. .OrderByDescending(c => c.MarketCap)
  97. .Select(c => c.ISIN!)
  98. .ToListAsync(ct);
  99. var targets = isins.Where(c => !have.Contains(c)).ToList();
  100. var quotaExhausted = false;
  101. var fetched = 0;
  102. foreach (var isin in targets)
  103. {
  104. ct.ThrowIfCancellationRequested();
  105. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  106. {
  107. quotaExhausted = true;
  108. break;
  109. }
  110. if (fetched > 0 && cfg.DelayMs > 0)
  111. {
  112. await Task.Delay(cfg.DelayMs, ct);
  113. }
  114. await FetchLendingAsync(db, client, cfg, isin, day, ct);
  115. fetched++;
  116. }
  117. Logger.LogInformation("[{Job}] 당일 대차 스윕 — 기준일={Day}, 대상={Targets}, 수집={Fetched}", JobName, day, targets.Count, fetched);
  118. return !quotaExhausted;
  119. }
  120. /// <summary>대차 1콜 수집 후 upsert (Isin+StdDt 스탬핑).</summary>
  121. private async Task FetchLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, string isin, DateOnly day, CancellationToken ct)
  122. {
  123. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSlbDealingByIsin", [new("ISIN", isin), new("STD_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  124. var rows = SeibroSlbDealingParser.Parse(SeibroXml.Parse(xml));
  125. await SeibroSupplyImport.UpsertLendingAsync(db, isin, day, rows, ct);
  126. }
  127. /// <summary>f) 대차 3년 백필 — 시총상위 TopN + 활성종목(StockBoardStats.Posts>0). 종목별 미적재 과거 영업일 recent-first, 잔여 예산.</summary>
  128. private async Task BackfillLendingAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly latestBizDay, CancellationToken ct)
  129. {
  130. var remaining = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
  131. if (remaining <= 0)
  132. {
  133. Logger.LogInformation("[{Job}] 대차 백필 skip — 당일 수집으로 주식 예산({Budget}) 소진", JobName, cfg.StockBudget);
  134. return;
  135. }
  136. var topN = cfg.LendingBackfillTopN > 0 ? cfg.LendingBackfillTopN : 300;
  137. var topIsins = await db.Stock.AsNoTracking()
  138. .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market))
  139. .OrderByDescending(c => c.MarketCap)
  140. .Take(topN)
  141. .Select(c => c.ISIN!)
  142. .ToListAsync(ct);
  143. var activeCodes = await db.StockBoardStats.AsNoTracking().Where(c => c.Posts > 0).Select(c => c.StockCode).ToListAsync(ct);
  144. var activeIsins = await db.Stock.AsNoTracking()
  145. .Where(c => c.IsActive && c.ISIN != null && LendingMarkets.Contains(c.Market) && activeCodes.Contains(c.Code))
  146. .Select(c => c.ISIN!)
  147. .ToListAsync(ct);
  148. var universe = topIsins.Concat(activeIsins).Distinct().ToList();
  149. if (universe.Count == 0)
  150. {
  151. return;
  152. }
  153. // 백필 창(주말/휴장 제외)의 휴장일 로드
  154. var holidays = (await db.MarketHoliday.AsNoTracking().Where(c => c.Date >= startDate && c.Date <= latestBizDay).Select(c => c.Date).ToListAsync(ct)).ToHashSet();
  155. var fetched = 0;
  156. // 종목을 순회하며 각 종목의 미적재 과거일을 recent-first 로 채운다. 예산 소진 시 즉시 종료.
  157. foreach (var isin in universe)
  158. {
  159. ct.ThrowIfCancellationRequested();
  160. var have = (await db.SecuritiesLending.AsNoTracking().Where(c => c.Isin == isin && c.StdDt >= startDate && c.StdDt <= latestBizDay).Select(c => c.StdDt).ToListAsync(ct)).ToHashSet();
  161. for (var day = latestBizDay; day >= startDate; day = day.AddDays(-1))
  162. {
  163. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day) || have.Contains(day))
  164. {
  165. continue;
  166. }
  167. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  168. {
  169. Logger.LogInformation("[{Job}] 대차 백필 종료 — 주식 예산 소진 (백필 수집={Fetched})", JobName, fetched);
  170. return;
  171. }
  172. if (cfg.DelayMs > 0)
  173. {
  174. await Task.Delay(cfg.DelayMs, ct);
  175. }
  176. await FetchLendingAsync(db, client, cfg, isin, day, ct);
  177. fetched++;
  178. }
  179. }
  180. Logger.LogInformation("[{Job}] 대차 백필 완료 — 대상종목={Universe}, 수집={Fetched}", JobName, universe.Count, fetched);
  181. }
  182. /// <summary>b) 보호예수 전 캘린더일 스윕(최신→과거) — 미적재일만. 예수일 window delete+insert. 예산 소진 시 false.</summary>
  183. private async Task<bool> SweepLockupAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  184. {
  185. var fetched = 0;
  186. var first = true;
  187. for (var day = today; day >= startDate; day = day.AddDays(-1))
  188. {
  189. ct.ThrowIfCancellationRequested();
  190. if (await db.LockupEvent.AsNoTracking().AnyAsync(c => c.SafedpDt == day, ct))
  191. {
  192. continue; // 이미 적재된 예수일 — resumable
  193. }
  194. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  195. {
  196. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getSafeDpDutyDepoStatus {Day} 부터 중단", JobName, cfg.StockBudget, day);
  197. return false;
  198. }
  199. if (!first && cfg.DelayMs > 0)
  200. {
  201. await Task.Delay(cfg.DelayMs, ct);
  202. }
  203. first = false;
  204. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSafeDpDutyDepoStatus", [new("BEGIN_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  205. var rows = SeibroLockupParser.Parse(SeibroXml.Parse(xml));
  206. var (inserted, deleted) = await SeibroSupplyImport.ReplaceLockupWindowAsync(db, day, day, rows, ct);
  207. Logger.LogInformation("[{Job}] getSafeDpDutyDepoStatus {Day} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, rows.Count, inserted, deleted);
  208. fetched++;
  209. }
  210. Logger.LogInformation("[{Job}] 보호예수 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched);
  211. return true;
  212. }
  213. /// <summary>c) 증감내역 rolling — Stock.IssucoCustno non-null 중 미수집·stale 우선. SHOTN_ISIN(=Code)으로 조회. 예산 소진 시 false.</summary>
  214. private async Task<bool> RollIssuanceChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  215. {
  216. var stocks = await db.Stock.AsNoTracking().Where(c => c.IssucoCustno != null && c.ISIN != null).Select(c => new { c.Code, c.ISIN }).ToListAsync(ct);
  217. if (stocks.Count == 0)
  218. {
  219. Logger.LogInformation("[{Job}] getStkIncdecDetails 대상 없음 (Stock.IssucoCustno 미스탬핑)", JobName);
  220. return true;
  221. }
  222. var lastByIsin = await db.StockIssuanceChange.AsNoTracking().GroupBy(c => c.Isin).Select(g => new { Isin = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.Isin, c => c.Last, ct);
  223. var targets = stocks.Select(s => (Key: s.Code, LastUpdatedAt: lastByIsin.TryGetValue(s.ISIN!, out var last) ? (DateTime?)last : null)).ToList();
  224. var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
  225. var quotaExhausted = false;
  226. var processed = await SeibroRollingSweep.RunAsync(
  227. targets: targets,
  228. fetchAndUpsert: async (code, token) => {
  229. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  230. {
  231. quotaExhausted = true;
  232. return;
  233. }
  234. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStkIncdecDetails", [new("SHOTN_ISIN", code)], Logger, token);
  235. var rows = SeibroStkIncdecParser.Parse(SeibroXml.Parse(xml));
  236. var (inserted, updated) = await SeibroSupplyImport.UpsertIssuanceChangesAsync(db, rows, token);
  237. Logger.LogInformation("[{Job}] getStkIncdecDetails SHOTN_ISIN={Code} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, code, rows.Count, inserted, updated);
  238. },
  239. maxPerRun: maxPerRun,
  240. delayMs: cfg.DelayMs,
  241. ct: ct);
  242. Logger.LogInformation("[{Job}] getStkIncdecDetails rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  243. return !quotaExhausted;
  244. }
  245. /// <summary>d) 유통형태변경 월 윈도우 스윕(최신→과거, 최대 1개월/콜) — 미적재 월만. 적용일 window delete+insert. 예산 소진 시 false.</summary>
  246. private async Task<bool> SweepListingChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  247. {
  248. var fetched = 0;
  249. var first = true;
  250. // 월 단위 창: [monthStart, monthEnd] 최대 1개월. 최신 달부터 과거로.
  251. var windowEnd = today;
  252. while (windowEnd >= startDate)
  253. {
  254. ct.ThrowIfCancellationRequested();
  255. var monthStart = new DateOnly(windowEnd.Year, windowEnd.Month, 1);
  256. var windowBegin = monthStart < startDate ? startDate : monthStart;
  257. if (!await db.ListingChangeEvent.AsNoTracking().AnyAsync(c => c.ApliDt >= windowBegin && c.ApliDt <= windowEnd, ct))
  258. {
  259. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  260. {
  261. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getStkListInfo {Begin}~{End} 부터 중단", JobName, cfg.StockBudget, windowBegin, windowEnd);
  262. return false;
  263. }
  264. if (!first && cfg.DelayMs > 0)
  265. {
  266. await Task.Delay(cfg.DelayMs, ct);
  267. }
  268. first = false;
  269. 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);
  270. var rows = SeibroStkListParser.Parse(SeibroXml.Parse(xml));
  271. var (inserted, deleted) = await SeibroSupplyImport.ReplaceListingChangeWindowAsync(db, windowBegin, windowEnd, rows, ct);
  272. Logger.LogInformation("[{Job}] getStkListInfo {Begin}~{End} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, windowBegin, windowEnd, rows.Count, inserted, deleted);
  273. fetched++;
  274. }
  275. windowEnd = monthStart.AddDays(-1); // 이전 달 말일
  276. }
  277. Logger.LogInformation("[{Job}] 유통형태변경 월 윈도우 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}월", JobName, startDate, today, fetched);
  278. return true;
  279. }
  280. /// <summary>e) 비상장 유통 전 캘린더일 스윕(최신→과거) — STD_DT 만으로 그날 전체 종목 반환. 미적재일만. 예산 소진 시 false.</summary>
  281. private async Task<bool> SweepUnlistedAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  282. {
  283. var fetched = 0;
  284. var first = true;
  285. for (var day = today; day >= startDate; day = day.AddDays(-1))
  286. {
  287. ct.ThrowIfCancellationRequested();
  288. if (await db.UnlistedCirculation.AsNoTracking().AnyAsync(c => c.StdDt == day, ct))
  289. {
  290. continue; // 이미 적재된 기준일 — resumable
  291. }
  292. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  293. {
  294. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getUnlistCirclInfo {Day} 부터 중단", JobName, cfg.StockBudget, day);
  295. return false;
  296. }
  297. if (!first && cfg.DelayMs > 0)
  298. {
  299. await Task.Delay(cfg.DelayMs, ct);
  300. }
  301. first = false;
  302. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getUnlistCirclInfo", [new("STD_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  303. var rows = SeibroUnlistCirclParser.Parse(SeibroXml.Parse(xml));
  304. var (inserted, updated) = await SeibroSupplyImport.UpsertUnlistedAsync(db, day, rows, ct);
  305. Logger.LogInformation("[{Job}] getUnlistCirclInfo {Day} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, rows.Count, inserted, updated);
  306. fetched++;
  307. }
  308. Logger.LogInformation("[{Job}] 비상장 유통 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched);
  309. return true;
  310. }
  311. }