using Application.Abstractions.Data; using Application.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Infrastructure.StockData; /// /// 모의투자 체결·스냅샷 배치 (d4 M2). KST 13:10 기동 후 직전 영업일(TargetDate) 시세 적재를 폴링하고 /// (StockDailyPrice 존재 여부, 10분 간격 ~18:00), 적재되면 체결 패스 → 스냅샷 패스를 1회 수행한다. /// /// DailyPriceSyncService(13:10) 가 시세를 먼저 넣으므로 그 뒤에 자연히 통과한다. 시세 미적재 상태로 /// 마감 시각을 넘기면 그날은 skip 하고 다음 날 다시 시도한다. BackgroundJobs:PaperFill 플래그로 토글, /// Web.Api 전용(중복 실행 방지). 체결은 주문 단위 컨텍스트로 격리(PaperBatchEngine 참조). /// internal sealed class PaperFillService( IServiceScopeFactory scopeFactory, IAppDbContextFactory dbContextFactory, ILogger logger ) : DailyScheduledService(logger) { /// 폴링 시작 시각(KST) — DailyPriceSync 13:10 직후. private static readonly TimeOnly StartTime = new(13, 10); /// 폴링 마감 시각(KST) — 이때까지 시세 미적재면 그날 skip. private static readonly TimeOnly Deadline = new(18, 0); /// 시세 적재 폴링 간격. private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10); protected override string JobName => "PaperFill"; protected override TimeOnly TargetTime => StartTime; protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { // 직전 영업일 = 이번에 적재될 시세의 TargetDate (DailyPriceSyncService 와 동일 기준) DateOnly targetDate; using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst, ct); } // 시세 적재 폴링 (10분 간격, 마감 시각까지) var loaded = await WaitForPricesAsync(targetDate, ct); if (!loaded) { Logger.LogWarning("[{Job}] targetDate={TargetDate} 시세 미적재 — 마감({Deadline}) 초과, 오늘 skip", JobName, targetDate, Deadline); return true; // 다음 날 다시 시도 } // 1) 체결 패스 — 주문 단위 독립 컨텍스트 (동시성 격리 + 멱등) var fillResult = await PaperBatchEngine.RunFillPassAsync(dbContextFactory.CreateDbContext, targetDate, ct); Logger.LogInformation("[{Job}] 체결 완료 — targetDate={TargetDate}, considered={Considered}, filled={Filled}, rejected={Rejected}, skipped={Skipped}, conflicts={Conflicts}", JobName, targetDate, fillResult.Considered, fillResult.Filled, fillResult.Rejected, fillResult.Skipped, fillResult.Conflicts); // 2) 스냅샷 패스 — 상폐 청산 + 좌수 NAV/수익률/MDD upsert (단일 컨텍스트) using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct); var feeRateBp = paper?.FeeRateBp ?? 15; var taxRateBp = paper?.TaxRateBp ?? 18; var snapResult = await PaperBatchEngine.RunSnapshotPassAsync(db, targetDate, feeRateBp, taxRateBp, ct); Logger.LogInformation("[{Job}] 스냅샷 완료 — targetDate={TargetDate}, liquidated={Liquidated}, snapshots={Snapshots}", JobName, targetDate, snapResult.Liquidated, snapResult.Snapshots); } return true; } /// targetDate 의 StockDailyPrice 가 적재될 때까지 마감 시각(KST)까지 10분 간격 폴링. private async Task WaitForPricesAsync(DateOnly targetDate, CancellationToken ct) { while (!ct.IsCancellationRequested) { using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (await db.StockDailyPrice.AsNoTracking().AnyAsync(c => c.TradingDate == targetDate, ct)) { return true; } } if (TimeOnly.FromDateTime(NowKst()) >= Deadline) { return false; } Logger.LogInformation("[{Job}] targetDate={TargetDate} 시세 미적재 — {Interval} 후 재확인", JobName, targetDate, PollInterval); try { await Task.Delay(PollInterval, ct); } catch (OperationCanceledException) { return false; } } return false; } }