using Application.Abstractions.Data; using Domain.Entities.Stocks; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.StockData; /// /// 주요 종목·상품 시세 스냅샷 수집 — Stooq 배치 라이트 시세(/q/l/?f=sd2t2ohlcv, 거래량 포함)를 하루 1회 조회해 심볼당 최신 종가를 upsert. /// config(MarketQuote:Items) 심볼을 한 요청으로 모아 조회하고, 응답 CSV 를 파싱해 MarketQuoteSnapshot 에 반영한다(전일 대비는 직전 저장 종가로 산출). /// WorldIndexSyncService 와 동일 패턴 + Volume·Category·GroupCode. 기본 07:10 KST(세계지수 07:00 직후). Items 비었거나 전량 실패 시 로그만 남기고 skip/재시도. /// internal sealed class MarketQuoteSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { protected override string JobName => "MarketQuoteSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.MarketQuote.SyncTime, new TimeOnly(7, 10)); protected override int MaxRetryCount => 2; protected override TimeSpan RetryDelay => TimeSpan.FromMinutes(settings.Value.MarketQuote.RetryDelayMinutes > 0 ? settings.Value.MarketQuote.RetryDelayMinutes : 60); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.MarketQuote; var items = cfg.Items.Where(c => !string.IsNullOrWhiteSpace(c.Symbol)).ToList(); if (items.Count == 0) { Logger.LogWarning("[{Job}] MarketQuote:Items 미설정 — 수집 skip", JobName); return true; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(StooqHttp.ClientName); var bySymbol = items .GroupBy(c => c.Symbol.Trim().ToLowerInvariant()) .ToDictionary(g => g.Key, g => g.First()); var symbolList = string.Join(",", bySymbol.Keys); var url = $"{cfg.BaseUrl.TrimEnd('/')}/q/l/?s={symbolList}&f=sd2t2ohlcv&h&e=csv"; string csv; try { csv = await StooqHttp.GetStringWithRetryAsync(client, url, Logger, ct); } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogError(ex, "[{Job}] Stooq 호출 실패 — 재시도 대상", JobName); return false; } var quotes = MarketQuoteCsvParser.Parse(csv); Logger.LogInformation("[{Job}] 응답 파싱 rows={Rows} (요청 {Count} 심볼)", JobName, quotes.Count, bySymbol.Count); if (quotes.Count == 0) { Logger.LogWarning("[{Job}] 파싱 0건 — 재시도 대상", JobName); return false; } var existing = await db.MarketQuoteSnapshot.ToListAsync(ct); var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase); var inserted = 0; var updated = 0; foreach (var q in quotes) { if (!bySymbol.TryGetValue(q.Symbol, out var meta)) { continue; // 요청하지 않은 심볼 방어 } var category = Enum.TryParse(meta.Category, ignoreCase: true, out var parsed) ? parsed : QuoteCategory.Stock; if (existingBySymbol.TryGetValue(q.Symbol, out var snap)) { snap.UpdateMeta(meta.Name, category, meta.GroupCode, meta.CountryCode); snap.Apply(q.TradeDate, q.Close, q.Open, q.High, q.Low, q.Volume); updated++; } else { 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); await db.MarketQuoteSnapshot.AddAsync(created, ct); existingBySymbol[q.Symbol] = created; inserted++; } } await db.SaveChangesAsync(ct); Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}", JobName, inserted, updated); return true; } }