feat(runtime): dcy-014 trading_date KST 타임존 분리 + 일일 슬롯 preallocation (BAT-018)

데몬이 시스템 UTC 날짜로 슬롯을 1회만 preallocate 해 trading_date 가 KST 캘린더와
어긋나고 재시작 없이는 다음날 슬롯이 안 생기던 문제(issue #14) 정정.

거버넌스: 전부 change-req (신규 REQ 없음, dcy-014 OQ-3; 스케줄러는 REQ-030 패턴 준용).
- REQ-005: trading_date 를 설정 타임존(기본 Asia/Seoul) 기준 계산(timestamp 는 UTC
  유지). DailyPreallocationScheduler 신규 — TZ 일자당 1회 멱등 fire, 재시작 없이
  다음날 슬롯 자동 생성. setup() 배선 + trex-preallocation 스레드
- REQ-048: TOML trading_timezone/daily_preallocation_time 키 + from_config 전파
- REQ-009: runtime-flows/data-model 정합 + instance.example.toml

OQ-1 기본 00:00(TZ 자정, TOML 설정 가능) / OQ-2 매 캘린더일 생성(거래일 캘린더 후속).
멱등 preallocate 로 중복 tick·다중 인스턴스 중복 생성 0.

검증: 단위 302 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 334 passed, ruff clean.
신규 test_preallocation_scheduler(고정 clock) + test_instance_config 확장.
BAT-018 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:39:04 +09:00
co-authored by Claude Opus 4.8
parent 4746f74aae
commit 0b7db66463
20 changed files with 818 additions and 15 deletions
@@ -0,0 +1,109 @@
"""Daily preallocation scheduler tests (dcy-014 / REQ-005, issue #14).
trading_date is derived in a configurable timezone (default KST) with an
injected clock; timestamps stay UTC. Covers KST/UTC boundary, cross-day
auto-create + idempotency, configurable TZ, and trigger-time gating.
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from trex_core.application.preallocation_scheduler import DailyPreallocationScheduler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
KST = ZoneInfo("Asia/Seoul")
UTC = timezone.utc
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo)
return repo, svc
def _dates(repo):
return sorted({s.trading_date for s in repo._slots.values()})
def _count(repo, d):
return sum(1 for s in repo._slots.values() if s.trading_date == d)
def test_trading_date_uses_kst_not_utc_at_midnight_boundary():
"""S-1: 2026-06-08 15:30Z is already 2026-06-09 in KST."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
sched = DailyPreallocationScheduler(
slot_service=svc, total_slots=10, tz=KST, clock=clock,
)
td = sched.run_once()
assert td == date(2026, 6, 9) # KST date, not UTC 6/8
assert _dates(repo) == [date(2026, 6, 9)]
assert _count(repo, date(2026, 6, 9)) == 10
def test_timestamps_stay_utc():
"""S-2: slot created_at/updated_at remain UTC even with KST trading_date."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
DailyPreallocationScheduler(
slot_service=svc, total_slots=1, tz=KST, clock=clock,
).run_once()
slot = next(iter(repo._slots.values()))
assert slot.updated_at.utcoffset() == timedelta(0) # UTC
def test_cross_day_boundary_auto_creates_next_day_idempotently():
"""S-3: a day boundary creates next-day slots once; re-ticks add nothing."""
repo, svc = _wire()
now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 2026-06-08 01:00 KST
sched = DailyPreallocationScheduler(
slot_service=svc, total_slots=10, tz=KST, clock=lambda: now["t"],
)
sched.run_once() # boot -> 6/8
assert _dates(repo) == [date(2026, 6, 8)]
now["t"] = datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # 2026-06-09 00:30 KST
sched._maybe_tick() # boundary -> 6/9
assert _dates(repo) == [date(2026, 6, 8), date(2026, 6, 9)]
now["t"] = datetime(2026, 6, 8, 16, 30, tzinfo=UTC) # still 6/9 KST
sched._maybe_tick() # already fired today
assert _count(repo, date(2026, 6, 9)) == 10 # no duplicates
def test_configurable_timezone_changes_trading_date():
"""S-4: same instant yields a different trading_date under a different TZ."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
DailyPreallocationScheduler(
slot_service=svc, total_slots=1, tz=UTC, clock=clock,
).run_once()
assert _dates(repo) == [date(2026, 6, 8)] # UTC date, not KST 6/9
def test_trigger_time_gates_next_day_creation():
"""A configured trigger time defers next-day creation until reached."""
repo, svc = _wire()
now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 6/8 01:00 KST
sched = DailyPreallocationScheduler(
slot_service=svc, total_slots=5, tz=KST,
schedule_time=time(hour=7), clock=lambda: now["t"],
)
sched.run_once() # boot -> 6/8
now["t"] = datetime(2026, 6, 8, 18, 0, tzinfo=UTC) # 6/9 03:00 KST (< 07:00)
sched._maybe_tick()
assert date(2026, 6, 9) not in _dates(repo) # not yet
now["t"] = datetime(2026, 6, 8, 22, 30, tzinfo=UTC) # 6/9 07:30 KST (>= 07:00)
sched._maybe_tick()
assert date(2026, 6, 9) in _dates(repo) # created after trigger
def test_idle_slots_created_for_trading_date():
"""Created slots are idle (active only on buy admission)."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 1, 0, tzinfo=UTC) # noqa: E731
DailyPreallocationScheduler(
slot_service=svc, total_slots=3, tz=KST, clock=clock,
).run_once()
assert all(s.status is SlotState.IDLE for s in repo._slots.values())
+21
View File
@@ -51,6 +51,27 @@ def test_default_instance_key_when_from_env(monkeypatch):
assert DaemonConfig.from_env().instance_key == "default"
def test_trading_timezone_defaults_to_kst(tmp_path, monkeypatch):
"""dcy-014 / REQ-048: trading TZ + daily trigger default to KST / midnight."""
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
inst = DaemonInstanceConfig.from_toml(_write_toml(tmp_path, 'instance_key = "i1"\n'))
assert inst.trading_timezone == "Asia/Seoul"
assert inst.daily_preallocation_time == "00:00"
assert DaemonConfig.from_env().trading_timezone == "Asia/Seoul"
def test_trading_timezone_from_toml_propagates(tmp_path, monkeypatch):
"""dcy-014 / REQ-048: TOML trading_timezone + trigger flow into DaemonConfig."""
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
path = _write_toml(
tmp_path,
'instance_key = "i2"\ntrading_timezone = "UTC"\ndaily_preallocation_time = "07:30"\n',
)
cfg = DaemonConfig.from_config(path)
assert cfg.trading_timezone == "UTC"
assert cfg.daily_preallocation_time == "07:30"
def test_config_required_cli_exits_nonzero():
"""AC-2: daemon entry without --config exits non-zero (argparse required)."""
proc = subprocess.run(