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;
}
}