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) 종목기본정보를
/// 일 1회(기본 07:40 KST) 전량 수집하여 Stock upsert. 신규 상장 = insert, 명칭/시장/영문명/업종 변경 = update,
/// 스냅샷에서 사라진 활성 종목 = 상폐 soft-off. ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책).
/// 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다.
///
internal sealed class KrxStockMasterSyncService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
IOptions settings,
ILogger logger
) : DailyScheduledService(logger)
{
// (시장, 엔드포인트 경로) — KOSPI/KOSDAQ. KONEX(knx_*)·ETF/채권/파생 등은 D1 범위 밖.
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")
];
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;
}
}