SeibroForeignSyncService.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. using Application.Abstractions.Data;
  2. using Microsoft.EntityFrameworkCore;
  3. using Microsoft.Extensions.DependencyInjection;
  4. using Microsoft.Extensions.Logging;
  5. using Microsoft.Extensions.Options;
  6. using SharedKernel;
  7. using Application.Helpers;
  8. namespace Infrastructure.StockData;
  9. /// <summary>
  10. /// SEIBro 외화증권 수집 (Wave 6, 최종) — 기본 08:20 KST(DerivSync 08:00 다음 슬롯), `Seibro:ForeignSync` 게이트.
  11. /// 전 단계가 외화(Foreign) 예산(Seibro:ForeignBudget, 실한도 20,000 의 80%=15,000)을 공유한다 — 국가×일 수십 콜이라 여유가 크다.
  12. ///
  13. /// a) ForeignCustodyNation: getNationFrsecCusInfo 날짜 스윕(STD_DT) × ForeignNations(설정) 3년 → (기준일,국가,종목구분) upsert.
  14. /// b) ForeignSettlementNation: getNationFrsecSetlInfo 날짜 스윕(SETL_DT) × ForeignNations 3년 → (결제일,국가,매매,종목구분) upsert.
  15. /// c) ForeignCustodySecurity: getSecnFrsecCusInfo 날짜 스윕(STD_DT) × ForeignIsins(설정, **기본 빈 → 스킵/대기**) → (기준일,ISIN) upsert.
  16. /// d) ForeignSettlementSecurity: getSecnFrsecSetlInfo 날짜 스윕(PROC_DT) × ForeignIsins(기본 빈) → (처리일,ISIN,매매) upsert.
  17. ///
  18. /// 외화 결제는 미국시간 T일 결제가 처리일 T+1 반영이라 결제일/처리일 스윕은 KrxBackfill(영업일·resumable)이 적절하다.
  19. /// ForeignIsins 가 빈 리스트면 종목별 2단계(c·d)는 콜 없이 정상 통과(대기). 실패/0행=정상 빈결과, HTTP·파싱 오류=경고+false(재시도), quota 소진=정상 종료(true). ApiKey 미설정 시 skip.
  20. /// </summary>
  21. internal sealed class SeibroForeignSyncService(
  22. IServiceScopeFactory scopeFactory,
  23. IHttpClientFactory httpClientFactory,
  24. SeibroQuota quota,
  25. IOptions<AppSettings> settings,
  26. ILogger<SeibroForeignSyncService> logger
  27. ) : DailyScheduledService(logger)
  28. {
  29. // 마커 job 키 (리뷰 결함 #1) — 결제 없는 영업일 재조회 회피. 모두 date-sweep (SweptDate).
  30. private const string CusNationJobKey = "foreign-cusnation"; // Discriminator=nation
  31. private const string SetlNationJobKey = "foreign-setlnation"; // Discriminator=nation
  32. private const string CusSecJobKey = "foreign-cussec"; // Discriminator=isin
  33. private const string SetlSecJobKey = "foreign-setlsec"; // Discriminator=isin
  34. protected override string JobName => "SeibroForeignSync";
  35. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.ForeignSyncTime, new TimeOnly(8, 20));
  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. using var scope = scopeFactory.CreateScope();
  42. var collectorSettings = scope.ServiceProvider.GetRequiredService<ICollectorSettingsProvider>();
  43. if (!await collectorSettings.IsEnabledAsync(CollectorFlag.SeibroForeign, ct))
  44. {
  45. return true;
  46. }
  47. cfg = cfg with { ApiKey = await collectorSettings.GetKeyAsync(CollectorKey.Seibro, ct) ?? cfg.ApiKey };
  48. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  49. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  50. {
  51. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  52. return true;
  53. }
  54. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  55. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
  56. var startDate = todayKst.AddYears(-years);
  57. var nations = cfg.ForeignNations ?? [];
  58. var isins = cfg.ForeignIsins ?? [];
  59. var holidays = await db.MarketHoliday.AsNoTracking().Select(c => c.Date).ToListAsync(ct);
  60. var holidaySet = holidays.ToHashSet();
  61. // 단계별 독립 실행 — 한 단계의 SEIBro 오류가 다른 단계를 막지 않도록 개별 try/catch.
  62. // 반환 false = 예산 소진(전체 중단). 파싱/HTTP 오류 = 해당 단계만 skip(마커 미기록 → 다음 실행 재시도).
  63. async Task<bool> Phase(string name, Func<CancellationToken, Task<bool>> body)
  64. {
  65. try
  66. {
  67. return await body(ct);
  68. }
  69. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  70. {
  71. Logger.LogWarning(ex, "[{Job}] {Phase} SEIBro 오류 — 단계 skip(다음 실행 재시도)", JobName, name);
  72. return true;
  73. }
  74. }
  75. // a) 국가별 보관 → b) 국가별 결제 → c) 종목별 보관 → d) 종목별 결제
  76. if (!await Phase("국가별보관", t => SweepCustodyNationAsync(db, client, cfg, nations, holidaySet, startDate, todayKst, t))) { return true; }
  77. if (!await Phase("국가별결제", t => SweepSettlementNationAsync(db, client, cfg, nations, holidaySet, startDate, todayKst, t))) { return true; }
  78. if (!await Phase("종목별보관", t => SweepCustodySecurityAsync(db, client, cfg, isins, holidaySet, startDate, todayKst, t))) { return true; }
  79. // d) 종목별 결제 (void 반환) — 개별 try/catch
  80. await Phase("종목별결제", async t => { await SweepSettlementSecurityAsync(db, client, cfg, isins, holidaySet, startDate, todayKst, t); return true; });
  81. return true;
  82. }
  83. /// <summary>
  84. /// a) 국가별 보관 날짜 스윕(STD_DT, 최신→과거 영업일) × ForeignNations — 미적재 (기준일,국가)만.
  85. /// getNationFrsecCusInfo(STD_DT,NATION_CD) 1콜당 종목구분 다수 행 upsert(UQ StdDt+NationCd+SecnTpcd). 예산 소진 시 false.
  86. /// </summary>
  87. private async Task<bool> SweepCustodyNationAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList<string> nations, IReadOnlySet<DateOnly> holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
  88. {
  89. if (nations.Count == 0)
  90. {
  91. Logger.LogInformation("[{Job}] getNationFrsecCusInfo 대상 국가 없음 (ForeignNations 비어 있음)", JobName);
  92. return true;
  93. }
  94. var fetched = 0;
  95. var first = true;
  96. for (var day = today; day >= startDate; day = day.AddDays(-1))
  97. {
  98. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
  99. {
  100. continue; // 영업일 스윕 (외화 결제 T+1 반영 — KrxBackfill 판단)
  101. }
  102. foreach (var nation in nations)
  103. {
  104. ct.ThrowIfCancellationRequested();
  105. // 데이터 존재 OR 조회완료 마커(축=nation) → 스킵 (보관 없는 (일,국가)도 재조회 회피, 리뷰 결함 #1)
  106. if (await db.ForeignCustodyNation.AsNoTracking().AnyAsync(c => c.StdDt == day && c.NationCd == nation, ct) || await SeibroMarkers.IsSweptAsync(db, CusNationJobKey, day, nation, ct))
  107. {
  108. continue;
  109. }
  110. if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
  111. {
  112. Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getNationFrsecCusInfo {Day}/{Nation} 부터 중단", JobName, cfg.ForeignBudget, day, nation);
  113. return false;
  114. }
  115. if (!first && cfg.DelayMs > 0)
  116. {
  117. await Task.Delay(cfg.DelayMs, ct);
  118. }
  119. first = false;
  120. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getNationFrsecCusInfo", [new("STD_DT", day.ToString("yyyyMMdd")), new("NATION_CD", nation)], Logger, ct);
  121. var rows = SeibroNationFrsecCusParser.Parse(SeibroXml.Parse(xml));
  122. var (inserted, updated) = await SeibroForeignImport.UpsertCustodyNationAsync(db, day, nation, rows, ct);
  123. await SeibroMarkers.MarkSweptAsync(db, CusNationJobKey, day, nation, ct); // 0행 포함 조회완료 기록
  124. Logger.LogInformation("[{Job}] getNationFrsecCusInfo {Day}/{Nation} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, nation, rows.Count, inserted, updated);
  125. fetched++;
  126. }
  127. }
  128. Logger.LogInformation("[{Job}] 국가별 보관 스윕 완료 — 창=[{Start}~{End}], 국가={Nations}, fetch={Fetched}콜", JobName, startDate, today, nations.Count, fetched);
  129. return true;
  130. }
  131. /// <summary>
  132. /// b) 국가별 결제 날짜 스윕(SETL_DT, 최신→과거 영업일) × ForeignNations — 미적재 (결제일,국가)만.
  133. /// getNationFrsecSetlInfo(SETL_DT,NATION_CD) 1콜당 매매×종목구분 다수 행 upsert(UQ SetlDt+NationCd+IntlBizCacd+SecnTpcd). 예산 소진 시 false.
  134. /// </summary>
  135. private async Task<bool> SweepSettlementNationAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList<string> nations, IReadOnlySet<DateOnly> holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
  136. {
  137. if (nations.Count == 0)
  138. {
  139. return true;
  140. }
  141. var fetched = 0;
  142. var first = true;
  143. for (var day = today; day >= startDate; day = day.AddDays(-1))
  144. {
  145. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
  146. {
  147. continue;
  148. }
  149. foreach (var nation in nations)
  150. {
  151. ct.ThrowIfCancellationRequested();
  152. // 데이터 존재 OR 조회완료 마커(축=nation) → 스킵 (결제 없는 (일,국가)도 재조회 회피, 리뷰 결함 #1)
  153. if (await db.ForeignSettlementNation.AsNoTracking().AnyAsync(c => c.SetlDt == day && c.NationCd == nation, ct) || await SeibroMarkers.IsSweptAsync(db, SetlNationJobKey, day, nation, ct))
  154. {
  155. continue;
  156. }
  157. if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
  158. {
  159. Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getNationFrsecSetlInfo {Day}/{Nation} 부터 중단", JobName, cfg.ForeignBudget, day, nation);
  160. return false;
  161. }
  162. if (!first && cfg.DelayMs > 0)
  163. {
  164. await Task.Delay(cfg.DelayMs, ct);
  165. }
  166. first = false;
  167. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getNationFrsecSetlInfo", [new("SETL_DT", day.ToString("yyyyMMdd")), new("NATION_CD", nation)], Logger, ct);
  168. var rows = SeibroNationFrsecSetlParser.Parse(SeibroXml.Parse(xml));
  169. var (inserted, updated) = await SeibroForeignImport.UpsertSettlementNationAsync(db, day, nation, rows, ct);
  170. await SeibroMarkers.MarkSweptAsync(db, SetlNationJobKey, day, nation, ct); // 0행 포함 조회완료 기록
  171. Logger.LogInformation("[{Job}] getNationFrsecSetlInfo {Day}/{Nation} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, nation, rows.Count, inserted, updated);
  172. fetched++;
  173. }
  174. }
  175. Logger.LogInformation("[{Job}] 국가별 결제 스윕 완료 — 창=[{Start}~{End}], 국가={Nations}, fetch={Fetched}콜", JobName, startDate, today, nations.Count, fetched);
  176. return true;
  177. }
  178. /// <summary>
  179. /// c) 종목별 보관 날짜 스윕(STD_DT, 최신→과거 영업일) × ForeignIsins — 미적재 (기준일,ISIN)만.
  180. /// getSecnFrsecCusInfo(STD_DT,ISIN) → (기준일,ISIN) upsert(첫 행). **ForeignIsins 기본 빈 → 콜 0(대기)**. 예산 소진 시 false.
  181. /// </summary>
  182. private async Task<bool> SweepCustodySecurityAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList<string> isins, IReadOnlySet<DateOnly> holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
  183. {
  184. if (isins.Count == 0)
  185. {
  186. Logger.LogInformation("[{Job}] getSecnFrsecCusInfo 대상 ISIN 없음 (ForeignIsins 비어 있음 — 종목별 보관 대기)", JobName);
  187. return true;
  188. }
  189. var fetched = 0;
  190. var first = true;
  191. for (var day = today; day >= startDate; day = day.AddDays(-1))
  192. {
  193. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
  194. {
  195. continue;
  196. }
  197. foreach (var isin in isins)
  198. {
  199. ct.ThrowIfCancellationRequested();
  200. // 데이터 존재 OR 조회완료 마커(축=isin) → 스킵 (보관 없는 (일,ISIN)도 재조회 회피, 리뷰 결함 #1)
  201. if (await db.ForeignCustodySecurity.AsNoTracking().AnyAsync(c => c.StdDt == day && c.Isin == isin, ct) || await SeibroMarkers.IsSweptAsync(db, CusSecJobKey, day, isin, ct))
  202. {
  203. continue;
  204. }
  205. if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
  206. {
  207. Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getSecnFrsecCusInfo {Day}/{Isin} 부터 중단", JobName, cfg.ForeignBudget, day, isin);
  208. return false;
  209. }
  210. if (!first && cfg.DelayMs > 0)
  211. {
  212. await Task.Delay(cfg.DelayMs, ct);
  213. }
  214. first = false;
  215. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSecnFrsecCusInfo", [new("STD_DT", day.ToString("yyyyMMdd")), new("ISIN", isin)], Logger, ct);
  216. var rows = SeibroSecnFrsecCusParser.Parse(SeibroXml.Parse(xml));
  217. var ok = await SeibroForeignImport.UpsertCustodySecurityAsync(db, day, isin, rows, ct);
  218. await SeibroMarkers.MarkSweptAsync(db, CusSecJobKey, day, isin, ct); // 0행 포함 조회완료 기록
  219. Logger.LogInformation("[{Job}] getSecnFrsecCusInfo {Day}/{Isin} rows={Rows} — upsert={Ok}", JobName, day, isin, rows.Count, ok);
  220. fetched++;
  221. }
  222. }
  223. Logger.LogInformation("[{Job}] 종목별 보관 스윕 완료 — 창=[{Start}~{End}], ISIN={Isins}, fetch={Fetched}콜", JobName, startDate, today, isins.Count, fetched);
  224. return true;
  225. }
  226. /// <summary>
  227. /// d) 종목별 결제 날짜 스윕(PROC_DT, 최신→과거 영업일) × ForeignIsins — 미적재 (처리일,ISIN)만.
  228. /// getSecnFrsecSetlInfo(PROC_DT,ISIN) 1콜당 매매 다수 행 upsert(UQ ProcDt+Isin+IntlBizCacd). **ForeignIsins 기본 빈 → 콜 0(대기)**. 예산 소진 시 즉시 종료.
  229. /// </summary>
  230. private async Task SweepSettlementSecurityAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList<string> isins, IReadOnlySet<DateOnly> holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
  231. {
  232. if (isins.Count == 0)
  233. {
  234. Logger.LogInformation("[{Job}] getSecnFrsecSetlInfo 대상 ISIN 없음 (ForeignIsins 비어 있음 — 종목별 결제 대기)", JobName);
  235. return;
  236. }
  237. var fetched = 0;
  238. var first = true;
  239. for (var day = today; day >= startDate; day = day.AddDays(-1))
  240. {
  241. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
  242. {
  243. continue;
  244. }
  245. foreach (var isin in isins)
  246. {
  247. ct.ThrowIfCancellationRequested();
  248. // 데이터 존재 OR 조회완료 마커(축=isin) → 스킵 (결제 없는 (일,ISIN)도 재조회 회피, 리뷰 결함 #1)
  249. if (await db.ForeignSettlementSecurity.AsNoTracking().AnyAsync(c => c.ProcDt == day && c.Isin == isin, ct) || await SeibroMarkers.IsSweptAsync(db, SetlSecJobKey, day, isin, ct))
  250. {
  251. continue;
  252. }
  253. if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
  254. {
  255. Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getSecnFrsecSetlInfo {Day}/{Isin} 부터 중단", JobName, cfg.ForeignBudget, day, isin);
  256. Logger.LogInformation("[{Job}] 종목별 결제 스윕 부분완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
  257. return;
  258. }
  259. if (!first && cfg.DelayMs > 0)
  260. {
  261. await Task.Delay(cfg.DelayMs, ct);
  262. }
  263. first = false;
  264. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSecnFrsecSetlInfo", [new("PROC_DT", day.ToString("yyyyMMdd")), new("ISIN", isin)], Logger, ct);
  265. var rows = SeibroSecnFrsecSetlParser.Parse(SeibroXml.Parse(xml));
  266. var (inserted, updated) = await SeibroForeignImport.UpsertSettlementSecurityAsync(db, day, isin, rows, ct);
  267. await SeibroMarkers.MarkSweptAsync(db, SetlSecJobKey, day, isin, ct); // 0행 포함 조회완료 기록
  268. Logger.LogInformation("[{Job}] getSecnFrsecSetlInfo {Day}/{Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, isin, rows.Count, inserted, updated);
  269. fetched++;
  270. }
  271. }
  272. Logger.LogInformation("[{Job}] 종목별 결제 스윕 완료 — 창=[{Start}~{End}], ISIN={Isins}, fetch={Fetched}콜", JobName, startDate, today, isins.Count, fetched);
  273. }
  274. }