PaperFillService.cs 4.9 KB

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