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_bydd_trd(KOSPI) + ksq_bydd_trd(KOSDAQ) 일별매매정보. /// 기본 18:10 KST 실행(장 마감 15:30 이후 확정), basDd=직전 영업일 전량 수집 → /// StockDailyPrice upsert (StockID+TradingDate) + Stock.UpdateLastPrice denorm(최근 종가/등락률/시총) 갱신. /// 마스터 미동기화 코드는 skip (다음 마스터 동기화 후 자연 반영). 미반영(0건)이면 2시간 간격 2회 재시도. /// ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책). /// internal sealed class KrxDailyPriceSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { // (시장, 엔드포인트 경로) — KOSPI/KOSDAQ. KONEX(knx_bydd_trd)·ETF/채권/파생 등은 D1 범위 밖. private static readonly (StockMarket Market, string Path)[] Endpoints = [ (StockMarket.KOSPI, "/svc/apis/sto/stk_bydd_trd"), (StockMarket.KOSDAQ, "/svc/apis/sto/ksq_bydd_trd") ]; protected override string JobName => "KrxDailyPriceSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.KRXCoKr.StockSyncTime, new TimeOnly(18, 10)); protected override int MaxRetryCount => 2; protected override TimeSpan RetryDelay => TimeSpan.FromHours(2); 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); var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct); var rows = new List(); foreach (var (market, path) in Endpoints) { var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={targetDate:yyyyMMdd}"; var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct); var parsed = KrxStockParser.ParseDailyPrices(json, market); Logger.LogInformation("[{Job}] {Market} basDd={TargetDate} rows={Rows}", JobName, market, targetDate, parsed.Count); rows.AddRange(parsed); } if (rows.Count == 0) { Logger.LogInformation("[{Job}] basDd={TargetDate} 시세 미반영 (0건)", JobName, targetDate); return false; } var stockByCode = await db.Stock.ToDictionaryAsync(c => c.Code, ct); var existingByStockID = await db.StockDailyPrice.Where(c => c.TradingDate == targetDate).ToDictionaryAsync(c => c.StockID, ct); var inserted = 0; var updated = 0; var unknown = 0; foreach (var row in rows) { if (!stockByCode.TryGetValue(row.Code, out var stock)) { // 마스터 미동기화 종목 — 다음 마스터 동기화 후 자연 반영 (DailyPriceSyncService 와 동일 정책) unknown++; continue; } if (existingByStockID.TryGetValue(stock.ID, out var price)) { price.Update(row.Open, row.High, row.Low, row.Close, row.Volume, row.TradingValue, row.ChangeAmount, row.ChangeRate, row.MarketCap, row.ListedShares); updated++; } else { var created = StockDailyPrice.Create(stock.ID, row.TradingDate, row.Open, row.High, row.Low, row.Close, row.Volume, row.TradingValue, row.ChangeAmount, row.ChangeRate, row.MarketCap, row.ListedShares); await db.StockDailyPrice.AddAsync(created, ct); existingByStockID[stock.ID] = created; inserted++; } stock.UpdateLastPrice(row.TradingDate, row.Close, row.ChangeRate, row.MarketCap); } await db.SaveChangesAsync(ct); Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}, unknownCode={Unknown}", JobName, targetDate, rows.Count, inserted, updated, unknown); return true; } }