MarketQuoteSyncService.cs 4.5 KB

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