SeibroQuota.cs 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. namespace Application.Helpers;
  2. /// <summary>SEIBro 일일 호출 한도의 관리 단위 — API 카테고리 (docs/SEIBro/readme.md §1, getXrcStk* 는 Stock 소속)</summary>
  3. public enum SeibroCategory
  4. {
  5. Corp = 1,
  6. Stock = 2,
  7. Bond = 3,
  8. Deriv = 4,
  9. Foreign = 5
  10. }
  11. /// <summary>
  12. /// SEIBro 카테고리별 일일 호출 예산 트래커 (DI 싱글턴) — 같은 카테고리에 속한 여러 수집 배치가 하나의 카운터를 공유한다.
  13. /// KST 날짜가 바뀌면 카운터가 자동 리셋된다 (SEIBro 한도가 일 단위이므로).
  14. /// 예산 상한(dailyBudget)은 호출자가 설정(Seibro:*Budget)에서 주입한다 — 트래커는 소비량만 기억한다.
  15. /// ⚠️ in-memory 카운터라 프로세스 재시작 시 리셋되는 한계가 있다 — 예산이 실한도의 80% 라 재시작 후 재소비분은
  16. /// 20% 헤드룸이 흡수한다 (허용된 설계 트레이드오프, DB 카운터로의 승격은 운영 관찰 후 결정).
  17. /// </summary>
  18. public sealed class SeibroQuota
  19. {
  20. private readonly Lock _lock = new();
  21. private readonly Func<DateOnly> _todayKst;
  22. private readonly Dictionary<SeibroCategory, int> _used = [];
  23. private DateOnly _date;
  24. public SeibroQuota() : this(TodayKst) { }
  25. /// <summary>테스트용 — KST 오늘 날짜 공급자 주입 (날짜 롤오버 검증)</summary>
  26. public SeibroQuota(Func<DateOnly> todayKstProvider)
  27. {
  28. _todayKst = todayKstProvider;
  29. _date = _todayKst();
  30. }
  31. /// <summary>count 만큼 소비 시도 — 예산 내면 소비 후 true, 초과면 소비 없이 false. count 가 0 이하면 항상 true.</summary>
  32. public bool TryConsume(SeibroCategory category, int count, int dailyBudget)
  33. {
  34. if (count <= 0)
  35. {
  36. return true;
  37. }
  38. lock (_lock)
  39. {
  40. RolloverIfNeeded();
  41. var used = _used.GetValueOrDefault(category);
  42. if (used + count > dailyBudget)
  43. {
  44. return false;
  45. }
  46. _used[category] = used + count;
  47. return true;
  48. }
  49. }
  50. /// <summary>오늘 남은 예산 (음수 없음) — rolling sweep 의 maxPerRun 산정용</summary>
  51. public int Remaining(SeibroCategory category, int dailyBudget)
  52. {
  53. lock (_lock)
  54. {
  55. RolloverIfNeeded();
  56. return Math.Max(0, dailyBudget - _used.GetValueOrDefault(category));
  57. }
  58. }
  59. /// <summary>오늘 소비량 (로그·모니터링용)</summary>
  60. public int Used(SeibroCategory category)
  61. {
  62. lock (_lock)
  63. {
  64. RolloverIfNeeded();
  65. return _used.GetValueOrDefault(category);
  66. }
  67. }
  68. private void RolloverIfNeeded()
  69. {
  70. var today = _todayKst();
  71. if (today == _date)
  72. {
  73. return;
  74. }
  75. _date = today;
  76. _used.Clear();
  77. }
  78. private static DateOnly TodayKst()
  79. {
  80. return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst));
  81. }
  82. // KST 는 DST 없음 — 시스템에 타임존 데이터가 없으면 고정 +9 fallback (DailyScheduledService 와 동일 규칙)
  83. private static readonly TimeZoneInfo Kst = ResolveKst();
  84. private static TimeZoneInfo ResolveKst()
  85. {
  86. if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
  87. {
  88. return tz;
  89. }
  90. if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
  91. {
  92. return tz;
  93. }
  94. return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
  95. }
  96. }