Skip to content

Commit c3e08e1

Browse files
committed
target-weight pilot 날짜/달력 헬퍼 모듈 분리 (모놀리스 1차 축소)
13,500줄 모놀리스(target_weight_rotation_pilot.py)에서 순수 KRX 거래일·날짜 로직을 tools/pilot_calendar.py로 분리했다. 동작 변화 0 — _접두어 이름을 re-import해 하위 호환을 유지하고, KST/TARGET_WEIGHT_PILOT_TARGET_DAYS 상수도 거기서 가져온다. 모놀리스 13521 → 13458줄. - pilot_calendar: split_symbols, date_range, load_kr_market_holidays, is_kr_market_business_day, pilot_valid_to, next_kr_market_business_day, coerce_kst_datetime, execution_day (+ _접두어 별칭). - 분리 전 전용 테스트가 없던 헬퍼에 test_pilot_calendar.py 18개 추가(거래일·휴장일· KST 변환 경계 검증). - 기존 테스트 2개가 twp._load_kr_market_holidays를 패치했는데, 의존성 조회 소스가 pilot_calendar로 옮겨졌으므로 pc.load_kr_market_holidays 패치로 정합화. 검증: 전체 1344 테스트 통과. pilot --help 및 헬퍼 접근성 스모크 정상.
1 parent 2998b6f commit c3e08e1

4 files changed

Lines changed: 240 additions & 81 deletions

File tree

‎tests/test_pilot_calendar.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""tools/pilot_calendar.py — KRX 거래일·날짜 헬퍼 단위 테스트.
2+
3+
모놀리스(target_weight_rotation_pilot.py)에서 분리한 순수 함수들. 분리 전에는 전용
4+
테스트가 없었으므로 이번에 커버리지를 추가한다. 휴장일은 주입해 결정론적으로 검증.
5+
"""
6+
from datetime import datetime
7+
8+
import pytest
9+
10+
from tools import pilot_calendar as pc
11+
12+
13+
class TestSplitSymbols:
14+
def test_none_returns_none(self):
15+
assert pc.split_symbols(None) is None
16+
17+
def test_comma_and_newline(self):
18+
assert pc.split_symbols("005930, 000660\n035420") == ["005930", "000660", "035420"]
19+
20+
def test_empty_filtered(self):
21+
assert pc.split_symbols("005930,, ,000660") == ["005930", "000660"]
22+
23+
24+
class TestDateRange:
25+
def test_weekdays_only(self):
26+
# 2024-01-01(월)~01-07(일): 평일 5개(1,2,3,4,5)
27+
out = pc.date_range("2024-01-01", "2024-01-07")
28+
assert out == ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"]
29+
30+
def test_end_before_start_raises(self):
31+
with pytest.raises(ValueError):
32+
pc.date_range("2024-01-10", "2024-01-01")
33+
34+
def test_only_weekend_raises(self):
35+
# 2024-01-06(토)~01-07(일): 평일 없음
36+
with pytest.raises(ValueError):
37+
pc.date_range("2024-01-06", "2024-01-07")
38+
39+
40+
class TestBusinessDay:
41+
def test_weekday_no_holiday(self):
42+
from datetime import date
43+
assert pc.is_kr_market_business_day(date(2024, 1, 2), set()) is True
44+
45+
def test_weekend_is_not_business_day(self):
46+
from datetime import date
47+
assert pc.is_kr_market_business_day(date(2024, 1, 6), set()) is False # 토
48+
49+
def test_holiday_is_not_business_day(self):
50+
from datetime import date
51+
assert pc.is_kr_market_business_day(date(2024, 1, 2), {"2024-01-02"}) is False
52+
53+
54+
class TestPilotValidTo:
55+
def test_counts_business_days(self, monkeypatch):
56+
# 휴장일 없음 가정 → 2024-01-02(화)부터 5영업일째
57+
monkeypatch.setattr(pc, "load_kr_market_holidays", lambda: set())
58+
# 1/2(1), 1/3(2), 1/4(3), 1/5(4), 주말, 1/8(5)
59+
assert pc.pilot_valid_to("2024-01-02", 5) == "2024-01-08"
60+
61+
def test_skips_holiday(self, monkeypatch):
62+
monkeypatch.setattr(pc, "load_kr_market_holidays", lambda: {"2024-01-03"})
63+
# 1/2(1), 1/3 휴장, 1/4(2), 1/5(3), 주말, 1/8(4), 1/9(5)
64+
assert pc.pilot_valid_to("2024-01-02", 5) == "2024-01-09"
65+
66+
def test_non_positive_raises(self):
67+
with pytest.raises(ValueError):
68+
pc.pilot_valid_to("2024-01-02", 0)
69+
70+
71+
class TestNextBusinessDay:
72+
def test_skips_weekend(self, monkeypatch):
73+
monkeypatch.setattr(pc, "load_kr_market_holidays", lambda: set())
74+
# 금(1/5) 다음 영업일 = 월(1/8)
75+
assert pc.next_kr_market_business_day("2024-01-05") == "2024-01-08"
76+
77+
def test_skips_holiday(self, monkeypatch):
78+
monkeypatch.setattr(pc, "load_kr_market_holidays", lambda: {"2024-01-03"})
79+
# 화(1/2) 다음 = 수(1/3) 휴장 → 목(1/4)
80+
assert pc.next_kr_market_business_day("2024-01-02") == "2024-01-04"
81+
82+
83+
class TestKstHelpers:
84+
def test_coerce_naive_kept(self):
85+
dt = datetime(2024, 1, 2, 10, 0, 0)
86+
assert pc.coerce_kst_datetime(dt) == dt
87+
88+
def test_coerce_aware_converted_to_kst_naive(self):
89+
from datetime import timezone as tz, timedelta
90+
utc_dt = datetime(2024, 1, 2, 1, 0, 0, tzinfo=tz.utc) # UTC 01:00 = KST 10:00
91+
out = pc.coerce_kst_datetime(utc_dt)
92+
assert out.tzinfo is None
93+
assert out.hour == 10
94+
95+
def test_execution_day_format(self):
96+
assert pc.execution_day(datetime(2024, 3, 5, 15, 30)) == "2024-03-05"
97+
98+
99+
def test_backcompat_aliases_exist():
100+
"""모놀리스가 쓰던 _접두어 별칭이 그대로 노출되는지."""
101+
for name in ("_split_symbols", "_date_range", "_load_kr_market_holidays",
102+
"_is_kr_market_business_day", "_pilot_valid_to",
103+
"_next_kr_market_business_day", "_coerce_kst_datetime", "_execution_day"):
104+
assert hasattr(pc, name)

‎tests/test_target_weight_paper_adapter.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8398,8 +8398,10 @@ def test_pilot_plan_validation_uses_net_exposure_increase_for_existing_positions
83988398

83998399
def test_pilot_valid_to_counts_inclusive_krx_business_days(monkeypatch):
84008400
import tools.target_weight_rotation_pilot as twp
8401+
import tools.pilot_calendar as pc
84018402

8402-
monkeypatch.setattr(twp, "_load_kr_market_holidays", lambda: {"2026-04-14"})
8403+
# 날짜 헬퍼는 tools/pilot_calendar로 분리됨 — 휴장일 로더는 그 모듈에서 조회한다.
8404+
monkeypatch.setattr(pc, "load_kr_market_holidays", lambda: {"2026-04-14"})
84038405

84048406
assert twp._pilot_valid_to("2026-04-10", target_pilot_days=3) == "2026-04-15"
84058407

@@ -9416,7 +9418,9 @@ def test_build_target_weight_daily_ops_summary_marks_today_recorded(tmp_path, mo
94169418
write_target_weight_daily_ops_summary,
94179419
)
94189420

9419-
monkeypatch.setattr(twp, "_load_kr_market_holidays", lambda: {"2026-04-13"})
9421+
import tools.pilot_calendar as pc
9422+
# 날짜 헬퍼는 tools/pilot_calendar로 분리됨 — 휴장일 로더는 그 모듈에서 조회한다.
9423+
monkeypatch.setattr(pc, "load_kr_market_holidays", lambda: {"2026-04-13"})
94209424
plan = _adapter_plan()
94219425
cap_recommendation = recommend_pilot_caps(plan)
94229426
pass_check = {

‎tools/pilot_calendar.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""target-weight pilot의 KRX 거래일·날짜 계산 헬퍼.
2+
3+
target_weight_rotation_pilot.py(13,500줄 모놀리스)에서 순수 날짜/달력 로직만 분리한
4+
모듈이다. 외부 상태에 의존하지 않고(휴장일은 core.trading_hours에서 lazy load) stdlib만
5+
쓰므로 단독 테스트가 쉽다. 기존 동작과 100% 동일하며, 모놀리스는 이 모듈을 re-import해
6+
하위 호환을 유지한다.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import date, datetime, timedelta, timezone
12+
13+
from loguru import logger
14+
15+
# 한국 표준시(KST). pilot 실행일·세션ID 계산 기준.
16+
KST = timezone(timedelta(hours=9))
17+
18+
# pilot 윈도우 기본 길이(영업일). _pilot_valid_to 기본값.
19+
TARGET_WEIGHT_PILOT_TARGET_DAYS = 60
20+
21+
22+
def split_symbols(raw: str | None) -> list[str] | None:
23+
"""쉼표/개행 구분 종목 문자열을 리스트로. None이면 None."""
24+
if raw is None:
25+
return None
26+
symbols = [part.strip() for part in raw.replace("\n", ",").split(",")]
27+
return [symbol for symbol in symbols if symbol]
28+
29+
30+
def date_range(start_date: str, end_date: str) -> list[str]:
31+
"""start~end(포함) 사이의 평일(월~금) 날짜 문자열 리스트."""
32+
start = datetime.strptime(start_date, "%Y-%m-%d").date()
33+
end = datetime.strptime(end_date, "%Y-%m-%d").date()
34+
if end < start:
35+
raise ValueError("shadow end date must be on or after start date")
36+
37+
dates: list[str] = []
38+
day = start
39+
while day <= end:
40+
if day.weekday() < 5:
41+
dates.append(day.strftime("%Y-%m-%d"))
42+
day += timedelta(days=1)
43+
if not dates:
44+
raise ValueError("shadow date range contains no weekdays")
45+
return dates
46+
47+
48+
def load_kr_market_holidays() -> set[str]:
49+
"""KRX 휴장일 집합(ISO 날짜 문자열). 조회 실패 시 빈 집합."""
50+
try:
51+
from core.trading_hours import _load_holidays
52+
53+
return {str(day).strip() for day in _load_holidays() if str(day).strip()}
54+
except Exception as exc:
55+
logger.debug("KRX holiday lookup skipped for pilot window calculation: {}", exc)
56+
return set()
57+
58+
59+
def is_kr_market_business_day(day: date, holidays: set[str]) -> bool:
60+
"""평일이면서 휴장일이 아니면 True."""
61+
return day.weekday() < 5 and day.isoformat() not in holidays
62+
63+
64+
def pilot_valid_to(
65+
valid_from: str,
66+
target_pilot_days: int = TARGET_WEIGHT_PILOT_TARGET_DAYS,
67+
) -> str:
68+
"""valid_from부터 target_pilot_days 영업일째 되는 날(포함)을 ISO로 반환."""
69+
if target_pilot_days <= 0:
70+
raise ValueError("target_pilot_days must be positive")
71+
72+
current = datetime.strptime(valid_from, "%Y-%m-%d").date()
73+
holidays = load_kr_market_holidays()
74+
counted_days = 0
75+
while True:
76+
if is_kr_market_business_day(current, holidays):
77+
counted_days += 1
78+
if counted_days >= target_pilot_days:
79+
return current.isoformat()
80+
current += timedelta(days=1)
81+
82+
83+
def next_kr_market_business_day(day: str) -> str:
84+
"""주어진 날의 다음 KRX 영업일을 ISO로 반환."""
85+
current = datetime.strptime(day, "%Y-%m-%d").date() + timedelta(days=1)
86+
holidays = load_kr_market_holidays()
87+
while not is_kr_market_business_day(current, holidays):
88+
current += timedelta(days=1)
89+
return current.isoformat()
90+
91+
92+
def coerce_kst_datetime(now: datetime | None = None) -> datetime:
93+
"""now를 KST naive datetime으로 정규화(미지정 시 현재 KST)."""
94+
current = now or datetime.now(KST)
95+
if current.tzinfo is not None:
96+
current = current.astimezone(KST).replace(tzinfo=None)
97+
return current
98+
99+
100+
def execution_day(now: datetime | None = None) -> str:
101+
"""KST 기준 실행일(YYYY-MM-DD)."""
102+
current = coerce_kst_datetime(now)
103+
return current.date().strftime("%Y-%m-%d")
104+
105+
106+
# ── 하위 호환: 모놀리스가 쓰던 _접두어 이름을 별칭으로 노출 ──
107+
_split_symbols = split_symbols
108+
_date_range = date_range
109+
_load_kr_market_holidays = load_kr_market_holidays
110+
_is_kr_market_business_day = is_kr_market_business_day
111+
_pilot_valid_to = pilot_valid_to
112+
_next_kr_market_business_day = next_kr_market_business_day
113+
_coerce_kst_datetime = coerce_kst_datetime
114+
_execution_day = execution_day

‎tools/target_weight_rotation_pilot.py‎

Lines changed: 16 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@
3636
validate_plan_against_pilot,
3737
)
3838

39+
# KRX 거래일·날짜 헬퍼는 tools/pilot_calendar.py로 분리(모놀리스 축소). 하위 호환 위해
40+
# _접두어 이름 그대로 re-import한다. KST / TARGET_WEIGHT_PILOT_TARGET_DAYS 도 여기서 온다.
41+
from tools.pilot_calendar import (
42+
KST,
43+
TARGET_WEIGHT_PILOT_TARGET_DAYS,
44+
_split_symbols,
45+
_date_range,
46+
_load_kr_market_holidays,
47+
_is_kr_market_business_day,
48+
_pilot_valid_to,
49+
_next_kr_market_business_day,
50+
_coerce_kst_datetime,
51+
_execution_day,
52+
)
53+
3954
DEFAULT_OUTPUT_DIR = Path("reports/paper_runtime")
4055
DEFAULT_PILOT_PREVIEW_CAPS = {
4156
"max_orders_per_day": 2,
@@ -48,10 +63,9 @@
4863
DEFAULT_SHADOW_SCAN_MULTIPLIER = 5
4964
DEFAULT_LIQUIDITY_LOOKBACK_DAYS = 20
5065
DEFAULT_MAX_ORDER_ADV_PCT = 5.0
51-
TARGET_WEIGHT_PILOT_TARGET_DAYS = 60
66+
# TARGET_WEIGHT_PILOT_TARGET_DAYS, KST 는 tools/pilot_calendar.py에서 import (위 import 블록).
5267
AUTHORIZATION_SNAPSHOT_SCHEMA_VERSION = 1
5368
AUTHORIZATION_SNAPSHOT_TYPE = "target_weight_plan_authorization"
54-
KST = timezone(timedelta(hours=9))
5569
NO_ORDER_OPERATION_ERRORS = (ValueError, DataCollectionError)
5670
REPAIRABLE_TARGET_WEIGHT_EVIDENCE_REASONS = {
5771
"target_weight_benchmark_status_not_final",
@@ -88,83 +102,6 @@ def _stable_manifest_hash(payload: dict[str, Any]) -> str:
88102
return hashlib.sha256(encoded).hexdigest()
89103

90104

91-
def _split_symbols(raw: str | None) -> list[str] | None:
92-
if raw is None:
93-
return None
94-
symbols = [part.strip() for part in raw.replace("\n", ",").split(",")]
95-
return [symbol for symbol in symbols if symbol]
96-
97-
98-
def _date_range(start_date: str, end_date: str) -> list[str]:
99-
start = datetime.strptime(start_date, "%Y-%m-%d").date()
100-
end = datetime.strptime(end_date, "%Y-%m-%d").date()
101-
if end < start:
102-
raise ValueError("shadow end date must be on or after start date")
103-
104-
dates: list[str] = []
105-
day = start
106-
while day <= end:
107-
if day.weekday() < 5:
108-
dates.append(day.strftime("%Y-%m-%d"))
109-
day += timedelta(days=1)
110-
if not dates:
111-
raise ValueError("shadow date range contains no weekdays")
112-
return dates
113-
114-
115-
def _load_kr_market_holidays() -> set[str]:
116-
try:
117-
from core.trading_hours import _load_holidays
118-
119-
return {str(day).strip() for day in _load_holidays() if str(day).strip()}
120-
except Exception as exc:
121-
logger.debug("KRX holiday lookup skipped for pilot window calculation: {}", exc)
122-
return set()
123-
124-
125-
def _is_kr_market_business_day(day: date, holidays: set[str]) -> bool:
126-
return day.weekday() < 5 and day.isoformat() not in holidays
127-
128-
129-
def _pilot_valid_to(
130-
valid_from: str,
131-
target_pilot_days: int = TARGET_WEIGHT_PILOT_TARGET_DAYS,
132-
) -> str:
133-
"""Return an inclusive KRX business-day pilot window end date."""
134-
if target_pilot_days <= 0:
135-
raise ValueError("target_pilot_days must be positive")
136-
137-
current = datetime.strptime(valid_from, "%Y-%m-%d").date()
138-
holidays = _load_kr_market_holidays()
139-
counted_days = 0
140-
while True:
141-
if _is_kr_market_business_day(current, holidays):
142-
counted_days += 1
143-
if counted_days >= target_pilot_days:
144-
return current.isoformat()
145-
current += timedelta(days=1)
146-
147-
148-
def _next_kr_market_business_day(day: str) -> str:
149-
current = datetime.strptime(day, "%Y-%m-%d").date() + timedelta(days=1)
150-
holidays = _load_kr_market_holidays()
151-
while not _is_kr_market_business_day(current, holidays):
152-
current += timedelta(days=1)
153-
return current.isoformat()
154-
155-
156-
def _coerce_kst_datetime(now: datetime | None = None) -> datetime:
157-
current = now or datetime.now(KST)
158-
if current.tzinfo is not None:
159-
current = current.astimezone(KST).replace(tzinfo=None)
160-
return current
161-
162-
163-
def _execution_day(now: datetime | None = None) -> str:
164-
current = _coerce_kst_datetime(now)
165-
return current.date().strftime("%Y-%m-%d")
166-
167-
168105
def _require_not_future_as_of_date(
169106
as_of_date: str | None,
170107
*,

0 commit comments

Comments
 (0)