From e6105998796f56fd1485746980dc7c2c53de3f20 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Sat, 23 May 2026 01:41:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(chart):=2010=EB=B6=84=EB=B4=89=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98,=20=EC=9E=A5?= =?UTF-8?q?=EC=99=B8=20=EC=BA=90=EC=8B=9C,=20=EC=98=88=EC=B8=A1=20horizon?= =?UTF-8?q?=20=EC=BA=A1=20=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewer 지적사항 반영: 1. KIS 분봉이 한 번에 30개만 와서 10분봉이 최대 3개만 나오던 문제 → fetch_minute_range() 추가. FID_INPUT_HOUR_1 을 30분씩 후퇴시키며 페이지네이션, 중복 ts 자연 dedupe, max_pages=20 으로 무한루프 방지. _ensure_intraday_fresh 는 last_ts+1m ~ now 빈 구간만 채우므로 평소엔 1~2 페이지로 끝남. 2. 장외/주말에 매번 KIS 를 때리던 문제 → - 주말: 'weekend' 반환, KIS 안 부름 (분봉 endpoint 는 당일만 지원) - 평일 장외 + 오늘 데이터 있음: 'cached_closed' 반환 - 장중 + 10분 이내: 'fresh' 반환 토큰/조회 제한 다시 밟지 않음. 3. 프론트 horizon 입력 60 vs 백엔드 30 불일치 → PredictionPanel 의 cap 을 30 으로 맞춤. 백엔드 predict.py 의 학습/검증 범위와 일치. placeholder 도 '1~30' 으로 명시. --- backend/app/api/chart.py | 58 ++++++++++++++++++++++-------- backend/app/fetch/kis.py | 53 +++++++++++++++++++++++++++ web/components/PredictionPanel.tsx | 8 +++-- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/backend/app/api/chart.py b/backend/app/api/chart.py index 7740392..a4f92de 100644 --- a/backend/app/api/chart.py +++ b/backend/app/api/chart.py @@ -138,32 +138,60 @@ def _intraday_window_today() -> tuple[datetime, datetime]: def _ensure_intraday_fresh(conn, code: str) -> str: - """마지막 ohlcv_1m 데이터가 10분 이상 오래됐으면 KIS 에서 보충. + """오늘 윈도우의 ohlcv_1m 을 필요한 만큼만 KIS 에서 보충. - Returns: 'fresh' | 'refreshed' | 'skipped_missing_key' | 'failed' | 'no_data' + 분기: + - 주말: KIS 분봉 endpoint 는 "당일" 만 지원 → 호출하지 않음. 'weekend'. + - 장외 (평일 09:00 이전 또는 15:35 이후) + 이미 오늘 데이터 있음: 'cached_closed'. + (마감 후엔 데이터 늘지 않으므로 KIS 호출 의미 없음) + - 장중 + last_ts 가 10분 이내: 'fresh' (DB 만 읽음) + - 그 외 (장중 stale / 장 막 끝나서 마지막 마감 데이터 1회 필요 / 캐시 비어있음): + last_ts+1m ~ now 사이의 빈 구간을 fetch_minute_range 로 페이지네이션 채움. + DB 캐시가 그날 데이터를 이미 갖고 있으면 자연히 호출 1~2 페이지로 끝. + + Returns: 'fresh' | 'refreshed' | 'cached_closed' | 'weekend' | + 'skipped_missing_key' | 'failed' | 'no_data' """ - last_ts = conn.execute( - text("SELECT MAX(ts) FROM ohlcv_1m WHERE code = :c"), - {"c": code}, - ).scalar() now = datetime.now(KST) - # 평일 장중 (09:00~15:30) 이 아니면 데이터가 더 들어올 일이 없으니 마지막 캐시 그대로. + if now.weekday() >= 5: + return "weekend" + + win_start, win_end = _intraday_window_today() + last_ts = conn.execute( + text( + "SELECT MAX(ts) FROM ohlcv_1m WHERE code = :c AND ts >= :s AND ts < :e" + ), + {"c": code, "s": win_start, "e": win_end}, + ).scalar() + market_open = dtime(9, 0) - market_close = dtime(15, 35) - in_session = ( - now.weekday() < 5 - and market_open <= now.time() <= market_close - ) - if last_ts is not None and (now - last_ts) < timedelta(minutes=10) and in_session: + market_close_buffer = dtime(15, 35) + in_session = market_open <= now.time() <= market_close_buffer + + # 장외이고 이미 오늘 데이터 있음 → 추가 호출 불필요 + if not in_session and last_ts is not None: + return "cached_closed" + + # 장중 + 10분 이내 갱신 → 추가 호출 불필요 + if in_session and last_ts is not None and (now - last_ts) < timedelta(minutes=10): + return "fresh" + + # fetch 윈도우 = [last_ts+1m or win_start, min(now, win_end)] + fetch_to = min(now, win_end) + if last_ts is not None and last_ts >= win_start: + fetch_from = last_ts + timedelta(minutes=1) + else: + fetch_from = win_start + if fetch_from >= fetch_to: return "fresh" try: - from app.fetch.kis import SkippedMissingKey, fetch_minute_price + from app.fetch.kis import SkippedMissingKey, fetch_minute_range except Exception: # noqa: BLE001 return "failed" try: - rows = fetch_minute_price(code) + rows = fetch_minute_range(code, fetch_from, fetch_to) except SkippedMissingKey: return "skipped_missing_key" except Exception: # noqa: BLE001 diff --git a/backend/app/fetch/kis.py b/backend/app/fetch/kis.py index 080bbcb..6a72723 100644 --- a/backend/app/fetch/kis.py +++ b/backend/app/fetch/kis.py @@ -304,6 +304,59 @@ def fetch_minute_price( return out +def fetch_minute_range( + code: str, + from_ts: datetime, + to_ts: datetime, + *, + max_pages: int = 20, +) -> list[dict[str, Any]]: + """[from_ts, to_ts] 윈도우의 1분봉 전체. KIS 30-bar 페이지를 역순으로 반복 호출. + + KIS `inquire-time-itemchartprice` 는 한 번에 최대 30개 1분봉만 주고, + `FID_INPUT_HOUR_1` 기준 그 시각 포함 이전 30분을 반환한다. 그래서 to_ts 부터 + 시작해서 가장 이른 응답 시각의 -1분을 다음 cursor 로 잡아 from_ts 까지 후퇴. + + 중복 키 (ts) 는 dict 로 자연 dedupe. 더 이상 새 행이 안 들어오거나 max_pages 도달 + 하면 종료 (rate-limit/무한루프 방지). + + Note: 이 endpoint 는 "당일" 만 지원. from_ts/to_ts 는 같은 날짜여야 한다. + """ + if not _has_keys(): + raise SkippedMissingKey("kis app_key/secret missing") + if from_ts >= to_ts: + return [] + + from datetime import timedelta as _td + + accumulated: dict[datetime, dict[str, Any]] = {} + cursor = to_ts + pages = 0 + while cursor > from_ts and pages < max_pages: + pages += 1 + rows = fetch_minute_price(code, end_hour=cursor.strftime("%H%M%S")) + if not rows: + break + added = 0 + for r in rows: + ts = r["ts"] + if ts < from_ts or ts > to_ts: + continue + if ts not in accumulated: + accumulated[ts] = r + added += 1 + if added == 0: + # 같은 30개를 또 받았다 — 더 과거가 없거나 KIS 가 똑같은 페이지를 반환. + break + earliest_ts = min(r["ts"] for r in rows) + next_cursor = earliest_ts - _td(minutes=1) + if next_cursor >= cursor: + break + cursor = next_cursor + + return sorted(accumulated.values(), key=lambda r: r["ts"]) + + def ping() -> dict[str, Any]: """토큰 발급만 시도해서 키 유효성 확인.""" if not _has_keys(): diff --git a/web/components/PredictionPanel.tsx b/web/components/PredictionPanel.tsx index d1e3c6b..19e2aac 100644 --- a/web/components/PredictionPanel.tsx +++ b/web/components/PredictionPanel.tsx @@ -58,10 +58,12 @@ export function PredictionPanel({ code, initial, onResult }: Props) { function effectiveHorizons(): number[] { if (useCustom) { + // 백엔드 predict.py 가 1~30 만 허용 (모델 학습/검증 범위와 일치). + // 프론트에서 동일 cap 으로 끊어서 400 안 나게 한다. const parsed = customRaw .split(",") .map((s) => Number(s.trim())) - .filter((n) => Number.isFinite(n) && n >= 1 && n <= 60); + .filter((n) => Number.isFinite(n) && n >= 1 && n <= 30); if (parsed.length > 0) return Array.from(new Set(parsed)).sort((a, b) => a - b); } return HORIZON_PRESETS[presetIdx].value; @@ -137,11 +139,11 @@ export function PredictionPanel({ code, initial, onResult }: Props) { 직접 setCustomRaw(e.target.value)} onFocus={() => setUseCustom(true)} - className="ml-1 w-24 rounded border border-zinc-700 bg-zinc-900 px-1 py-0.5 text-xs text-zinc-100" + className="ml-1 w-28 rounded border border-zinc-700 bg-zinc-900 px-1 py-0.5 text-xs text-zinc-100" />