PaperFillService.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Paper;
  3. using Application.Helpers;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Logging;
  7. namespace Infrastructure.StockData;
  8. /// <summary>
  9. /// 모의투자 체결·스냅샷 배치 (d4 M2). KST 13:10 기동 후 직전 영업일(TargetDate) 시세 적재를 폴링하고
  10. /// (StockDailyPrice 존재 여부, 10분 간격 ~18:00), 적재되면 체결 패스 → 스냅샷 패스를 1회 수행한다.
  11. ///
  12. /// DailyPriceSyncService(13:10) 가 시세를 먼저 넣으므로 그 뒤에 자연히 통과한다. 시세 미적재 상태로
  13. /// 마감 시각을 넘기면 그날은 skip 하고 다음 날 다시 시도한다. BackgroundJobs:PaperFill 플래그로 토글,
  14. /// Web.Api 전용(중복 실행 방지). 체결은 주문 단위 컨텍스트로 격리(PaperBatchEngine 참조).
  15. /// </summary>
  16. internal sealed class PaperFillService(
  17. IServiceScopeFactory scopeFactory,
  18. IAppDbContextFactory dbContextFactory,
  19. IPaperLeaderboardCache leaderboardCache,
  20. ILogger<PaperFillService> logger
  21. ) : DailyScheduledService(logger)
  22. {
  23. /// <summary>폴링 시작 시각(KST) — DailyPriceSync 13:10 직후.</summary>
  24. private static readonly TimeOnly StartTime = new(13, 10);
  25. /// <summary>폴링 마감 시각(KST) — 이때까지 시세 미적재면 그날 skip.</summary>
  26. private static readonly TimeOnly Deadline = new(18, 0);
  27. /// <summary>시세 적재 폴링 간격.</summary>
  28. private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10);
  29. protected override string JobName => "PaperFill";
  30. protected override TimeOnly TargetTime => StartTime;
  31. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  32. {
  33. // 직전 영업일 = 이번에 적재될 시세의 TargetDate (DailyPriceSyncService 와 동일 기준)
  34. DateOnly targetDate;
  35. using (var scope = scopeFactory.CreateScope())
  36. {
  37. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  38. targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst, ct);
  39. }
  40. // 시세 적재 폴링 (10분 간격, 마감 시각까지)
  41. var loaded = await WaitForPricesAsync(targetDate, ct);
  42. if (!loaded)
  43. {
  44. Logger.LogWarning("[{Job}] targetDate={TargetDate} 시세 미적재 — 마감({Deadline}) 초과, 오늘 skip", JobName, targetDate, Deadline);
  45. return true; // 다음 날 다시 시도
  46. }
  47. // 1) 체결 패스 — 주문 단위 독립 컨텍스트 (동시성 격리 + 멱등)
  48. var fillResult = await PaperBatchEngine.RunFillPassAsync(dbContextFactory.CreateDbContext, targetDate, ct);
  49. Logger.LogInformation("[{Job}] 체결 완료 — targetDate={TargetDate}, considered={Considered}, filled={Filled}, rejected={Rejected}, skipped={Skipped}, conflicts={Conflicts}",
  50. JobName, targetDate, fillResult.Considered, fillResult.Filled, fillResult.Rejected, fillResult.Skipped, fillResult.Conflicts);
  51. // 2) 스냅샷 패스 — 상폐 청산 + 좌수 NAV/수익률/MDD upsert (단일 컨텍스트)
  52. using (var scope = scopeFactory.CreateScope())
  53. {
  54. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  55. var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct);
  56. var feeRateBp = paper?.FeeRateBp ?? 15;
  57. var taxRateBp = paper?.TaxRateBp ?? 18;
  58. var snapResult = await PaperBatchEngine.RunSnapshotPassAsync(db, targetDate, feeRateBp, taxRateBp, ct);
  59. Logger.LogInformation("[{Job}] 스냅샷 완료 — targetDate={TargetDate}, liquidated={Liquidated}, snapshots={Snapshots}",
  60. JobName, targetDate, snapResult.Liquidated, snapResult.Snapshots);
  61. }
  62. // 스냅샷 반영 후 리더보드 캐시 무효화 → 다음 조회가 최신 순위로 재시드 (warm-refresh)
  63. await leaderboardCache.InvalidateAllAsync(ct);
  64. return true;
  65. }
  66. /// <summary>targetDate 의 StockDailyPrice 가 적재될 때까지 마감 시각(KST)까지 10분 간격 폴링.</summary>
  67. private async Task<bool> WaitForPricesAsync(DateOnly targetDate, CancellationToken ct)
  68. {
  69. while (!ct.IsCancellationRequested)
  70. {
  71. using (var scope = scopeFactory.CreateScope())
  72. {
  73. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  74. if (await db.StockDailyPrice.AsNoTracking().AnyAsync(c => c.TradingDate == targetDate, ct))
  75. {
  76. return true;
  77. }
  78. }
  79. if (TimeOnly.FromDateTime(NowKst()) >= Deadline)
  80. {
  81. return false;
  82. }
  83. Logger.LogInformation("[{Job}] targetDate={TargetDate} 시세 미적재 — {Interval} 후 재확인", JobName, targetDate, PollInterval);
  84. try
  85. {
  86. await Task.Delay(PollInterval, ct);
  87. }
  88. catch (OperationCanceledException)
  89. {
  90. return false;
  91. }
  92. }
  93. return false;
  94. }
  95. }