using Application.Abstractions.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SharedKernel;
using Application.Helpers;
namespace Infrastructure.StockData;
///
/// SEIBro 외화증권 수집 (Wave 6, 최종) — 기본 08:20 KST(DerivSync 08:00 다음 슬롯), `Seibro:ForeignSync` 게이트.
/// 전 단계가 외화(Foreign) 예산(Seibro:ForeignBudget, 실한도 20,000 의 80%=15,000)을 공유한다 — 국가×일 수십 콜이라 여유가 크다.
///
/// a) ForeignCustodyNation: getNationFrsecCusInfo 날짜 스윕(STD_DT) × ForeignNations(설정) 3년 → (기준일,국가,종목구분) upsert.
/// b) ForeignSettlementNation: getNationFrsecSetlInfo 날짜 스윕(SETL_DT) × ForeignNations 3년 → (결제일,국가,매매,종목구분) upsert.
/// c) ForeignCustodySecurity: getSecnFrsecCusInfo 날짜 스윕(STD_DT) × ForeignIsins(설정, **기본 빈 → 스킵/대기**) → (기준일,ISIN) upsert.
/// d) ForeignSettlementSecurity: getSecnFrsecSetlInfo 날짜 스윕(PROC_DT) × ForeignIsins(기본 빈) → (처리일,ISIN,매매) upsert.
///
/// 외화 결제는 미국시간 T일 결제가 처리일 T+1 반영이라 결제일/처리일 스윕은 KrxBackfill(영업일·resumable)이 적절하다.
/// ForeignIsins 가 빈 리스트면 종목별 2단계(c·d)는 콜 없이 정상 통과(대기). 실패/0행=정상 빈결과, HTTP·파싱 오류=경고+false(재시도), quota 소진=정상 종료(true). ApiKey 미설정 시 skip.
///
internal sealed class SeibroForeignSyncService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
SeibroQuota quota,
IOptions settings,
ILogger logger
) : DailyScheduledService(logger)
{
// 마커 job 키 (리뷰 결함 #1) — 결제 없는 영업일 재조회 회피. 모두 date-sweep (SweptDate).
private const string CusNationJobKey = "foreign-cusnation"; // Discriminator=nation
private const string SetlNationJobKey = "foreign-setlnation"; // Discriminator=nation
private const string CusSecJobKey = "foreign-cussec"; // Discriminator=isin
private const string SetlSecJobKey = "foreign-setlsec"; // Discriminator=isin
protected override string JobName => "SeibroForeignSync";
protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.ForeignSyncTime, new TimeOnly(8, 20));
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.Seibro;
if (string.IsNullOrWhiteSpace(cfg.ApiKey))
{
Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
return true;
}
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
var startDate = todayKst.AddYears(-years);
var nations = cfg.ForeignNations ?? [];
var isins = cfg.ForeignIsins ?? [];
var holidays = await db.MarketHoliday.AsNoTracking().Select(c => c.Date).ToListAsync(ct);
var holidaySet = holidays.ToHashSet();
try
{
// a) 국가별 보관 날짜 스윕 × 국가 (Foreign)
if (!await SweepCustodyNationAsync(db, client, cfg, nations, holidaySet, startDate, todayKst, ct))
{
return true;
}
// b) 국가별 결제 날짜 스윕 × 국가 (Foreign)
if (!await SweepSettlementNationAsync(db, client, cfg, nations, holidaySet, startDate, todayKst, ct))
{
return true;
}
// c) 종목별 보관 날짜 스윕 × ISIN (Foreign, ForeignIsins 빈 → 콜 0)
if (!await SweepCustodySecurityAsync(db, client, cfg, isins, holidaySet, startDate, todayKst, ct))
{
return true;
}
// d) 종목별 결제 날짜 스윕 × ISIN (Foreign, ForeignIsins 빈 → 콜 0)
await SweepSettlementSecurityAsync(db, client, cfg, isins, holidaySet, startDate, todayKst, ct);
return true;
}
catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
{
Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
return false;
}
}
///
/// a) 국가별 보관 날짜 스윕(STD_DT, 최신→과거 영업일) × ForeignNations — 미적재 (기준일,국가)만.
/// getNationFrsecCusInfo(STD_DT,NATION_CD) 1콜당 종목구분 다수 행 upsert(UQ StdDt+NationCd+SecnTpcd). 예산 소진 시 false.
///
private async Task SweepCustodyNationAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList nations, IReadOnlySet holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
{
if (nations.Count == 0)
{
Logger.LogInformation("[{Job}] getNationFrsecCusInfo 대상 국가 없음 (ForeignNations 비어 있음)", JobName);
return true;
}
var fetched = 0;
var first = true;
for (var day = today; day >= startDate; day = day.AddDays(-1))
{
if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
{
continue; // 영업일 스윕 (외화 결제 T+1 반영 — KrxBackfill 판단)
}
foreach (var nation in nations)
{
ct.ThrowIfCancellationRequested();
// 데이터 존재 OR 조회완료 마커(축=nation) → 스킵 (보관 없는 (일,국가)도 재조회 회피, 리뷰 결함 #1)
if (await db.ForeignCustodyNation.AsNoTracking().AnyAsync(c => c.StdDt == day && c.NationCd == nation, ct) || await SeibroMarkers.IsSweptAsync(db, CusNationJobKey, day, nation, ct))
{
continue;
}
if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
{
Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getNationFrsecCusInfo {Day}/{Nation} 부터 중단", JobName, cfg.ForeignBudget, day, nation);
return false;
}
if (!first && cfg.DelayMs > 0)
{
await Task.Delay(cfg.DelayMs, ct);
}
first = false;
var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getNationFrsecCusInfo", [new("STD_DT", day.ToString("yyyyMMdd")), new("NATION_CD", nation)], Logger, ct);
var rows = SeibroNationFrsecCusParser.Parse(SeibroXml.Parse(xml));
var (inserted, updated) = await SeibroForeignImport.UpsertCustodyNationAsync(db, day, nation, rows, ct);
await SeibroMarkers.MarkSweptAsync(db, CusNationJobKey, day, nation, ct); // 0행 포함 조회완료 기록
Logger.LogInformation("[{Job}] getNationFrsecCusInfo {Day}/{Nation} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, nation, rows.Count, inserted, updated);
fetched++;
}
}
Logger.LogInformation("[{Job}] 국가별 보관 스윕 완료 — 창=[{Start}~{End}], 국가={Nations}, fetch={Fetched}콜", JobName, startDate, today, nations.Count, fetched);
return true;
}
///
/// b) 국가별 결제 날짜 스윕(SETL_DT, 최신→과거 영업일) × ForeignNations — 미적재 (결제일,국가)만.
/// getNationFrsecSetlInfo(SETL_DT,NATION_CD) 1콜당 매매×종목구분 다수 행 upsert(UQ SetlDt+NationCd+IntlBizCacd+SecnTpcd). 예산 소진 시 false.
///
private async Task SweepSettlementNationAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList nations, IReadOnlySet holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
{
if (nations.Count == 0)
{
return true;
}
var fetched = 0;
var first = true;
for (var day = today; day >= startDate; day = day.AddDays(-1))
{
if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
{
continue;
}
foreach (var nation in nations)
{
ct.ThrowIfCancellationRequested();
// 데이터 존재 OR 조회완료 마커(축=nation) → 스킵 (결제 없는 (일,국가)도 재조회 회피, 리뷰 결함 #1)
if (await db.ForeignSettlementNation.AsNoTracking().AnyAsync(c => c.SetlDt == day && c.NationCd == nation, ct) || await SeibroMarkers.IsSweptAsync(db, SetlNationJobKey, day, nation, ct))
{
continue;
}
if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
{
Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getNationFrsecSetlInfo {Day}/{Nation} 부터 중단", JobName, cfg.ForeignBudget, day, nation);
return false;
}
if (!first && cfg.DelayMs > 0)
{
await Task.Delay(cfg.DelayMs, ct);
}
first = false;
var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getNationFrsecSetlInfo", [new("SETL_DT", day.ToString("yyyyMMdd")), new("NATION_CD", nation)], Logger, ct);
var rows = SeibroNationFrsecSetlParser.Parse(SeibroXml.Parse(xml));
var (inserted, updated) = await SeibroForeignImport.UpsertSettlementNationAsync(db, day, nation, rows, ct);
await SeibroMarkers.MarkSweptAsync(db, SetlNationJobKey, day, nation, ct); // 0행 포함 조회완료 기록
Logger.LogInformation("[{Job}] getNationFrsecSetlInfo {Day}/{Nation} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, nation, rows.Count, inserted, updated);
fetched++;
}
}
Logger.LogInformation("[{Job}] 국가별 결제 스윕 완료 — 창=[{Start}~{End}], 국가={Nations}, fetch={Fetched}콜", JobName, startDate, today, nations.Count, fetched);
return true;
}
///
/// c) 종목별 보관 날짜 스윕(STD_DT, 최신→과거 영업일) × ForeignIsins — 미적재 (기준일,ISIN)만.
/// getSecnFrsecCusInfo(STD_DT,ISIN) → (기준일,ISIN) upsert(첫 행). **ForeignIsins 기본 빈 → 콜 0(대기)**. 예산 소진 시 false.
///
private async Task SweepCustodySecurityAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList isins, IReadOnlySet holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
{
if (isins.Count == 0)
{
Logger.LogInformation("[{Job}] getSecnFrsecCusInfo 대상 ISIN 없음 (ForeignIsins 비어 있음 — 종목별 보관 대기)", JobName);
return true;
}
var fetched = 0;
var first = true;
for (var day = today; day >= startDate; day = day.AddDays(-1))
{
if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
{
continue;
}
foreach (var isin in isins)
{
ct.ThrowIfCancellationRequested();
// 데이터 존재 OR 조회완료 마커(축=isin) → 스킵 (보관 없는 (일,ISIN)도 재조회 회피, 리뷰 결함 #1)
if (await db.ForeignCustodySecurity.AsNoTracking().AnyAsync(c => c.StdDt == day && c.Isin == isin, ct) || await SeibroMarkers.IsSweptAsync(db, CusSecJobKey, day, isin, ct))
{
continue;
}
if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
{
Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getSecnFrsecCusInfo {Day}/{Isin} 부터 중단", JobName, cfg.ForeignBudget, day, isin);
return false;
}
if (!first && cfg.DelayMs > 0)
{
await Task.Delay(cfg.DelayMs, ct);
}
first = false;
var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSecnFrsecCusInfo", [new("STD_DT", day.ToString("yyyyMMdd")), new("ISIN", isin)], Logger, ct);
var rows = SeibroSecnFrsecCusParser.Parse(SeibroXml.Parse(xml));
var ok = await SeibroForeignImport.UpsertCustodySecurityAsync(db, day, isin, rows, ct);
await SeibroMarkers.MarkSweptAsync(db, CusSecJobKey, day, isin, ct); // 0행 포함 조회완료 기록
Logger.LogInformation("[{Job}] getSecnFrsecCusInfo {Day}/{Isin} rows={Rows} — upsert={Ok}", JobName, day, isin, rows.Count, ok);
fetched++;
}
}
Logger.LogInformation("[{Job}] 종목별 보관 스윕 완료 — 창=[{Start}~{End}], ISIN={Isins}, fetch={Fetched}콜", JobName, startDate, today, isins.Count, fetched);
return true;
}
///
/// d) 종목별 결제 날짜 스윕(PROC_DT, 최신→과거 영업일) × ForeignIsins — 미적재 (처리일,ISIN)만.
/// getSecnFrsecSetlInfo(PROC_DT,ISIN) 1콜당 매매 다수 행 upsert(UQ ProcDt+Isin+IntlBizCacd). **ForeignIsins 기본 빈 → 콜 0(대기)**. 예산 소진 시 즉시 종료.
///
private async Task SweepSettlementSecurityAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, IReadOnlyList isins, IReadOnlySet holidays, DateOnly startDate, DateOnly today, CancellationToken ct)
{
if (isins.Count == 0)
{
Logger.LogInformation("[{Job}] getSecnFrsecSetlInfo 대상 ISIN 없음 (ForeignIsins 비어 있음 — 종목별 결제 대기)", JobName);
return;
}
var fetched = 0;
var first = true;
for (var day = today; day >= startDate; day = day.AddDays(-1))
{
if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidays.Contains(day))
{
continue;
}
foreach (var isin in isins)
{
ct.ThrowIfCancellationRequested();
// 데이터 존재 OR 조회완료 마커(축=isin) → 스킵 (결제 없는 (일,ISIN)도 재조회 회피, 리뷰 결함 #1)
if (await db.ForeignSettlementSecurity.AsNoTracking().AnyAsync(c => c.ProcDt == day && c.Isin == isin, ct) || await SeibroMarkers.IsSweptAsync(db, SetlSecJobKey, day, isin, ct))
{
continue;
}
if (!quota.TryConsume(SeibroCategory.Foreign, 1, cfg.ForeignBudget))
{
Logger.LogWarning("[{Job}] 외화 예산({Budget}) 소진 — getSecnFrsecSetlInfo {Day}/{Isin} 부터 중단", JobName, cfg.ForeignBudget, day, isin);
Logger.LogInformation("[{Job}] 종목별 결제 스윕 부분완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
return;
}
if (!first && cfg.DelayMs > 0)
{
await Task.Delay(cfg.DelayMs, ct);
}
first = false;
var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getSecnFrsecSetlInfo", [new("PROC_DT", day.ToString("yyyyMMdd")), new("ISIN", isin)], Logger, ct);
var rows = SeibroSecnFrsecSetlParser.Parse(SeibroXml.Parse(xml));
var (inserted, updated) = await SeibroForeignImport.UpsertSettlementSecurityAsync(db, day, isin, rows, ct);
await SeibroMarkers.MarkSweptAsync(db, SetlSecJobKey, day, isin, ct); // 0행 포함 조회완료 기록
Logger.LogInformation("[{Job}] getSecnFrsecSetlInfo {Day}/{Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, isin, rows.Count, inserted, updated);
fetched++;
}
}
Logger.LogInformation("[{Job}] 종목별 결제 스윕 완료 — 창=[{Start}~{End}], ISIN={Isins}, fetch={Fetched}콜", JobName, startDate, today, isins.Count, fetched);
}
}