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; /// /// 종목 마스터 동기화 — 금융위 KRX 상장종목정보 API 를 일 1회(기본 07:30 KST) 전량 수집하여 Stock upsert. /// 신규 상장 = insert, 명칭/시장 변경 = update, 스냅샷에서 사라진 종목 = 상폐 soft-off. /// ServiceKey 미설정 시 로그만 남기고 skip. /// internal sealed class StockMasterSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { private const string ServicePath = "/1160100/service/GetKrxListedInfoService/getItemInfo"; private const int MaxPages = 50; private const int MaxBaseDateLookback = 7; protected override string JobName => "StockMasterSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.StockData.MasterSyncTime, new TimeOnly(7, 30)); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.StockData.DataGoKr; if (string.IsNullOrWhiteSpace(cfg.ServiceKey)) { Logger.LogWarning("[{Job}] StockData:DataGoKr:ServiceKey 미설정 — 수집 skip", JobName); return true; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(DataGoKrHttp.ClientName); // 상장종목정보는 basDt 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다 var baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct); List? snapshot = null; for (var back = 0; back < MaxBaseDateLookback; back++) { snapshot = await FetchSnapshotAsync(client, cfg, baseDate, ct); if (snapshot.Count > 0) { break; } baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, baseDate, ct); } if (snapshot is null || snapshot.Count == 0) { Logger.LogError("[{Job}] 상장종목 스냅샷 없음 — 최근 {Days}영업일 조회 실패", JobName, MaxBaseDateLookback); return true; } // 동일 코드 중복 행은 마지막 행 우선 var byCode = new Dictionary(); foreach (var item in snapshot) { var market = MapMarket(item.MarketName); if (market is null) { continue; } byCode[item.Code] = item; } var stocks = await db.Stock.ToListAsync(ct); var stockByCode = stocks.ToDictionary(c => c.Code); var inserted = 0; var updated = 0; var delisted = 0; foreach (var (code, item) in byCode) { var market = MapMarket(item.MarketName)!.Value; if (stockByCode.TryGetValue(code, out var stock)) { var wasUpdated = stock.UpdatedAt; stock.UpdateMaster(item.Name, market, item.Isin); if (stock.UpdatedAt != wasUpdated) { updated++; } } else { await db.Stock.AddAsync(Stock.Create(code, item.Name, market, item.BaseDate, item.Isin), ct); inserted++; } } // 스냅샷에서 사라진 활성 종목 = 상폐 soft-off foreach (var stock in stocks.Where(c => c.IsActive && !byCode.ContainsKey(c.Code))) { stock.MarkDelisted(baseDate); delisted++; } await db.SaveChangesAsync(ct); Logger.LogInformation("[{Job}] 완료 — basDt={BaseDate}, snapshot={Snapshot}, inserted={Inserted}, updated={Updated}, delisted={Delisted}", JobName, baseDate, byCode.Count, inserted, updated, delisted); return true; } private async Task> FetchSnapshotAsync(HttpClient client, AppSettings.StockDataSection.DataGoKrSection cfg, DateOnly baseDate, CancellationToken ct) { var all = new List(); var totalCount = int.MaxValue; for (var pageNo = 1; pageNo <= MaxPages && all.Count < totalCount; pageNo++) { var url = $"{cfg.BaseUrl.TrimEnd('/')}{ServicePath}?serviceKey={Uri.EscapeDataString(cfg.ServiceKey)}&resultType=json&numOfRows={cfg.PageSize}&pageNo={pageNo}&basDt={baseDate:yyyyMMdd}"; var json = await DataGoKrHttp.GetStringWithRetryAsync(client, url, Logger, ct); var (items, total) = DataGoKrStockParser.ParseListedItems(json); totalCount = total; if (items.Count == 0) { break; } all.AddRange(items); } return all; } private static StockMarket? MapMarket(string marketName) { if (marketName.Contains("KOSPI", StringComparison.OrdinalIgnoreCase)) { return StockMarket.KOSPI; } if (marketName.Contains("KOSDAQ", StringComparison.OrdinalIgnoreCase)) { return StockMarket.KOSDAQ; } if (marketName.Contains("KONEX", StringComparison.OrdinalIgnoreCase)) { return StockMarket.KONEX; } return null; } }