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:
@@ -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())
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user