MarketQuoteSyncService.cs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  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. /// 주요 종목·상품 시세 스냅샷 수집 — Stooq 배치 라이트 시세(/q/l/?f=sd2t2ohlcv, 거래량 포함)를 하루 1회 조회해 심볼당 최신 종가를 upsert.
  12. /// config(MarketQuote:Items) 심볼을 한 요청으로 모아 조회하고, 응답 CSV 를 파싱해 MarketQuoteSnapshot 에 반영한다(전일 대비는 직전 저장 종가로 산출).
  13. /// WorldIndexSyncService 와 동일 패턴 + Volume·Category·GroupCode. 기본 07:10 KST(세계지수 07:00 직후). Items 비었거나 전량 실패 시 로그만 남기고 skip/재시도.
  14. /// </summary>
  15. internal sealed class MarketQuoteSyncService(
  16. IServiceScopeFactory scopeFactory,
  17. IHttpClientFactory httpClientFactory,
  18. IOptions<AppSettings> settings,
  19. ILogger<MarketQuoteSyncService> logger
  20. ) : DailyScheduledService(logger)
  21. {
  22. protected override string JobName => "MarketQuoteSync";
  23. protected override TimeOnly TargetTime => ParseTime(settings.Value.MarketQuote.SyncTime, new TimeOnly(7, 10));
  24. protected override int MaxRetryCount => 2;
  25. protected override TimeSpan RetryDelay => TimeSpan.FromMinutes(settings.Value.MarketQuote.RetryDelayMinutes > 0 ? settings.Value.MarketQuote.RetryDelayMinutes : 60);
  26. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  27. {
  28. var cfg = settings.Value.MarketQuote;
  29. var items = cfg.Items.Where(c => !string.IsNullOrWhiteSpace(c.Symbol)).ToList();
  30. if (items.Count == 0)
  31. {
  32. Logger.LogWarning("[{Job}] MarketQuote: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(StooqHttp.ClientName);
  38. var bySymbol = items
  39. .GroupBy(c => c.Symbol.Trim().ToLowerInvariant())
  40. .ToDictionary(g => g.Key, g => g.First());
  41. var symbolList = string.Join(",", bySymbol.Keys);
  42. var url = $"{cfg.BaseUrl.TrimEnd('/')}/q/l/?s={symbolList}&f=sd2t2ohlcv&h&e=csv";
  43. string csv;
  44. try
  45. {
  46. csv = await StooqHttp.GetStringWithRetryAsync(client, url, Logger, ct);
  47. }
  48. catch (Exception ex) when (ex is not OperationCanceledException)
  49. {
  50. Logger.LogError(ex, "[{Job}] Stooq 호출 실패 — 재시도 대상", JobName);
  51. return false;
  52. }
  53. var quotes = MarketQuoteCsvParser.Parse(csv);
  54. Logger.LogInformation("[{Job}] 응답 파싱 rows={Rows} (요청 {Count} 심볼)", JobName, quotes.Count, bySymbol.Count);
  55. if (quotes.Count == 0)
  56. {
  57. Logger.LogWarning("[{Job}] 파싱 0건 — 재시도 대상", JobName);
  58. return false;
  59. }
  60. var existing = await db.MarketQuoteSnapshot.ToListAsync(ct);
  61. var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
  62. var inserted = 0;
  63. var updated = 0;
  64. foreach (var q in quotes)
  65. {
  66. if (!bySymbol.TryGetValue(q.Symbol, out var meta))
  67. {
  68. continue; // 요청하지 않은 심볼 방어
  69. }
  70. var category = Enum.TryParse<QuoteCategory>(meta.Category, ignoreCase: true, out var parsed) ? parsed : QuoteCategory.Stock;
  71. if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
  72. {
  73. snap.UpdateMeta(meta.Name, category, meta.GroupCode, meta.CountryCode);
  74. snap.Apply(q.TradeDate, q.Close, q.Open, q.High, q.Low, q.Volume);
  75. updated++;
  76. }
  77. else
  78. {
  79. 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);
  80. await db.MarketQuoteSnapshot.AddAsync(created, ct);
  81. existingBySymbol[q.Symbol] = created;
  82. inserted++;
  83. }
  84. }
  85. await db.SaveChangesAsync(ct);
  86. Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}", JobName, inserted, updated);
  87. return true;
  88. }
  89. }