Compare commits

8 Commits

Author SHA1 Message Date
Claude
bf898d78be chore(gitignore): backend/data/ 추가 (LGBM 학습 체크포인트)
리뷰어가 호스트에서 backend/data/ 가 untracked 로 잡힌 걸 발견. LGBM
체크포인트는 런타임 산출물이라 커밋되면 안 됨.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 17:01:41 +09:00
Claude
73593adb5c fix(seed): /api/refresh/seed/symbols 500 → 200 (logger.exception 우회)
pykrx 가 주말/장마감 시 KRX 에서 비-JSON 응답을 받아 JSONDecodeError 를
던지는데, except 블록의 logger.exception() 이 traceback 을 포매팅하다
Python 3.11 의 _byte_offset_to_character_offset 버그(pykrx 소스의 한글
주석 'df = 가...' 바이트 처리) 로 UnicodeDecodeError 가 떠서 try 밖으로
escape — 500 의 진짜 원인이었다.

- symbols_seed.py: logger.exception → logger.error(repr(e)) 로 교체.
  traceback 포매팅을 피한다.
- refresh.py: 라우트 핸들러를 try/except 로 감싸 만일 다른 경로로
  예외가 새도 200 + ok:false 로 응답. SEED 10 종목은 별도 트랜잭션이라
  KRX fetch 실패와 무관하게 보장됨.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:54:55 +09:00
claude-owner
323061df02 fix(chronos): inference RuntimeError 시 자동 CPU 폴백
ea88597 의 fp32 변경만으로는 sm_86 커널 누락 케이스를 100% 회피한다는
보장이 없음 (에러 메시지가 dtype 과 무관한 dispatch 단계에서 날 수 있어
fp32 도 같은 증상 가능). 그래서 forecast() 안에서 RuntimeError 잡아
'no kernel image' / 'CUDA error' / 'CUBLAS' 신호면 pipeline 을 CPU 로
재로드하고 한 번 더 추론. 폴백 후엔 그 세션 동안 계속 CPU 사용 (재시도
비용 회피).

이로써 사용자는 환경변수 수동 변경 없이도 GPU 비호환 시 자동으로 차트가
뜸. GPU 가 잘 도는 경우는 영향 없음.
2026-05-23 16:09:09 +09:00
claude-owner
ea885973c7 fix(chronos): cuda 도 fp32 기본 — sm_86 bf16 커널 누락 우회
torch 2.3.1+cu121 사전빌드 wheel 이 RTX 3070 Ti(sm_86) 의 일부 T5 bf16
커널을 빠뜨려서 inference 첫 호출에 'no kernel image is available for
execution on the device' 가 났음. ping/_load 까지는 통과해 진단이 까다로움.

chronos-t5-small 은 46M params 라 fp32 로도 8GB VRAM 여유 충분. cuda 도
fp32 default 로 바꾸고, CHRONOS_DTYPE=bf16|fp16 env 로 강제 가능하게 둠.

005930 h=1 예측 409 의 실제 원인이 이거였음 (LAN 으로 직접 확인).
2026-05-23 16:03:31 +09:00
claude-owner
e0edc8f1e3 feat: 예측 실패 원인 노출 + /health/models 진단 + restart-ci.bat
사금향님이 만난 409 'both chronos & lgbm failed' 에러가 원인을 안 보여줘서
디버깅 어려웠음. 세 군데 보강:

1. ensemble.py: 두 모델 다 실패 시 chronos/lgbm 각각의 실제 에러 원문
   (type:message) 을 RuntimeError 메시지에 포함. predict.py 가 409 detail
   로 그대로 노출하므로 브라우저에서 바로 원인 확인 가능. LGBM 가 None
   반환 (체크포인트 없음) 인 경우도 'model checkpoint not found' 로 명시.

2. /health/models 엔드포인트 추가:
   - chronos.ping() — lazy load 시도 + 디바이스/모델명 반환
   - LGBM_MODEL_DIR 의 *.pkl 개수와 샘플 8개 파일명 반환. cold start
     (체크포인트 0개) 면 'no_checkpoints' 상태로 알림.

3. restart-ci.bat 추가 — restart.bat 에서 pause 빼고 종료 코드로만 알리는
   SSH 비대화형 친화 버전. 일반 사용은 그대로 restart.bat.
2026-05-23 15:42:44 +09:00
claude-owner
44873ddb39 fix(chart): "오늘" 표시를 차트 본체 마커 → 시간축 아래 캡션으로 이동
lightweight-charts 의 timeScale tick 라벨에 직접 텍스트를 끼우는 공식 API 가
없어서, 차트 컨테이너 바로 아래에 작은 캡션 (• 오늘 · YYYY-MM-DD (요일)) 으로
표시. 사용자 요청 ("차트에 표시 말고 아래 날짜 표시하는 곳에").
2026-05-23 02:49:36 +09:00
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
13 changed files with 778 additions and 108 deletions

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ build/
# Models / artifacts (downloaded HF caches, trained LGBM) # Models / artifacts (downloaded HF caches, trained LGBM)
backend/artifacts/ backend/artifacts/
backend/.cache/ backend/.cache/
backend/data/
.huggingface/ .huggingface/
# Node # Node

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,19 +227,44 @@ 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()
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: try:
from app.pipelines.refresh_one import refresh_code from app.pipelines.refresh_one import refresh_code
logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code) logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code)
refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365)) refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365))
ohlcv_rows = _query_ohlcv(conn, code, start, end) rows = _query_ohlcv_daily(conn, code, start, end)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.exception("chart: auto-refresh failed for %s", code) logger.exception("chart: auto-refresh failed for %s", code)
ohlcv = [ ohlcv = [
{ {
"date": str(r[0]), "date": str(r[0]),
@@ -74,11 +274,11 @@ def get_chart(
"close": float(r[4]) if r[4] 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, "volume": int(r[5]) if r[5] is not None else None,
} }
for r in ohlcv_rows 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

@@ -46,11 +46,27 @@ def reseed_symbols() -> dict:
호출 예 (Windows cmd): 호출 예 (Windows cmd):
curl -X POST http://localhost:8000/api/refresh/seed/symbols curl -X POST http://localhost:8000/api/refresh/seed/symbols
KRX 가 주말/장 마감 시간에 비정상 응답을 줄 때도 SEED 10 종목은 항상 보장하므로
엔드포인트는 200 을 돌려준다. 부분 성공 정보는 응답 body 에 담아 사용자가 판단.
""" """
try:
report = seed_symbols() report = seed_symbols()
return { return {
"ok": True,
"inserted": report.inserted, "inserted": report.inserted,
"updated": report.updated, "updated": report.updated,
"seed_marked": report.seed_marked, "seed_marked": report.seed_marked,
"markets": report.markets, "markets": report.markets,
} }
except Exception as e: # noqa: BLE001
# seed_symbols 내부에서 다 잡지만, 만에 하나 외부로 새는 예외 (logger 포매터
# 자체 버그 등) 도 200 으로 흡수해서 SEED 10 만이라도 살리는 게 UX 목표.
return {
"ok": False,
"inserted": 0,
"updated": 0,
"seed_marked": 0,
"markets": {},
"error": repr(e)[:300],
}

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

@@ -78,8 +78,11 @@ def seed_symbols() -> SeedReport:
_upsert_seed_tickers() _upsert_seed_tickers()
seed_marked = len(SEED_TICKERS) seed_marked = len(SEED_TICKERS)
logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked) logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked)
except Exception: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.exception("seed_symbols: seed-tickers upsert failed (critical)") # logger.exception 은 Python 3.11 의 traceback 포매터가 pykrx 소스의 한글 주석
# 'df = 가...' 바이트를 만나면 UnicodeDecodeError 를 던지는 버그가 있어, 그 예외가
# try 밖으로 escape 해서 500 을 만든다. 그래서 traceback 안 찍는다.
logger.error("seed_symbols: seed-tickers upsert failed: %s", repr(e)[:300])
seed_marked = 0 seed_marked = 0
# 2) KRX 전 종목 — fetch 실패해도 부분 성공 허용 # 2) KRX 전 종목 — fetch 실패해도 부분 성공 허용
@@ -92,8 +95,8 @@ def seed_symbols() -> SeedReport:
for code, name in listing: for code, name in listing:
all_rows.append((code, name, market)) all_rows.append((code, name, market))
logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing)) logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing))
except Exception: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.exception("seed_symbols: KRX %s fetch failed — skip market", market) logger.error("seed_symbols: KRX %s fetch failed — skip market: %s", market, repr(e)[:300])
market_counts[market] = 0 market_counts[market] = 0
inserted = updated = 0 inserted = updated = 0
@@ -122,8 +125,8 @@ def seed_symbols() -> SeedReport:
inserted += 1 inserted += 1
else: else:
updated += 1 updated += 1
except Exception: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.exception("seed_symbols: KRX bulk upsert failed (transaction rolled back)") logger.error("seed_symbols: KRX bulk upsert failed (transaction rolled back): %s", repr(e)[:300])
logger.info( logger.info(
"seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s", "seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s",

View File

@@ -132,3 +132,30 @@ def health_keys() -> dict[str, object]:
"dart": dart_mod.ping(), "dart": dart_mod.ping(),
# huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피) # huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피)
} }
@app.get("/health/models")
def health_models() -> dict[str, object]:
"""Chronos / LGBM 가용성 진단.
Chronos: lazy 로드 첫 호출이라 30초~수 분 걸릴 수 있음 (HuggingFace 다운로드).
LGBM: 체크포인트 디렉토리 스캔 — retrain 안 돈 cold start 에선 비어있음.
"""
from pathlib import Path
from app.models import chronos as chronos_mod
lgbm_dir = Path(os.environ.get("LGBM_MODEL_DIR", "/app/data/models"))
lgbm_files: list[str] = []
if lgbm_dir.exists():
lgbm_files = sorted(p.name for p in lgbm_dir.glob("*.pkl"))
return {
"chronos": chronos_mod.ping(),
"lgbm": {
"model_dir": str(lgbm_dir),
"checkpoint_count": len(lgbm_files),
"samples": lgbm_files[:8], # 너무 많으면 잘라서.
"status": "ok" if lgbm_files else "no_checkpoints (cold start, run retrain_weekly)",
},
}

View File

@@ -59,8 +59,22 @@ def _load() -> None:
os.environ.setdefault("HF_TOKEN", token) os.environ.setdefault("HF_TOKEN", token)
device = _resolve_device() device = _resolve_device()
# bf16 은 RTX 30xx 이상에서 지원. cpu 에선 fp32. # dtype 선택:
dtype = torch.bfloat16 if device == "cuda" else torch.float32 # - 이전엔 cuda 면 무조건 bf16 으로 갔는데, torch 2.3.1+cu121 사전빌드 wheel 이
# sm_86 (RTX 3070 Ti) 의 일부 T5 커널 binary 를 빠뜨려서 inference 첫 호출에
# "no kernel image is available for execution on the device" 발생. ping/load
# 까지는 통과해서 진단이 까다로웠음 (실제 005930 케이스에서 관측).
# - chronos-t5-small 은 46M params 라 fp32 로도 8GB VRAM 에 여유 충분, 속도
# 차이도 일봉 30일 예측에선 무시 가능. 호환성 우선해 default 를 fp32 로.
# - 드라이버/torch 업그레이드 후 다시 bf16 시험하려면 .env 에
# CHRONOS_DTYPE=bf16 (또는 fp16) 두면 됨.
dtype_pref = os.environ.get("CHRONOS_DTYPE", "fp32").lower()
if device == "cuda" and dtype_pref == "bf16":
dtype = torch.bfloat16
elif device == "cuda" and dtype_pref == "fp16":
dtype = torch.float16
else:
dtype = torch.float32
logger.info("loading Chronos %s on %s (dtype=%s)", MODEL_NAME, device, dtype) logger.info("loading Chronos %s on %s (dtype=%s)", MODEL_NAME, device, dtype)
pipe = ChronosPipeline.from_pretrained( pipe = ChronosPipeline.from_pretrained(
MODEL_NAME, MODEL_NAME,
@@ -70,6 +84,26 @@ def _load() -> None:
_state.update({"loaded": True, "pipe": pipe, "device": device}) _state.update({"loaded": True, "pipe": pipe, "device": device})
def _reload_cpu() -> None:
"""현재 pipeline 을 폐기하고 CPU 로 강제 재로드.
cuda 환경에서 'no kernel image is available for execution on the device' 같이
런타임에야 드러나는 GPU 비호환 에러가 났을 때 자동 폴백용. 한 번 폴백하면
다음 호출부터는 CPU 그대로 사용 (재시도 비용 회피)."""
global _state
import torch
from chronos import ChronosPipeline
with _lock:
logger.warning("falling back to CPU for Chronos (GPU inference failed)")
_state.update({"loaded": False, "pipe": None, "device": None})
pipe = ChronosPipeline.from_pretrained(
MODEL_NAME,
device_map="cpu",
torch_dtype=torch.float32,
)
_state.update({"loaded": True, "pipe": pipe, "device": "cpu"})
def forecast( def forecast(
series: list[float], series: list[float],
*, *,
@@ -88,14 +122,31 @@ def forecast(
import numpy as np import numpy as np
import torch import torch
def _do_predict():
pipe = _state["pipe"] pipe = _state["pipe"]
context = torch.tensor([float(x) for x in series], dtype=torch.float32) context = torch.tensor([float(x) for x in series], dtype=torch.float32)
with torch.no_grad(): with torch.no_grad():
samples = pipe.predict( return pipe.predict(
context=context, context=context,
prediction_length=horizon, prediction_length=horizon,
num_samples=num_samples, num_samples=num_samples,
) )
try:
samples = _do_predict()
except RuntimeError as exc:
# cuda 빌드/드라이버 미스매치는 inference 시점에야 드러나는 경우가 많음.
# 'no kernel image is available' / 'CUDA error' 같은 신호 잡아서 CPU 로 폴백.
msg = str(exc)
if _state.get("device") == "cuda" and (
"no kernel image" in msg
or "CUDA error" in msg
or "CUBLAS" in msg
):
_reload_cpu()
samples = _do_predict()
else:
raise
# samples: (1, num_samples, prediction_length) # samples: (1, num_samples, prediction_length)
arr = samples[0].cpu().float().numpy() arr = samples[0].cpu().float().numpy()
q10 = np.quantile(arr, 0.10, axis=0).tolist() q10 = np.quantile(arr, 0.10, axis=0).tolist()

View File

@@ -87,30 +87,41 @@ def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePred
sources_used: list[str] = [] sources_used: list[str] = []
cf: ChronosForecast | None = None cf: ChronosForecast | None = None
chronos_err: str | None = None
try: try:
cf = chronos_forecast(closes, horizon=max_h, num_samples=30) cf = chronos_forecast(closes, horizon=max_h, num_samples=30)
sources_used.append("chronos") sources_used.append("chronos")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("chronos forecast failed for %s: %s", code, exc) chronos_err = f"{type(exc).__name__}: {exc}"
logger.warning("chronos forecast failed for %s: %s", code, chronos_err)
steps: list[EnsembleStep] = [] steps: list[EnsembleStep] = []
lgbm_raw: dict[int, LgbmForecast] = {} lgbm_raw: dict[int, LgbmForecast] = {}
for h in horizons: for h in horizons:
lf: LgbmForecast | None = None lf: LgbmForecast | None = None
lgbm_err: str | None = None
try: try:
lf = lgbm_predict(code, h) lf = lgbm_predict(code, h)
if lf is not None: if lf is not None:
sources_used.append(f"lgbm_h{h}") sources_used.append(f"lgbm_h{h}")
lgbm_raw[h] = lf lgbm_raw[h] = lf
else:
# predict_one 이 None 반환 = 체크포인트 파일 없음 (cold start).
lgbm_err = "model checkpoint not found (run retrain_weekly)"
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, exc) lgbm_err = f"{type(exc).__name__}: {exc}"
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, lgbm_err)
# 가중치 (DB 없으면 default 0.6/0.4). # 가중치 (DB 없으면 default 0.6/0.4).
w = load_weights(code, h) w = load_weights(code, h)
wc, wl = w.w_chronos, w.w_lgbm wc, wl = w.w_chronos, w.w_lgbm
# 한쪽이 없으면 다른 쪽 전부. # 한쪽이 없으면 다른 쪽 전부.
if cf is None and lf is None: if cf is None and lf is None:
raise RuntimeError(f"both chronos & lgbm failed for {code} h={h}") # 사용자가 브라우저에서 바로 원인을 보게 두 에러를 그대로 노출.
raise RuntimeError(
f"both chronos & lgbm failed for {code} h={h}; "
f"chronos={chronos_err or 'unknown'}; lgbm={lgbm_err or 'unknown'}"
)
if cf is None: if cf is None:
wc, wl = 0.0, 1.0 wc, wl = 0.0, 1.0
if lf is None: if lf is None:

57
restart-ci.bat Normal file
View File

@@ -0,0 +1,57 @@
@echo off
REM stock_chart_site - SSH/CI 친화 재시작 스크립트
REM
REM restart.bat 과의 차이: pause 가 없음. SSH 비대화형 (예: ssh user@host "restart-ci.bat")
REM 에서 멈추지 않고 끝까지 실행. 에러는 종료 코드로만 알린다.
REM
REM 일반 사용 시엔 restart.bat 을 쓰는게 출력 검토에 편하다.
setlocal enabledelayedexpansion
cd /d "%~dp0"
echo === stock_chart_site restart-ci ===
where docker >nul 2>&1
if errorlevel 1 (
echo [ERROR] docker not found
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo [ERROR] Docker Desktop not running
exit /b 1
)
set USE_GPU=0
where nvidia-smi >nul 2>&1
if not errorlevel 1 (
nvidia-smi >nul 2>&1
if not errorlevel 1 set USE_GPU=1
)
if "%USE_GPU%"=="1" (
echo [GPU] using GPU profile
set COMPOSE_FILES=-f docker-compose.yml -f docker-compose.gpu.yml
) else (
echo [CPU] using CPU profile
set COMPOSE_FILES=-f docker-compose.yml
)
for /f %%i in ('docker compose %COMPOSE_FILES% ps --status running --quiet backend web 2^>nul ^| find /v /c ""') do set RUN_COUNT=%%i
if "%RUN_COUNT%"=="0" (
echo [ERROR] backend/web not running. run build.bat first.
exit /b 1
)
echo === docker compose up -d --force-recreate --no-deps backend web ===
docker compose %COMPOSE_FILES% up -d --force-recreate --no-deps backend web
if errorlevel 1 (
echo [ERROR] restart failed
exit /b 1
)
echo === status ===
docker compose %COMPOSE_FILES% ps
endlocal
exit /b 0

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);
const load = () => {
api api
.getChart(code, days) .getChart(code, days, interval)
.then((c) => { .then((c) => {
if (alive) setChart(c); if (alive) setChart(c);
}) })
.catch((e) => { .catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(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,6 +87,23 @@ 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>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-xs">
{INTERVALS.map((it) => (
<button
key={it.value}
onClick={() => pickInterval(it.value)}
className={
interval === it.value
? "bg-emerald-700 px-3 py-1 text-white"
: "bg-zinc-900 px-3 py-1 text-zinc-300 hover:bg-zinc-800"
}
>
{it.label}
</button>
))}
</div>
{interval !== "10m" && (
<select <select
value={days} value={days}
onChange={(e) => setDays(Number(e.target.value))} onChange={(e) => setDays(Number(e.target.value))}
@@ -65,18 +112,30 @@ export default function CodePage({ params }: { params: { code: string } }) {
<option value={60}> 3</option> <option value={60}> 3</option>
<option value={180}> 6</option> <option value={180}> 6</option>
<option value={365}> 1</option> <option value={365}> 1</option>
<option value={1095}> 3</option> <option value={365 * 2}> 2</option>
<option value={365 * 5}> 5</option>
<option value={365 * 10}> 10</option>
</select> </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

@@ -16,12 +16,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( 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)),
) / 1000) as UTCTimestamp;
}
// datetime: YYYY-MM-DDTHH:MM:SS
return (Date.UTC(
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; ) / 1000) as UTCTimestamp;
} }
@@ -33,7 +47,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 +62,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 +87,30 @@ 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);
// 오늘 표시는 차트 본체 위가 아니라 컨테이너 아래 캡션 (return JSX) 으로 옮김.
// lightweight-charts 의 timeScale tick 자체에 라벨을 끼울 공식 API 가 없어서,
// 시각적으로 동일한 위치 (시간축 바로 아래) 에 별도 div 로 렌더.
chartRef.current?.timeScale().fitContent(); chartRef.current?.timeScale().fitContent();
}, [chart]); }, [chart, isIntraday]);
// push prediction overlay // 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 +123,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 +131,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,11 +180,31 @@ 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]);
// 오늘 라벨 — 차트 본체에 마커 대신 시간축 바로 아래에 작은 캡션으로.
// 10분봉은 데이터 자체가 오늘 하루라 굳이 라벨 불필요.
const todayLabel =
!isIntraday && chart.today
? new Date(chart.today + "T00:00:00").toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
})
: null;
return ( return (
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2"> <div className="w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
<div className="h-[460px] w-full">
<div ref={containerRef} className="h-full w-full" /> <div ref={containerRef} className="h-full w-full" />
</div> </div>
{todayLabel && (
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-xs text-zinc-400">
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
<span> · {todayLabel}</span>
</div>
)}
</div>
); );
} }

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) =>