fix(match): 주말/공휴일 이월 매칭 (target_date <= today + 최초 거래일 종가)
- match_for_date(d) → match_up_to(today) 로 시맨틱 변경: target_date == d 대신 target_date <= today AND outcomes 미존재 전부 후보로 - 각 후보마다 ohlcv_daily 에서 target_date 이상 today 이하 범위의 최초 거래일 행을 actual_close 로 매칭 → 주말/공휴일 자동 이월 - user_triggered 필터 제거: chronos/lgbm shadow 행도 함께 매칭됨 - prediction_outcomes.target_date 에는 실제 매칭된 거래일을 기록 - 하위 호환: match_for_date(d) 는 match_up_to(d) 별칭으로 유지 리뷰어 지적 2번 (공휴일/주말이면 target_date 일치 행이 영원히 미매칭) 해결. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
"""prediction_outcomes 매칭 배치.
|
"""prediction_outcomes 매칭 배치.
|
||||||
|
|
||||||
평일 16:30 KST 에 실행. 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30) 의
|
평일 16:30 KST 에 실행. 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30) 의
|
||||||
확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, target_date == 오늘인
|
확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, 매칭 미해결 예측을 실제
|
||||||
user_triggered=TRUE 예측을 그 종가와 매칭.
|
종가와 매칭한다.
|
||||||
|
|
||||||
cold-start / 휴장일 대비: 인자로 받은 target_date 의 ohlcv_daily 에 종가가
|
이월/공휴일 정책:
|
||||||
없으면 자연스럽게 skip. 다음 거래일 매칭 잡이 다시 시도하면 그 날짜는
|
target_date 가 calendar date 라서 비거래일이면 ohlcv_daily 에 행이 없다.
|
||||||
여전히 매칭되지 않으므로 (매칭 sql 이 target_date 기준), 영원히 매칭 안되는
|
그래서 `target_date <= today` 인 미해결 행을 전부 후보로 잡고, 각 행마다
|
||||||
잘못된 calendar date 예측은 cleanup CLI 로 별도 정리 가능 (Phase 7).
|
`target_date <= ohlcv_daily.date <= today` 범위의 최초 거래일 종가로
|
||||||
|
매칭한다 (=다음 거래일로 자동 이월).
|
||||||
|
|
||||||
|
shadow prediction 도 같은 방식으로 매칭한다 (user_triggered 필터 없음).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ FLAT_BAND = 0.003
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MatchSummary:
|
class MatchSummary:
|
||||||
target_date: str
|
today: str
|
||||||
candidates: int
|
candidates: int
|
||||||
matched: int
|
matched: int
|
||||||
skipped_no_actual: int
|
skipped_no_actual: int
|
||||||
@@ -44,64 +47,62 @@ def _direction_label(ret: float) -> str:
|
|||||||
return "flat"
|
return "flat"
|
||||||
|
|
||||||
|
|
||||||
def match_for_date(d: date) -> MatchSummary:
|
def match_up_to(today: date) -> MatchSummary:
|
||||||
"""target_date == d 인 user_triggered=TRUE 예측을 매칭."""
|
"""target_date <= today 인 모든 미해결 예측을 매칭.
|
||||||
|
|
||||||
|
각 행마다 ohlcv_daily 에서 target_date 이상, today 이하 범위의 최초
|
||||||
|
거래일 종가를 actual_close 로 사용 — 공휴일/주말 이월 자연 처리.
|
||||||
|
"""
|
||||||
eng = get_engine()
|
eng = get_engine()
|
||||||
with eng.begin() as conn:
|
with eng.begin() as conn:
|
||||||
# 매칭 대상 예측 + 매칭 안 됐는지 확인.
|
|
||||||
candidate_rows = conn.execute(
|
candidate_rows = conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT p.id, p.code, p.base_date, p.horizon, p.point_forecast,
|
SELECT p.id, p.code, p.base_date, p.target_date, p.horizon,
|
||||||
p.direction, p.model
|
p.point_forecast, p.direction, p.model
|
||||||
FROM predictions p
|
FROM predictions p
|
||||||
LEFT JOIN prediction_outcomes po ON po.prediction_id = p.id
|
LEFT JOIN prediction_outcomes po ON po.prediction_id = p.id
|
||||||
WHERE p.target_date = :d
|
WHERE p.target_date <= :today
|
||||||
AND p.user_triggered = TRUE
|
|
||||||
AND po.prediction_id IS NULL
|
AND po.prediction_id IS NULL
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"d": d},
|
{"today": today},
|
||||||
).all()
|
).all()
|
||||||
candidates = len(candidate_rows)
|
candidates = len(candidate_rows)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return MatchSummary(str(d), 0, 0, 0, 0)
|
return MatchSummary(str(today), 0, 0, 0, 0)
|
||||||
|
|
||||||
# 종목별로 actual close 조회 (한번에 batch).
|
|
||||||
codes = list({r[1] for r in candidate_rows})
|
|
||||||
actual_map: dict[tuple[str, date], float] = {}
|
|
||||||
for code in codes:
|
|
||||||
row = conn.execute(
|
|
||||||
text(
|
|
||||||
"SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"
|
|
||||||
),
|
|
||||||
{"c": code, "d": d},
|
|
||||||
).first()
|
|
||||||
if row and row[0] is not None:
|
|
||||||
actual_map[(code, d)] = float(row[0])
|
|
||||||
|
|
||||||
# base_close (각 예측의 base_date 종가) 도 필요 — direction 판정용.
|
|
||||||
base_close_map: dict[tuple[str, date], float] = {}
|
|
||||||
for pid, code, base_date, *_ in candidate_rows:
|
|
||||||
key = (code, base_date)
|
|
||||||
if key in base_close_map:
|
|
||||||
continue
|
|
||||||
row = conn.execute(
|
|
||||||
text("SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"),
|
|
||||||
{"c": code, "d": base_date},
|
|
||||||
).first()
|
|
||||||
if row and row[0] is not None:
|
|
||||||
base_close_map[key] = float(row[0])
|
|
||||||
|
|
||||||
matched = 0
|
matched = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
already = 0
|
already = 0
|
||||||
for pid, code, base_date, horizon, point_forecast, pred_dir, model in candidate_rows:
|
for pid, code, base_date, target_date, horizon, point_forecast, pred_dir, model in candidate_rows:
|
||||||
actual = actual_map.get((code, d))
|
# 첫 거래일 종가 (target_date <= date <= today)
|
||||||
base_close = base_close_map.get((code, base_date))
|
actual_row = conn.execute(
|
||||||
if actual is None or base_close is None:
|
text(
|
||||||
|
"""
|
||||||
|
SELECT date, close FROM ohlcv_daily
|
||||||
|
WHERE code = :c AND date >= :td AND date <= :today
|
||||||
|
ORDER BY date ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"c": code, "td": target_date, "today": today},
|
||||||
|
).first()
|
||||||
|
if not actual_row or actual_row[1] is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
actual_date = actual_row[0]
|
||||||
|
actual = float(actual_row[1])
|
||||||
|
|
||||||
|
base_close_row = conn.execute(
|
||||||
|
text("SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"),
|
||||||
|
{"c": code, "d": base_date},
|
||||||
|
).first()
|
||||||
|
if not base_close_row or base_close_row[0] is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
base_close = float(base_close_row[0])
|
||||||
|
|
||||||
actual_ret = actual / base_close - 1.0
|
actual_ret = actual / base_close - 1.0
|
||||||
actual_dir = _direction_label(actual_ret)
|
actual_dir = _direction_label(actual_ret)
|
||||||
dir_hit = (pred_dir == actual_dir)
|
dir_hit = (pred_dir == actual_dir)
|
||||||
@@ -122,7 +123,8 @@ def match_for_date(d: date) -> MatchSummary:
|
|||||||
{
|
{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"code": code,
|
"code": code,
|
||||||
"d": d,
|
# 실제 매칭된 거래일 (이월된 경우 target_date 와 다를 수 있음)
|
||||||
|
"d": actual_date,
|
||||||
"h": horizon,
|
"h": horizon,
|
||||||
"m": model,
|
"m": model,
|
||||||
"pc": float(point_forecast) if point_forecast is not None else None,
|
"pc": float(point_forecast) if point_forecast is not None else None,
|
||||||
@@ -138,7 +140,7 @@ def match_for_date(d: date) -> MatchSummary:
|
|||||||
already += 1
|
already += 1
|
||||||
|
|
||||||
return MatchSummary(
|
return MatchSummary(
|
||||||
target_date=str(d),
|
today=str(today),
|
||||||
candidates=candidates,
|
candidates=candidates,
|
||||||
matched=matched,
|
matched=matched,
|
||||||
skipped_no_actual=skipped,
|
skipped_no_actual=skipped,
|
||||||
@@ -146,13 +148,19 @@ def match_for_date(d: date) -> MatchSummary:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 하위 호환 alias — 이전 시그니처를 쓰던 호출자 (예: 단일 날짜 매칭 테스트)
|
||||||
|
def match_for_date(d: date) -> MatchSummary:
|
||||||
|
"""legacy: target_date == d 만 매칭하던 동작 → 이제 target_date <= d 전체 처리."""
|
||||||
|
return match_up_to(d)
|
||||||
|
|
||||||
|
|
||||||
def match_today() -> dict[str, Any]:
|
def match_today() -> dict[str, Any]:
|
||||||
"""평일 16:30 KST 호출용. target_date == today (KST) 인 행 매칭."""
|
"""평일 16:30 KST 호출용. target_date <= today (KST) 인 미해결 행 일괄 매칭."""
|
||||||
from datetime import datetime, timezone, timedelta as td
|
from datetime import datetime, timezone, timedelta as td
|
||||||
|
|
||||||
kst = timezone(td(hours=9))
|
kst = timezone(td(hours=9))
|
||||||
today = datetime.now(kst).date()
|
today = datetime.now(kst).date()
|
||||||
summary = match_for_date(today)
|
summary = match_up_to(today)
|
||||||
return {
|
return {
|
||||||
"today": str(today),
|
"today": str(today),
|
||||||
"summary": summary.__dict__,
|
"summary": summary.__dict__,
|
||||||
|
|||||||
Reference in New Issue
Block a user