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 OpenAPI) — stk_isu_base_info(KOSPI) + ksq_isu_base_info(KOSDAQ) + knx_isu_base_info(KONEX) 종목기본정보를 /// 일 1회(기본 07:40 KST) 전량 수집하여 Stock upsert. 신규 상장 = insert, 명칭/시장/영문명/업종 변경 = update, /// 스냅샷에서 사라진 활성 종목 = 상폐 soft-off. ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책). /// 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다. /// KONEX 응답 필드셋은 KOSPI/KOSDAQ 과 동일(ISU_SRT_CD/ISU_CD/ISU_ABBRV/ISU_ENG_NM/LIST_DD/MKT_TP_NM/SECT_TP_NM) — KrxStockParser 재사용. /// internal sealed class KrxStockMasterSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { // (시장, 엔드포인트 경로) — KOSPI/KOSDAQ/KONEX. 세 시장 모두 Stock 테이블에 Market 으로만 구분되어 적재된다. private static readonly (StockMarket Market, string Path)[] Endpoints = [ (StockMarket.KOSPI, "/svc/apis/sto/stk_isu_base_info"), (StockMarket.KOSDAQ, "/svc/apis/sto/ksq_isu_base_info"), (StockMarket.KONEX, "/svc/apis/sto/knx_isu_base_info") ]; private const int MaxBaseDateLookback = 7; protected override string JobName => "KrxStockMasterSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.KRXCoKr.MasterSyncTime, new TimeOnly(7, 40)); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.KRXCoKr; if (string.IsNullOrWhiteSpace(cfg.ApiKey)) { Logger.LogWarning("[{Job}] KRXCoKr:ApiKey 미설정 — 수집 skip", JobName); return true; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName); // 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 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) { 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) { if (stockByCode.TryGetValue(code, out var stock)) { var wasUpdated = stock.UpdatedAt; stock.UpdateMaster(item.Name, item.Market, item.Isin, item.EnglishName, item.SectorName); if (stock.UpdatedAt != wasUpdated) { updated++; } } else { await db.Stock.AddAsync(Stock.Create(code, item.Name, item.Market, item.ListedDate, item.Isin, englishName: item.EnglishName, sectorName: item.SectorName), 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}] 완료 — basDd={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.KRXCoKrSection cfg, DateOnly baseDate, CancellationToken ct) { var all = new List(); foreach (var (market, path) in Endpoints) { var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={baseDate:yyyyMMdd}"; var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct); var rows = KrxStockParser.ParseMasterInfo(json, market); Logger.LogInformation("[{Job}] {Market} basDd={BaseDate} rows={Rows}", JobName, market, baseDate, rows.Count); all.AddRange(rows); } return all; } }