using System.Text.Json; using Application.Abstractions.Data; using Domain.Entities.Stocks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.StockData; /// /// 세계 주요국 증시지수 스냅샷 수집 — Yahoo Finance v8 chart(무키)를 심볼별로 하루 1회 조회해 최신 종가를 upsert. /// config(WorldIndex:Items) 심볼(Yahoo 표기, 예: ^GSPC/^N225/000001.SS)을 하나씩 GET 하고 meta 를 파싱해 WorldIndexSnapshot 에 반영한다. /// 국내(코스피)는 이 배치가 아니라 GetWorldIndices Handler 가 IndexDailyPrice 실데이터에서 병합한다. /// 기본 07:00 KST 실행. 심볼 하나 실패는 skip(격리)하고, 전량 실패 시에만 재시도. (Stooq 봇차단으로 소스 전환 2026-07-09) /// internal sealed class WorldIndexSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { protected override string JobName => "WorldIndexSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.WorldIndex.SyncTime, new TimeOnly(7, 0)); protected override int MaxRetryCount => 2; protected override TimeSpan RetryDelay => TimeSpan.FromMinutes(settings.Value.WorldIndex.RetryDelayMinutes > 0 ? settings.Value.WorldIndex.RetryDelayMinutes : 60); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.WorldIndex; using var scope = scopeFactory.CreateScope(); var collectorSettings = scope.ServiceProvider.GetRequiredService(); if (!await collectorSettings.IsEnabledAsync(CollectorFlag.WorldIndex, ct)) { return true; } var items = cfg.Items.Where(c => !string.IsNullOrWhiteSpace(c.Symbol)).ToList(); if (items.Count == 0) { Logger.LogWarning("[{Job}] WorldIndex:Items 미설정 — 수집 skip", JobName); return true; } var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(YahooFinanceHttp.ClientName); var existing = await db.WorldIndexSnapshot.ToListAsync(ct); var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase); var baseUrl = cfg.BaseUrl.TrimEnd('/'); var inserted = 0; var updated = 0; var failed = 0; foreach (var meta in items) { ct.ThrowIfCancellationRequested(); var symbol = meta.Symbol.Trim(); // range=1mo — 최신 종가(meta)와 함께 스파크라인용 일별 종가 시계열(~20영업일)을 한 번에 수신 var url = $"{baseUrl}/v8/finance/chart/{Uri.EscapeDataString(symbol)}?interval=1d&range=1mo"; YahooChartParser.YahooQuote? q; string? sparkJson = null; try { var json = await YahooFinanceHttp.GetStringWithRetryAsync(client, url, Logger, ct); q = YahooChartParser.Parse(json, symbol); var series = YahooChartParser.ParseCloseSeries(json, 30); if (series.Count > 0) { sparkJson = JsonSerializer.Serialize(series); } } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogWarning(ex, "[{Job}] {Symbol} 조회 실패 — skip", JobName, symbol); failed++; continue; } if (q is null) { Logger.LogWarning("[{Job}] {Symbol} 데이터 없음 — skip", JobName, symbol); failed++; continue; } if (existingBySymbol.TryGetValue(q.Symbol, out var snap)) { snap.UpdateMeta(meta.Name, meta.CountryCode, meta.ExchangeName); snap.Apply(q.TradeDate, q.Close, q.Open, q.High, q.Low); snap.SetSpark(sparkJson); updated++; } else { var created = WorldIndexSnapshot.Create(q.Symbol, meta.Name, meta.CountryCode, meta.ExchangeName, q.TradeDate, q.Close, q.Open, q.High, q.Low); created.SetSpark(sparkJson); await db.WorldIndexSnapshot.AddAsync(created, ct); existingBySymbol[q.Symbol] = created; inserted++; } } if (inserted == 0 && updated == 0) { Logger.LogWarning("[{Job}] 전량 실패 (failed={Failed}) — 재시도 대상", JobName, failed); return false; } await db.SaveChangesAsync(ct); Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}, failed={Failed}", JobName, inserted, updated, failed); return true; } }