diff --git a/backend/app/api/chart.py b/backend/app/api/chart.py index 24f8dda..7740392 100644 --- a/backend/app/api/chart.py +++ b/backend/app/api/chart.py @@ -1,14 +1,21 @@ """차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시). -UI: /code 페이지 첫 로드 시 호출 → lightweight-charts 캔들 데이터로 사용. +UI: /code 페이지가 호출 → lightweight-charts 캔들 데이터로 사용. -첫 방문 시 ohlcv_daily 가 비어 있으면 (symbols 만 시드됨, daily_batch 아직 안 돔) -즉시 pykrx 로 자동 갱신 — 사용자 입장에선 한 번의 차트 요청으로 데이터까지 충전. +interval 파라미터로 캔들 단위 선택: + - "10m" : 당일 10분봉. ohlcv_1m 을 time_bucket 으로 10분 단위 집계. + stale (>10분) 이면 KIS inquire-time-itemchartprice 로 즉시 보충. + - "1d" : 일봉. ohlcv_daily 직접 조회. 비어있으면 pykrx auto-refresh. + - "1w" : 주봉. ohlcv_daily 를 date_trunc('week') 로 집계. + - "1mo" : 월봉. ohlcv_daily 를 date_trunc('month') 로 집계. + +10m 외에는 date 필드가 'YYYY-MM-DD' ISO date 문자열, +10m 일 때는 'YYYY-MM-DDTHH:MM:SS' ISO datetime (KST) 으로 통일. """ from __future__ import annotations import logging -from datetime import date, timedelta +from datetime import date, datetime, time as dtime, timedelta, timezone from fastapi import APIRouter, HTTPException, Query from sqlalchemy import text @@ -19,8 +26,11 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/chart", tags=["chart"]) +ALLOWED_INTERVALS = ("10m", "1d", "1w", "1mo") +KST = timezone(timedelta(hours=9)) -def _query_ohlcv(conn, code: str, start: date, end: date): + +def _query_ohlcv_daily(conn, code: str, start: date, end: date): return conn.execute( text( """ @@ -34,13 +44,150 @@ def _query_ohlcv(conn, code: str, start: date, end: date): ).all() +def _query_ohlcv_bucketed(conn, code: str, start: date, end: date, trunc: str): + """1d → 1w/1mo 집계. date_trunc 로 bucket 잡고, 첫/마지막/최고/최저/합 집계. + + open=bucket 첫 거래일 시가, close=마지막 거래일 종가. PostgreSQL window 함수로 구한다. + """ + return conn.execute( + text( + f""" + WITH base AS ( + SELECT date_trunc(:trunc, date)::date AS bucket, + date, open, high, low, close, volume + FROM ohlcv_daily + WHERE code = :c AND date BETWEEN :s AND :e + ), + ranked AS ( + SELECT bucket, date, open, high, low, close, volume, + ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY date ASC) AS rn_first, + ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY date DESC) AS rn_last + FROM base + ) + SELECT bucket AS date, + MAX(open) FILTER (WHERE rn_first = 1) AS open, + MAX(high) AS high, + MIN(low) AS low, + MAX(close) FILTER (WHERE rn_last = 1) AS close, + SUM(volume) AS volume + FROM ranked + GROUP BY bucket + ORDER BY bucket + """ + ), + {"c": code, "s": start, "e": end, "trunc": trunc}, + ).all() + + +def _query_ohlcv_10m(conn, code: str, start_ts: datetime, end_ts: datetime): + """ohlcv_1m → 10분봉. TimescaleDB time_bucket 으로 10분 단위 집계. + + first()/last() 는 TimescaleDB 의 집계함수. + """ + return conn.execute( + text( + """ + SELECT time_bucket(INTERVAL '10 minutes', ts) AS bucket, + first(open, ts) AS open, + MAX(high) AS high, + MIN(low) AS low, + last(close, ts) AS close, + SUM(volume) AS volume + FROM ohlcv_1m + WHERE code = :c AND ts >= :s AND ts < :e + GROUP BY bucket + ORDER BY bucket + """ + ), + {"c": code, "s": start_ts, "e": end_ts}, + ).all() + + +def _upsert_ohlcv_1m(conn, code: str, rows: list[dict]) -> int: + """KIS 분봉 응답을 ohlcv_1m 에 UPSERT. 같은 (code, ts) 는 덮어쓰기 (장중 갱신용).""" + if not rows: + return 0 + conn.execute( + text( + """ + INSERT INTO ohlcv_1m (code, ts, open, high, low, close, volume) + VALUES (:code, :ts, :open, :high, :low, :close, :volume) + ON CONFLICT (code, ts) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume = EXCLUDED.volume + """ + ), + [{"code": code, **r} for r in rows], + ) + return len(rows) + + +def _intraday_window_today() -> tuple[datetime, datetime]: + """오늘 KST 의 장 시간대 윈도우 (08:50 ~ 16:00). 토/일은 직전 영업일.""" + now = datetime.now(KST) + d = now.date() + # 주말이면 직전 금요일로 + while d.weekday() >= 5: + d -= timedelta(days=1) + start = datetime.combine(d, dtime(8, 50), tzinfo=KST) + end = datetime.combine(d, dtime(16, 0), tzinfo=KST) + return start, end + + +def _ensure_intraday_fresh(conn, code: str) -> str: + """마지막 ohlcv_1m 데이터가 10분 이상 오래됐으면 KIS 에서 보충. + + Returns: 'fresh' | 'refreshed' | '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) 이 아니면 데이터가 더 들어올 일이 없으니 마지막 캐시 그대로. + 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: + return "fresh" + + try: + from app.fetch.kis import SkippedMissingKey, fetch_minute_price + except Exception: # noqa: BLE001 + return "failed" + + try: + rows = fetch_minute_price(code) + except SkippedMissingKey: + return "skipped_missing_key" + except Exception: # noqa: BLE001 + logger.exception("intraday refresh failed for %s", code) + return "failed" + + if not rows: + return "no_data" + _upsert_ohlcv_1m(conn, code, rows) + conn.commit() + return "refreshed" + + @router.get("/{code}") def get_chart( code: str, - days: int = Query(default=180, ge=10, le=3650), + days: int = Query(default=180, ge=1, le=3650), + interval: str = Query(default="1d"), include_sentiment: bool = Query(default=True), include_trading_value: bool = Query(default=True), ) -> dict: + if interval not in ALLOWED_INTERVALS: + raise HTTPException(status_code=400, detail=f"interval must be one of {ALLOWED_INTERVALS}") + eng = get_engine() end = date.today() start = end - timedelta(days=days) @@ -52,33 +199,58 @@ def get_chart( if not symbol: raise HTTPException(status_code=404, detail=f"unknown code: {code}") - ohlcv_rows = _query_ohlcv(conn, code, start, end) - if not ohlcv_rows: - # 첫 방문 — ohlcv_daily 가 비어있다. pykrx 로 즉시 채우고 재조회. - # refresh_code 는 별도 트랜잭션으로 ohlcv/trading_value/news 모두 commit 하므로 - # 이 conn 에서 다시 SELECT 하면 새 행이 보인다. lookback 은 차트 요청 범위 + - # 예측 모델 학습용 마진 (Chronos/LightGBM 이 충분한 과거 시계열을 요구) 으로 365 하한. - try: - from app.pipelines.refresh_one import refresh_code - logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code) - refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365)) - ohlcv_rows = _query_ohlcv(conn, code, start, end) - except Exception: # noqa: BLE001 - logger.exception("chart: auto-refresh failed for %s", code) - ohlcv = [ - { - "date": str(r[0]), - "open": float(r[1]) if r[1] is not None else None, - "high": float(r[2]) if r[2] is not None else None, - "low": float(r[3]) if r[3] is not None else None, - "close": float(r[4]) if r[4] is not None else None, - "volume": int(r[5]) if r[5] is not None else None, - } - for r in ohlcv_rows - ] + ohlcv: list[dict] = [] + intraday_status: str | None = None + + if interval == "10m": + intraday_status = _ensure_intraday_fresh(conn, code) + win_start, win_end = _intraday_window_today() + rows = _query_ohlcv_10m(conn, code, win_start, win_end) + ohlcv = [ + { + # KST aware datetime → ISO datetime. 프론트에서 Date 파싱. + "date": (r[0].astimezone(KST) if r[0].tzinfo else r[0].replace(tzinfo=KST)) + .strftime("%Y-%m-%dT%H:%M:%S"), + "open": float(r[1]) if r[1] is not None else None, + "high": float(r[2]) if r[2] is not None else None, + "low": float(r[3]) if r[3] is not None else None, + "close": float(r[4]) if r[4] is not None else None, + "volume": int(r[5]) if r[5] is not None else None, + } + for r in rows + ] + else: + if interval == "1d": + rows = _query_ohlcv_daily(conn, code, start, end) + elif interval == "1w": + rows = _query_ohlcv_bucketed(conn, code, start, end, "week") + else: # "1mo" + rows = _query_ohlcv_bucketed(conn, code, start, end, "month") + + if not rows and interval == "1d": + # 첫 방문 → pykrx auto-refresh. + try: + from app.pipelines.refresh_one import refresh_code + logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code) + refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365)) + rows = _query_ohlcv_daily(conn, code, start, end) + except Exception: # noqa: BLE001 + logger.exception("chart: auto-refresh failed for %s", code) + + ohlcv = [ + { + "date": str(r[0]), + "open": float(r[1]) if r[1] is not None else None, + "high": float(r[2]) if r[2] is not None else None, + "low": float(r[3]) if r[3] is not None else None, + "close": float(r[4]) if r[4] is not None else None, + "volume": int(r[5]) if r[5] is not None else None, + } + for r in rows + ] sentiment: list[dict] = [] - if include_sentiment: + if include_sentiment and interval != "10m": try: s_rows = conn.execute( text( @@ -101,11 +273,10 @@ def get_chart( for r in s_rows ] except Exception: # noqa: BLE001 - # v_sentiment_daily 뷰 아직 없을 수 있음 (마이그레이션 미실행) sentiment = [] trading: list[dict] = [] - if include_trading_value: + if include_trading_value and interval != "10m": tv_rows = conn.execute( text( """ @@ -131,7 +302,10 @@ def get_chart( "code": symbol[0], "name": symbol[1], "market": symbol[2], + "interval": interval, + "intraday_status": intraday_status, "range": {"from": str(start), "to": str(end)}, + "today": date.today().isoformat(), "ohlcv": ohlcv, "sentiment": sentiment, "trading_value": trading, diff --git a/backend/app/fetch/kis.py b/backend/app/fetch/kis.py index 64e3b20..080bbcb 100644 --- a/backend/app/fetch/kis.py +++ b/backend/app/fetch/kis.py @@ -233,6 +233,77 @@ def fetch_daily_price( return out +@retry( + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=1, max=4), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, +) +def fetch_minute_price( + code: str, + *, + end_hour: str | None = None, +) -> list[dict[str, Any]]: + """당일 1분봉 시세 조회 (read-only). 최신 30개 캔들을 반환. + + KIS 분봉 endpoint (`inquire-time-itemchartprice`) 는 base 시각 (FID_INPUT_HOUR_1) 부터 + 역순으로 최대 30개의 1분봉을 돌려준다. base 를 비우면 KIS 가 가장 최근 시각으로 해석. + 즉 장중 호출 → 직전 30분 / 장 종료 후 호출 → 15:00~15:30 의 30분. + + Returns: [{ts: datetime(KST aware), open, high, low, close, volume}, ...] + ts 오름차순 정렬. + + Note: 이 endpoint 는 "당일" 분봉만 지원. 어제 이전 분봉은 별도 endpoint 가 필요한데, + 이 사이트의 사용 패턴 (장중 라이브 차트) 에는 당일 데이터로 충분하다. + """ + if not _has_keys(): + raise SkippedMissingKey("kis app_key/secret missing") + url = f"{KIS_BASE}/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + params = { + "FID_ETC_CLS_CODE": "", + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + # 비우면 KIS 가 "지금" 으로 해석. 장 마감 후엔 15:30:00 부근 데이터. + "FID_INPUT_HOUR_1": end_hour or "", + "FID_PW_DATA_INCU_YN": "Y", # 과거 데이터 포함 (장 시작 직후 빈 데이터 방지) + } + with httpx.Client(timeout=15.0) as cli: + resp = cli.get(url, headers=_headers("FHKST03010200"), params=params) + resp.raise_for_status() + data = resp.json() + if data.get("rt_cd") != "0": + raise RuntimeError(f"kis error: {data.get('msg1')} (rt_cd={data.get('rt_cd')})") + + # KIS 응답은 KST. tz-aware 로 변환해서 DB (TIMESTAMPTZ) 에 안전 적재. + from datetime import timedelta, timezone as _tz + KST = _tz(timedelta(hours=9)) + + out: list[dict[str, Any]] = [] + for row in data.get("output2", []) or []: + raw_date = row.get("stck_bsop_date") + raw_hour = row.get("stck_cntg_hour") + if not raw_date or not raw_hour: + continue + try: + ts = datetime.strptime(raw_date + raw_hour.zfill(6), "%Y%m%d%H%M%S").replace(tzinfo=KST) + except ValueError: + continue + out.append( + { + "ts": ts, + "open": float(row.get("stck_oprc") or 0), + "high": float(row.get("stck_hgpr") or 0), + "low": float(row.get("stck_lwpr") or 0), + # 분봉에서는 종가가 stck_prpr (현재가) 로 옴 + "close": float(row.get("stck_prpr") or row.get("stck_clpr") or 0), + "volume": int(row.get("cntg_vol") or 0), + } + ) + # KIS 응답은 보통 최신→과거 역순. UI/DB 적재 편의를 위해 오름차순으로 뒤집는다. + out.sort(key=lambda r: r["ts"]) + return out + + def ping() -> dict[str, Any]: """토큰 발급만 시도해서 키 유효성 확인.""" if not _has_keys(): diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 054f8a3..fe730c7 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -8,33 +8,63 @@ import { PredictionPanel } from "../../components/PredictionPanel"; import { StockChart } from "../../components/StockChart"; import { api, + type ChartInterval, type ChartPayload, type LatestPredictionResponse, } from "../../lib/api"; +const INTERVALS: { label: string; value: ChartInterval; defaultDays: number }[] = [ + { label: "10분", value: "10m", defaultDays: 1 }, + { label: "일", value: "1d", defaultDays: 180 }, + { label: "주", value: "1w", defaultDays: 365 * 2 }, + { label: "월", value: "1mo", defaultDays: 365 * 5 }, +]; + export default function CodePage({ params }: { params: { code: string } }) { const { code } = params; const [chart, setChart] = useState(null); const [prediction, setPrediction] = useState(null); const [err, setErr] = useState(null); + const [interval, setIntervalKind] = useState("1d"); const [days, setDays] = useState(180); + // interval 바꾸면 days 도 그 interval 에 맞는 기본값으로 (사용자가 명시적으로 다시 고를 수 있게). + function pickInterval(v: ChartInterval) { + const meta = INTERVALS.find((i) => i.value === v)!; + setIntervalKind(v); + setDays(meta.defaultDays); + } + + // 초기/주기적 차트 로드. 10분봉이면 60초마다 폴링 — 백엔드가 캐시-then-fetch 로 + // 10분 이내면 DB 만 읽고, 넘었으면 KIS 호출. 폴링 부담은 낮음. useEffect(() => { let alive = true; setErr(null); setChart(null); - api - .getChart(code, days) - .then((c) => { - if (alive) setChart(c); - }) - .catch((e) => { - if (alive) setErr(e instanceof Error ? e.message : String(e)); - }); + + const load = () => { + api + .getChart(code, days, interval) + .then((c) => { + if (alive) setChart(c); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + }; + load(); + + if (interval === "10m") { + const h = window.setInterval(load, 60_000); + return () => { + alive = false; + window.clearInterval(h); + }; + } return () => { alive = false; }; - }, [code, days]); + }, [code, days, interval]); useEffect(() => { let alive = true; @@ -57,26 +87,55 @@ export default function CodePage({ params }: { params: { code: string } }) { ← 검색으로 - +
+
+ {INTERVALS.map((it) => ( + + ))} +
+ {interval !== "10m" && ( + + )} +
{chart && ( -
+

{chart.name}{" "} {chart.code} · {chart.market}

+ {interval === "10m" && ( +
+ 실시간 10분봉 · 60초마다 갱신 + {chart.intraday_status && ( + [{chart.intraday_status}] + )} +
+ )}
)} diff --git a/web/components/PredictionPanel.tsx b/web/components/PredictionPanel.tsx index 61aea54..d1e3c6b 100644 --- a/web/components/PredictionPanel.tsx +++ b/web/components/PredictionPanel.tsx @@ -42,16 +42,37 @@ function normalizeFromPredictResponse( }; } +const HORIZON_PRESETS: { label: string; value: number[] }[] = [ + { label: "단기 (1·3·5)", value: [1, 3, 5] }, + { label: "중기 (1·5·10)", value: [1, 5, 10] }, + { label: "장기 (5·10·20)", value: [5, 10, 20] }, +]; + export function PredictionPanel({ code, initial, onResult }: Props) { const [pred, setPred] = useState(initial ?? null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); + const [presetIdx, setPresetIdx] = useState(0); + const [customRaw, setCustomRaw] = useState(""); + const [useCustom, setUseCustom] = useState(false); + + function effectiveHorizons(): number[] { + if (useCustom) { + const parsed = customRaw + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n) && n >= 1 && n <= 60); + if (parsed.length > 0) return Array.from(new Set(parsed)).sort((a, b) => a - b); + } + return HORIZON_PRESETS[presetIdx].value; + } async function runPredict() { setLoading(true); setErr(null); try { - const r = await api.predict(code); + const horizons = effectiveHorizons(); + const r = await api.predict(code, horizons); const normalized = normalizeFromPredictResponse(code, r); setPred(normalized); onResult(normalized); @@ -82,6 +103,49 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
+
+ 예측 거래일: + {HORIZON_PRESETS.map((p, i) => ( + + ))} + +
+ {err &&
에러: {err}
} {pred?.found ? ( diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index 0348bf0..d1c62c2 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -7,6 +7,8 @@ import { type IChartApi, type ISeriesApi, type LineData, + type SeriesMarker, + type Time, type UTCTimestamp, } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; @@ -16,12 +18,26 @@ type Props = { prediction?: LatestPredictionResponse | null; }; -function dateToUtcTs(d: string): UTCTimestamp { - // 'YYYY-MM-DD' → UTC midnight epoch seconds +// 'YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS' (KST naive, 백엔드가 +09:00 시각의 wall-clock 을 +// 그대로 ISO 로 직렬화) 를 UTCTimestamp 로. lightweight-charts 는 timestamp 가 UTC 라고 +// 가정하지만, 우리는 KST wall-clock 을 UTC 인 척 넣는다 — timeScale 의 표시도 KST 그대로 +// 나와서 한국 사용자에겐 가장 직관적. +function isoToUtcTs(s: string): UTCTimestamp { + if (s.length <= 10) { + return (Date.UTC( + Number(s.slice(0, 4)), + Number(s.slice(5, 7)) - 1, + Number(s.slice(8, 10)), + ) / 1000) as UTCTimestamp; + } + // datetime: YYYY-MM-DDTHH:MM:SS return (Date.UTC( - Number(d.slice(0, 4)), - Number(d.slice(5, 7)) - 1, - Number(d.slice(8, 10)), + Number(s.slice(0, 4)), + Number(s.slice(5, 7)) - 1, + Number(s.slice(8, 10)), + Number(s.slice(11, 13)), + Number(s.slice(14, 16)), + Number(s.slice(17, 19) || "0"), ) / 1000) as UTCTimestamp; } @@ -33,7 +49,9 @@ export function StockChart({ chart, prediction }: Props) { const predLowRef = useRef | null>(null); const predHighRef = useRef | null>(null); - // create chart once + const isIntraday = chart.interval === "10m"; + + // create chart once (interval 바뀌면 timeVisible 토글 위해 의존성에 isIntraday 포함 — 재생성) useEffect(() => { if (!containerRef.current) return; const c = createChart(containerRef.current, { @@ -46,7 +64,11 @@ export function StockChart({ chart, prediction }: Props) { horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151" }, - timeScale: { borderColor: "#374151", timeVisible: false }, + timeScale: { + borderColor: "#374151", + timeVisible: isIntraday, + secondsVisible: false, + }, autoSize: true, }); const candle = c.addCandlestickSeries({ @@ -67,28 +89,49 @@ export function StockChart({ chart, prediction }: Props) { predLowRef.current = null; predHighRef.current = null; }; - }, []); + }, [isIntraday]); - // push candle data + // push candle data + today marker useEffect(() => { if (!candleRef.current) return; const data: CandlestickData[] = chart.ohlcv .filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null) .map((p) => ({ - time: dateToUtcTs(p.date), + time: isoToUtcTs(p.date), open: p.open as number, high: p.high as number, low: p.low as number, close: p.close as number, })); candleRef.current.setData(data); - chartRef.current?.timeScale().fitContent(); - }, [chart]); - // push prediction overlay + // 오늘 날짜 마커: 일/주/월봉에서만 표시 (10분봉은 데이터 자체가 오늘 하루라 무의미). + // markers 는 데이터 포인트의 time 과 일치해야 표시되므로, 오늘 또는 가장 가까운 과거 + // 거래일을 찾는다. + if (!isIntraday && chart.today) { + const todayTs = isoToUtcTs(chart.today); + // 차트의 마지막 데이터가 오늘이면 그 위에, 아니면 마지막 데이터 위에 "오늘" 표시. + const lastTs = data.length > 0 ? (data[data.length - 1].time as UTCTimestamp) : null; + const markerTime = (lastTs && lastTs <= todayTs ? lastTs : todayTs) as UTCTimestamp; + const markers: SeriesMarker