WorldIndexSyncService.cs 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. using Application.Abstractions.Data;
  2. using Domain.Entities.Stocks;
  3. using Microsoft.EntityFrameworkCore;
  4. using Microsoft.Extensions.DependencyInjection;
  5. using Microsoft.Extensions.Logging;
  6. using Microsoft.Extensions.Options;
  7. using SharedKernel;
  8. namespace Infrastructure.StockData;
  9. /// <summary>
  10. /// 세계 주요국 증시지수 스냅샷 수집 — Yahoo Finance v8 chart(무키)를 심볼별로 하루 1회 조회해 최신 종가를 upsert.
  11. /// config(WorldIndex:Items) 심볼(Yahoo 표기, 예: ^GSPC/^N225/000001.SS)을 하나씩 GET 하고 meta 를 파싱해 WorldIndexSnapshot 에 반영한다.
  12. /// 국내(코스피)는 이 배치가 아니라 GetWorldIndices Handler 가 IndexDailyPrice 실데이터에서 병합한다.
  13. /// 기본 07:00 KST 실행. 심볼 하나 실패는 skip(격리)하고, 전량 실패 시에만 재시도. (Stooq 봇차단으로 소스 전환 2026-07-09)
  14. /// </summary>
  15. internal sealed class WorldIndexSyncService(
  16. IServiceScopeFactory scopeFactory,
  17. IHttpClientFactory httpClientFactory,
  18. IOptions<AppSettings> settings,
  19. ILogger<WorldIndexSyncService> logger
  20. ) : DailyScheduledService(logger)
  21. {
  22. protected override string JobName => "WorldIndexSync";
  23. protected override TimeOnly TargetTime => ParseTime(settings.Value.WorldIndex.SyncTime, new TimeOnly(7, 0));
  24. protected override int MaxRetryCount => 2;
  25. protected override TimeSpan RetryDelay => TimeSpan.FromMinutes(settings.Value.WorldIndex.RetryDelayMinutes > 0 ? settings.Value.WorldIndex.RetryDelayMinutes : 60);
  26. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  27. {
  28. var cfg = settings.Value.WorldIndex;
  29. var items = cfg.Items.Where(c => !string.IsNullOrWhiteSpace(c.Symbol)).ToList();
  30. if (items.Count == 0)
  31. {
  32. Logger.LogWarning("[{Job}] WorldIndex:Items 미설정 — 수집 skip", JobName);
  33. return true;
  34. }
  35. using var scope = scopeFactory.CreateScope();
  36. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  37. var client = httpClientFactory.CreateClient(YahooFinanceHttp.ClientName);
  38. var existing = await db.WorldIndexSnapshot.ToListAsync(ct);
  39. var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
  40. var baseUrl = cfg.BaseUrl.TrimEnd('/');
  41. var inserted = 0;
  42. var updated = 0;
  43. var failed = 0;
  44. foreach (var meta in items)
  45. {
  46. ct.ThrowIfCancellationRequested();
  47. var symbol = meta.Symbol.Trim();
  48. var url = $"{baseUrl}/v8/finance/chart/{Uri.EscapeDataString(symbol)}?interval=1d&range=5d";
  49. YahooChartParser.YahooQuote? q;
  50. try
  51. {
  52. var json = await YahooFinanceHttp.GetStringWithRetryAsync(client, url, Logger, ct);
  53. q = YahooChartParser.Parse(json, symbol);
  54. }
  55. catch (Exception ex) when (ex is not OperationCanceledException)
  56. {
  57. Logger.LogWarning(ex, "[{Job}] {Symbol} 조회 실패 — skip", JobName, symbol);
  58. failed++;
  59. continue;
  60. }
  61. if (q is null)
  62. {
  63. Logger.LogWarning("[{Job}] {Symbol} 데이터 없음 — skip", JobName, symbol);
  64. failed++;
  65. continue;
  66. }
  67. if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
  68. {
  69. snap.UpdateMeta(meta.Name, meta.CountryCode, meta.ExchangeName);
  70. snap.Apply(q.TradeDate, q.Close, q.Open, q.High, q.Low);
  71. updated++;
  72. }
  73. else
  74. {
  75. var created = WorldIndexSnapshot.Create(q.Symbol, meta.Name, meta.CountryCode, meta.ExchangeName, q.TradeDate, q.Close, q.Open, q.High, q.Low);
  76. await db.WorldIndexSnapshot.AddAsync(created, ct);
  77. existingBySymbol[q.Symbol] = created;
  78. inserted++;
  79. }
  80. }
  81. if (inserted == 0 && updated == 0)
  82. {
  83. Logger.LogWarning("[{Job}] 전량 실패 (failed={Failed}) — 재시도 대상", JobName, failed);
  84. return false;
  85. }
  86. await db.SaveChangesAsync(ct);
  87. Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}, failed={Failed}", JobName, inserted, updated, failed);
  88. return true;
  89. }
  90. }