| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106 |
- 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;
- /// <summary>
- /// 세계 주요국 증시지수 스냅샷 수집 — Stooq 배치 라이트 시세(/q/l/?f=sd2t2ohlc)를 하루 1회 조회해 심볼당 최신 종가를 upsert.
- /// config(WorldIndex:Items) 심볼을 한 요청으로 모아 조회하고, 응답 CSV 를 파싱해 WorldIndexSnapshot 에 반영한다(전일 대비는 직전 저장 종가로 산출).
- /// 국내(코스피)는 이 배치가 아니라 GetWorldIndices Handler 가 IndexDailyPrice 실데이터에서 병합한다.
- /// 기본 07:00 KST 실행(미국장 마감 이후 확정치 확보). Items 비었거나 전량 실패 시 로그만 남기고 skip/재시도.
- /// </summary>
- internal sealed class WorldIndexSyncService(
- IServiceScopeFactory scopeFactory,
- IHttpClientFactory httpClientFactory,
- IOptions<AppSettings> settings,
- ILogger<WorldIndexSyncService> 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<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
- {
- var cfg = settings.Value.WorldIndex;
- var items = cfg.Items.Where(c => !string.IsNullOrWhiteSpace(c.Symbol)).ToList();
- if (items.Count == 0)
- {
- Logger.LogWarning("[{Job}] WorldIndex:Items 미설정 — 수집 skip", JobName);
- return true;
- }
- using var scope = scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- var client = httpClientFactory.CreateClient(StooqHttp.ClientName);
- // 심볼별 config 매핑 (소문자 정규화 — 응답 심볼과 대소문자 무시 매칭, 중복 심볼은 첫 항목)
- 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=sd2t2ohlc&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 = WorldIndexCsvParser.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.WorldIndexSnapshot.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; // 요청하지 않은 심볼 방어
- }
- 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);
- updated++;
- }
- else
- {
- var created = WorldIndexSnapshot.Create(q.Symbol, meta.Name, meta.CountryCode, meta.ExchangeName, q.TradeDate, q.Close, q.Open, q.High, q.Low);
- await db.WorldIndexSnapshot.AddAsync(created, ct);
- existingBySymbol[q.Symbol] = created;
- inserted++;
- }
- }
- await db.SaveChangesAsync(ct);
- Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}", JobName, inserted, updated);
- return true;
- }
- }
|