Compare commits

..

2 Commits

Author SHA1 Message Date
claude-owner
e610599879 fix(chart): 10분봉 페이지네이션, 장외 캐시, 예측 horizon 캡 일치
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' 으로 명시.
2026-05-23 01:41:43 +09:00
claude-owner
0a5c634680 feat(chart): 10m 실시간 / 일·주·월 토글 / 오늘 마커 / 예측 거래일 선택
- backend/app/api/chart.py: interval=10m|1d|1w|1mo. 10m 은 ohlcv_1m 을
  time_bucket(10min) 으로 집계, stale(>10분) 이면 KIS 분봉 fetch 후 재조회.
  1w/1mo 는 ohlcv_daily 를 date_trunc 로 집계. today 필드 추가.
- backend/app/fetch/kis.py: fetch_minute_price() 추가 (tr_id FHKST03010200).
  KIS 응답 KST 시각을 tz-aware datetime 으로 변환, 오름차순 정렬.
- web/lib/api.ts: ChartInterval 타입, getChart(interval), predict(horizons[]).
- web/components/StockChart.tsx: 10m 이면 timeVisible. 일·주·월에서 오늘
  화살표 마커 표시. ISO datetime 도 파싱.
- web/components/PredictionPanel.tsx: 단기/중기/장기 프리셋 + 사용자 직접
  지정 (예: 1,2,3,7). API 에 horizons 배열 전달.
- web/app/[code]/page.tsx: interval 칩 (10분/일/주/월). 10m 일 때 60초마다
  폴링. interval 별 기본 lookback (10m=1, 1d=180, 1w=730, 1mo=1825).
2026-05-23 01:34:29 +09:00
6 changed files with 586 additions and 81 deletions

View File

@@ -1,14 +1,21 @@
"""차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시). """차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시).
UI: /code 페이지 첫 로드 시 호출 → lightweight-charts 캔들 데이터로 사용. UI: /code 페이지 호출 → lightweight-charts 캔들 데이터로 사용.
첫 방문 시 ohlcv_daily 가 비어 있으면 (symbols 만 시드됨, daily_batch 아직 안 돔) interval 파라미터로 캔들 단위 선택:
즉시 pykrx 로 자동 갱신 — 사용자 입장에선 한 번의 차트 요청으로 데이터까지 충전. - "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 from __future__ import annotations
import logging import logging
from datetime import date, timedelta from datetime import date, datetime, time as dtime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import text from sqlalchemy import text
@@ -19,8 +26,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chart", tags=["chart"]) 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( return conn.execute(
text( text(
""" """
@@ -34,13 +44,178 @@ def _query_ohlcv(conn, code: str, start: date, end: date):
).all() ).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 을 필요한 만큼만 KIS 에서 보충.
분기:
- 주말: 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'
"""
now = datetime.now(KST)
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_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_range
except Exception: # noqa: BLE001
return "failed"
try:
rows = fetch_minute_range(code, fetch_from, fetch_to)
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}") @router.get("/{code}")
def get_chart( def get_chart(
code: str, 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_sentiment: bool = Query(default=True),
include_trading_value: bool = Query(default=True), include_trading_value: bool = Query(default=True),
) -> dict: ) -> dict:
if interval not in ALLOWED_INTERVALS:
raise HTTPException(status_code=400, detail=f"interval must be one of {ALLOWED_INTERVALS}")
eng = get_engine() eng = get_engine()
end = date.today() end = date.today()
start = end - timedelta(days=days) start = end - timedelta(days=days)
@@ -52,33 +227,58 @@ def get_chart(
if not symbol: if not symbol:
raise HTTPException(status_code=404, detail=f"unknown code: {code}") raise HTTPException(status_code=404, detail=f"unknown code: {code}")
ohlcv_rows = _query_ohlcv(conn, code, start, end) ohlcv: list[dict] = []
if not ohlcv_rows: intraday_status: str | None = None
# 첫 방문 — ohlcv_daily 가 비어있다. pykrx 로 즉시 채우고 재조회.
# refresh_code 는 별도 트랜잭션으로 ohlcv/trading_value/news 모두 commit 하므로 if interval == "10m":
# 이 conn 에서 다시 SELECT 하면 새 행이 보인다. lookback 은 차트 요청 범위 + intraday_status = _ensure_intraday_fresh(conn, code)
# 예측 모델 학습용 마진 (Chronos/LightGBM 이 충분한 과거 시계열을 요구) 으로 365 하한. win_start, win_end = _intraday_window_today()
try: rows = _query_ohlcv_10m(conn, code, win_start, win_end)
from app.pipelines.refresh_one import refresh_code ohlcv = [
logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code) {
refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365)) # KST aware datetime → ISO datetime. 프론트에서 Date 파싱.
ohlcv_rows = _query_ohlcv(conn, code, start, end) "date": (r[0].astimezone(KST) if r[0].tzinfo else r[0].replace(tzinfo=KST))
except Exception: # noqa: BLE001 .strftime("%Y-%m-%dT%H:%M:%S"),
logger.exception("chart: auto-refresh failed for %s", code) "open": float(r[1]) if r[1] is not None else None,
ohlcv = [ "high": float(r[2]) if r[2] is not None else None,
{ "low": float(r[3]) if r[3] is not None else None,
"date": str(r[0]), "close": float(r[4]) if r[4] is not None else None,
"open": float(r[1]) if r[1] is not None else None, "volume": int(r[5]) if r[5] 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, for r in rows
"close": float(r[4]) if r[4] is not None else None, ]
"volume": int(r[5]) if r[5] is not None else None, else:
} if interval == "1d":
for r in ohlcv_rows 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] = [] sentiment: list[dict] = []
if include_sentiment: if include_sentiment and interval != "10m":
try: try:
s_rows = conn.execute( s_rows = conn.execute(
text( text(
@@ -101,11 +301,10 @@ def get_chart(
for r in s_rows for r in s_rows
] ]
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
# v_sentiment_daily 뷰 아직 없을 수 있음 (마이그레이션 미실행)
sentiment = [] sentiment = []
trading: list[dict] = [] trading: list[dict] = []
if include_trading_value: if include_trading_value and interval != "10m":
tv_rows = conn.execute( tv_rows = conn.execute(
text( text(
""" """
@@ -131,7 +330,10 @@ def get_chart(
"code": symbol[0], "code": symbol[0],
"name": symbol[1], "name": symbol[1],
"market": symbol[2], "market": symbol[2],
"interval": interval,
"intraday_status": intraday_status,
"range": {"from": str(start), "to": str(end)}, "range": {"from": str(start), "to": str(end)},
"today": date.today().isoformat(),
"ohlcv": ohlcv, "ohlcv": ohlcv,
"sentiment": sentiment, "sentiment": sentiment,
"trading_value": trading, "trading_value": trading,

View File

@@ -233,6 +233,130 @@ def fetch_daily_price(
return out 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 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]: def ping() -> dict[str, Any]:
"""토큰 발급만 시도해서 키 유효성 확인.""" """토큰 발급만 시도해서 키 유효성 확인."""
if not _has_keys(): if not _has_keys():

View File

@@ -8,33 +8,63 @@ import { PredictionPanel } from "../../components/PredictionPanel";
import { StockChart } from "../../components/StockChart"; import { StockChart } from "../../components/StockChart";
import { import {
api, api,
type ChartInterval,
type ChartPayload, type ChartPayload,
type LatestPredictionResponse, type LatestPredictionResponse,
} from "../../lib/api"; } 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 } }) { export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params; const { code } = params;
const [chart, setChart] = useState<ChartPayload | null>(null); const [chart, setChart] = useState<ChartPayload | null>(null);
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null); const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [interval, setIntervalKind] = useState<ChartInterval>("1d");
const [days, setDays] = useState(180); 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(() => { useEffect(() => {
let alive = true; let alive = true;
setErr(null); setErr(null);
setChart(null); setChart(null);
api
.getChart(code, days) const load = () => {
.then((c) => { api
if (alive) setChart(c); .getChart(code, days, interval)
}) .then((c) => {
.catch((e) => { if (alive) setChart(c);
if (alive) setErr(e instanceof Error ? e.message : String(e)); })
}); .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 () => { return () => {
alive = false; alive = false;
}; };
}, [code, days]); }, [code, days, interval]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -57,26 +87,55 @@ export default function CodePage({ params }: { params: { code: string } }) {
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300"> <Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link> </Link>
<select <div className="flex items-center gap-2">
value={days} <div className="flex overflow-hidden rounded-md border border-zinc-700 text-xs">
onChange={(e) => setDays(Number(e.target.value))} {INTERVALS.map((it) => (
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs" <button
> key={it.value}
<option value={60}> 3</option> onClick={() => pickInterval(it.value)}
<option value={180}> 6</option> className={
<option value={365}> 1</option> interval === it.value
<option value={1095}> 3</option> ? "bg-emerald-700 px-3 py-1 text-white"
</select> : "bg-zinc-900 px-3 py-1 text-zinc-300 hover:bg-zinc-800"
}
>
{it.label}
</button>
))}
</div>
{interval !== "10m" && (
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs"
>
<option value={60}> 3</option>
<option value={180}> 6</option>
<option value={365}> 1</option>
<option value={365 * 2}> 2</option>
<option value={365 * 5}> 5</option>
<option value={365 * 10}> 10</option>
</select>
)}
</div>
</div> </div>
{chart && ( {chart && (
<div className="mb-4"> <div className="mb-4 flex items-baseline justify-between">
<h1 className="text-2xl font-semibold text-zinc-100"> <h1 className="text-2xl font-semibold text-zinc-100">
{chart.name}{" "} {chart.name}{" "}
<span className="text-sm font-normal text-zinc-500"> <span className="text-sm font-normal text-zinc-500">
{chart.code} · {chart.market} {chart.code} · {chart.market}
</span> </span>
</h1> </h1>
{interval === "10m" && (
<div className="text-xs text-zinc-500">
10 · 60
{chart.intraday_status && (
<span className="ml-2 text-zinc-600">[{chart.intraday_status}]</span>
)}
</div>
)}
</div> </div>
)} )}

View File

@@ -42,16 +42,39 @@ 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) { export function PredictionPanel({ code, initial, onResult }: Props) {
const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null); const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [presetIdx, setPresetIdx] = useState(0);
const [customRaw, setCustomRaw] = useState("");
const [useCustom, setUseCustom] = useState(false);
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 <= 30);
if (parsed.length > 0) return Array.from(new Set(parsed)).sort((a, b) => a - b);
}
return HORIZON_PRESETS[presetIdx].value;
}
async function runPredict() { async function runPredict() {
setLoading(true); setLoading(true);
setErr(null); setErr(null);
try { try {
const r = await api.predict(code); const horizons = effectiveHorizons();
const r = await api.predict(code, horizons);
const normalized = normalizeFromPredictResponse(code, r); const normalized = normalizeFromPredictResponse(code, r);
setPred(normalized); setPred(normalized);
onResult(normalized); onResult(normalized);
@@ -82,6 +105,49 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
</button> </button>
</div> </div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-zinc-500"> :</span>
{HORIZON_PRESETS.map((p, i) => (
<button
key={p.label}
onClick={() => {
setUseCustom(false);
setPresetIdx(i);
}}
className={
!useCustom && presetIdx === i
? "rounded-full bg-emerald-700 px-3 py-1 text-white"
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
{p.label}
</button>
))}
<label
className={
useCustom
? "flex items-center gap-1 rounded-full bg-emerald-700 px-3 py-1 text-white"
: "flex items-center gap-1 rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
}
>
<input
type="checkbox"
checked={useCustom}
onChange={(e) => setUseCustom(e.target.checked)}
className="h-3 w-3"
/>
<input
type="text"
placeholder="1~30, 예: 1,2,3,7"
value={customRaw}
onChange={(e) => setCustomRaw(e.target.value)}
onFocus={() => setUseCustom(true)}
className="ml-1 w-28 rounded border border-zinc-700 bg-zinc-900 px-1 py-0.5 text-xs text-zinc-100"
/>
</label>
</div>
{err && <div className="mb-3 text-xs text-red-400">: {err}</div>} {err && <div className="mb-3 text-xs text-red-400">: {err}</div>}
{pred?.found ? ( {pred?.found ? (

View File

@@ -7,6 +7,8 @@ import {
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type LineData, type LineData,
type SeriesMarker,
type Time,
type UTCTimestamp, type UTCTimestamp,
} from "lightweight-charts"; } from "lightweight-charts";
import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
@@ -16,12 +18,26 @@ type Props = {
prediction?: LatestPredictionResponse | null; prediction?: LatestPredictionResponse | null;
}; };
function dateToUtcTs(d: string): UTCTimestamp { // 'YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS' (KST naive, 백엔드가 +09:00 시각의 wall-clock 을
// 'YYYY-MM-DD' → UTC midnight epoch seconds // 그대로 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( return (Date.UTC(
Number(d.slice(0, 4)), Number(s.slice(0, 4)),
Number(d.slice(5, 7)) - 1, Number(s.slice(5, 7)) - 1,
Number(d.slice(8, 10)), Number(s.slice(8, 10)),
Number(s.slice(11, 13)),
Number(s.slice(14, 16)),
Number(s.slice(17, 19) || "0"),
) / 1000) as UTCTimestamp; ) / 1000) as UTCTimestamp;
} }
@@ -33,7 +49,9 @@ export function StockChart({ chart, prediction }: Props) {
const predLowRef = useRef<ISeriesApi<"Line"> | null>(null); const predLowRef = useRef<ISeriesApi<"Line"> | null>(null);
const predHighRef = useRef<ISeriesApi<"Line"> | null>(null); const predHighRef = useRef<ISeriesApi<"Line"> | null>(null);
// create chart once const isIntraday = chart.interval === "10m";
// create chart once (interval 바뀌면 timeVisible 토글 위해 의존성에 isIntraday 포함 — 재생성)
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const c = createChart(containerRef.current, { const c = createChart(containerRef.current, {
@@ -46,7 +64,11 @@ export function StockChart({ chart, prediction }: Props) {
horzLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" },
}, },
rightPriceScale: { borderColor: "#374151" }, rightPriceScale: { borderColor: "#374151" },
timeScale: { borderColor: "#374151", timeVisible: false }, timeScale: {
borderColor: "#374151",
timeVisible: isIntraday,
secondsVisible: false,
},
autoSize: true, autoSize: true,
}); });
const candle = c.addCandlestickSeries({ const candle = c.addCandlestickSeries({
@@ -67,28 +89,49 @@ export function StockChart({ chart, prediction }: Props) {
predLowRef.current = null; predLowRef.current = null;
predHighRef.current = null; predHighRef.current = null;
}; };
}, []); }, [isIntraday]);
// push candle data // push candle data + today marker
useEffect(() => { useEffect(() => {
if (!candleRef.current) return; if (!candleRef.current) return;
const data: CandlestickData[] = chart.ohlcv const data: CandlestickData[] = chart.ohlcv
.filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null) .filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null)
.map((p) => ({ .map((p) => ({
time: dateToUtcTs(p.date), time: isoToUtcTs(p.date),
open: p.open as number, open: p.open as number,
high: p.high as number, high: p.high as number,
low: p.low as number, low: p.low as number,
close: p.close as number, close: p.close as number,
})); }));
candleRef.current.setData(data); 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<Time>[] = [
{
time: markerTime,
position: "aboveBar",
color: "#fbbf24",
shape: "arrowDown",
text: "오늘",
},
];
candleRef.current.setMarkers(markers);
} else {
candleRef.current.setMarkers([]);
}
chartRef.current?.timeScale().fitContent();
}, [chart, isIntraday]);
// push prediction overlay (10분봉에서는 표시 안 함 — 예측은 일봉 기준)
useEffect(() => { useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
// remove previous overlay
if (predRef.current) { if (predRef.current) {
chartRef.current.removeSeries(predRef.current); chartRef.current.removeSeries(predRef.current);
predRef.current = null; predRef.current = null;
@@ -101,6 +144,7 @@ export function StockChart({ chart, prediction }: Props) {
chartRef.current.removeSeries(predHighRef.current); chartRef.current.removeSeries(predHighRef.current);
predHighRef.current = null; predHighRef.current = null;
} }
if (isIntraday) return;
if (!prediction || !prediction.found || !prediction.steps?.length) return; if (!prediction || !prediction.found || !prediction.steps?.length) return;
const baseDate = prediction.base_date!; const baseDate = prediction.base_date!;
const baseClose = prediction.base_close; const baseClose = prediction.base_close;
@@ -108,22 +152,22 @@ export function StockChart({ chart, prediction }: Props) {
const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon); const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon);
const med: LineData[] = [ const med: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose }, { time: isoToUtcTs(baseDate), value: baseClose },
...sorted ...sorted
.filter((s) => s.point_close !== null) .filter((s) => s.point_close !== null)
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.point_close as number })), .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.point_close as number })),
]; ];
const lo: LineData[] = [ const lo: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose }, { time: isoToUtcTs(baseDate), value: baseClose },
...sorted ...sorted
.filter((s) => s.ci_low !== null) .filter((s) => s.ci_low !== null)
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.ci_low as number })), .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_low as number })),
]; ];
const hi: LineData[] = [ const hi: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose }, { time: isoToUtcTs(baseDate), value: baseClose },
...sorted ...sorted
.filter((s) => s.ci_high !== null) .filter((s) => s.ci_high !== null)
.map((s) => ({ time: dateToUtcTs(s.target_date), value: s.ci_high as number })), .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_high as number })),
]; ];
const medLine = chartRef.current.addLineSeries({ const medLine = chartRef.current.addLineSeries({
@@ -157,7 +201,7 @@ export function StockChart({ chart, prediction }: Props) {
predLowRef.current = loLine; predLowRef.current = loLine;
predHighRef.current = hiLine; predHighRef.current = hiLine;
chartRef.current.timeScale().fitContent(); chartRef.current.timeScale().fitContent();
}, [prediction]); }, [prediction, isIntraday]);
return ( return (
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2"> <div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">

View File

@@ -42,6 +42,7 @@ export type SymbolSearch = {
}; };
export type OhlcvPoint = { export type OhlcvPoint = {
// 1d/1w/1mo: 'YYYY-MM-DD' / 10m: 'YYYY-MM-DDTHH:MM:SS' (KST naive ISO)
date: string; date: string;
open: number | null; open: number | null;
high: number | null; high: number | null;
@@ -50,6 +51,8 @@ export type OhlcvPoint = {
volume: number | null; volume: number | null;
}; };
export type ChartInterval = "10m" | "1d" | "1w" | "1mo";
export type SentimentPoint = { export type SentimentPoint = {
date: string; date: string;
n_articles: number; n_articles: number;
@@ -68,7 +71,10 @@ export type ChartPayload = {
code: string; code: string;
name: string; name: string;
market: string; market: string;
interval: ChartInterval;
intraday_status: string | null;
range: { from: string; to: string }; range: { from: string; to: string };
today: string;
ohlcv: OhlcvPoint[]; ohlcv: OhlcvPoint[];
sentiment: SentimentPoint[]; sentiment: SentimentPoint[];
trading_value: TradingValuePoint[]; trading_value: TradingValuePoint[];
@@ -178,13 +184,17 @@ export const api = {
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`, `/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`,
), ),
getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`), getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`),
getChart: (code: string, days = 180) => getChart: (code: string, days = 180, interval: ChartInterval = "1d") =>
getJson<ChartPayload>(`/api/chart/${encodeURIComponent(code)}?days=${days}`), getJson<ChartPayload>(
predict: (code: string, horizons = "1,3,5") => `/api/chart/${encodeURIComponent(code)}?days=${days}&interval=${encodeURIComponent(interval)}`,
getJson<PredictResponse>(
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(horizons)}`,
{ method: "POST" },
), ),
predict: (code: string, horizons: string | number[] = "1,3,5") => {
const h = Array.isArray(horizons) ? horizons.join(",") : horizons;
return getJson<PredictResponse>(
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(h)}`,
{ method: "POST" },
);
},
latestPrediction: (code: string) => latestPrediction: (code: string) =>
getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`), getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`),
metrics: (code: string, windowDays = 30) => metrics: (code: string, windowDays = 30) =>