From 0af556396e57fb98e86a3ae1c8d63e845df0c3c4 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Wed, 20 May 2026 16:27:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(match):=20=EC=A3=BC=EB=A7=90/=EA=B3=B5?= =?UTF-8?q?=ED=9C=B4=EC=9D=BC=20=EC=9D=B4=EC=9B=94=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?(target=5Fdate=20<=3D=20today=20+=20=EC=B5=9C=EC=B4=88=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=9D=BC=20=EC=A2=85=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/pipelines/match_outcomes.py | 108 +++++++++++++----------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/backend/app/pipelines/match_outcomes.py b/backend/app/pipelines/match_outcomes.py index 810462e..9a91915 100644 --- a/backend/app/pipelines/match_outcomes.py +++ b/backend/app/pipelines/match_outcomes.py @@ -1,13 +1,16 @@ """prediction_outcomes 매칭 배치. 평일 16:30 KST 에 실행. 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30) 의 -확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, target_date == 오늘인 -user_triggered=TRUE 예측을 그 종가와 매칭. +확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, 매칭 미해결 예측을 실제 +종가와 매칭한다. -cold-start / 휴장일 대비: 인자로 받은 target_date 의 ohlcv_daily 에 종가가 -없으면 자연스럽게 skip. 다음 거래일 매칭 잡이 다시 시도하면 그 날짜는 -여전히 매칭되지 않으므로 (매칭 sql 이 target_date 기준), 영원히 매칭 안되는 -잘못된 calendar date 예측은 cleanup CLI 로 별도 정리 가능 (Phase 7). +이월/공휴일 정책: + target_date 가 calendar date 라서 비거래일이면 ohlcv_daily 에 행이 없다. + 그래서 `target_date <= today` 인 미해결 행을 전부 후보로 잡고, 각 행마다 + `target_date <= ohlcv_daily.date <= today` 범위의 최초 거래일 종가로 + 매칭한다 (=다음 거래일로 자동 이월). + +shadow prediction 도 같은 방식으로 매칭한다 (user_triggered 필터 없음). """ from __future__ import annotations @@ -29,7 +32,7 @@ FLAT_BAND = 0.003 @dataclass class MatchSummary: - target_date: str + today: str candidates: int matched: int skipped_no_actual: int @@ -44,64 +47,62 @@ def _direction_label(ret: float) -> str: return "flat" -def match_for_date(d: date) -> MatchSummary: - """target_date == d 인 user_triggered=TRUE 예측을 매칭.""" +def match_up_to(today: date) -> MatchSummary: + """target_date <= today 인 모든 미해결 예측을 매칭. + + 각 행마다 ohlcv_daily 에서 target_date 이상, today 이하 범위의 최초 + 거래일 종가를 actual_close 로 사용 — 공휴일/주말 이월 자연 처리. + """ eng = get_engine() with eng.begin() as conn: - # 매칭 대상 예측 + 매칭 안 됐는지 확인. candidate_rows = conn.execute( text( """ - SELECT p.id, p.code, p.base_date, p.horizon, p.point_forecast, - p.direction, p.model + SELECT p.id, p.code, p.base_date, p.target_date, p.horizon, + p.point_forecast, p.direction, p.model FROM predictions p LEFT JOIN prediction_outcomes po ON po.prediction_id = p.id - WHERE p.target_date = :d - AND p.user_triggered = TRUE + WHERE p.target_date <= :today AND po.prediction_id IS NULL """ ), - {"d": d}, + {"today": today}, ).all() candidates = len(candidate_rows) if not candidates: - return MatchSummary(str(d), 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]) + return MatchSummary(str(today), 0, 0, 0, 0) matched = 0 skipped = 0 already = 0 - for pid, code, base_date, horizon, point_forecast, pred_dir, model in candidate_rows: - actual = actual_map.get((code, d)) - base_close = base_close_map.get((code, base_date)) - if actual is None or base_close is None: + for pid, code, base_date, target_date, horizon, point_forecast, pred_dir, model in candidate_rows: + # 첫 거래일 종가 (target_date <= date <= today) + actual_row = conn.execute( + 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 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_dir = _direction_label(actual_ret) dir_hit = (pred_dir == actual_dir) @@ -122,7 +123,8 @@ def match_for_date(d: date) -> MatchSummary: { "pid": pid, "code": code, - "d": d, + # 실제 매칭된 거래일 (이월된 경우 target_date 와 다를 수 있음) + "d": actual_date, "h": horizon, "m": model, "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 return MatchSummary( - target_date=str(d), + today=str(today), candidates=candidates, matched=matched, 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]: - """평일 16:30 KST 호출용. target_date == today (KST) 인 행 매칭.""" + """평일 16:30 KST 호출용. target_date <= today (KST) 인 미해결 행 일괄 매칭.""" from datetime import datetime, timezone, timedelta as td kst = timezone(td(hours=9)) today = datetime.now(kst).date() - summary = match_for_date(today) + summary = match_up_to(today) return { "today": str(today), "summary": summary.__dict__,