using Application.Abstractions.Data;
using Application.Abstractions.Paper;
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,
IPaperLeaderboardCache leaderboardCache,
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);
}
// 스냅샷 반영 후 리더보드 캐시 무효화 → 다음 조회가 최신 순위로 재시드 (warm-refresh)
await leaderboardCache.InvalidateAllAsync(ct);
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;
}
}