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)
backend/artifacts/
backend/.cache/
backend/data/
.huggingface/
# Node

View File

@@ -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,178 @@ 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 을 필요한 만큼만 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}")
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 +227,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 +301,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 +330,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,

View File

@@ -46,11 +46,27 @@ def reseed_symbols() -> dict:
호출 예 (Windows cmd):
curl -X POST http://localhost:8000/api/refresh/seed/symbols
KRX 가 주말/장 마감 시간에 비정상 응답을 줄 때도 SEED 10 종목은 항상 보장하므로
엔드포인트는 200 을 돌려준다. 부분 성공 정보는 응답 body 에 담아 사용자가 판단.
"""
report = seed_symbols()
return {
"inserted": report.inserted,
"updated": report.updated,
"seed_marked": report.seed_marked,
"markets": report.markets,
}
try:
report = seed_symbols()
return {
"ok": True,
"inserted": report.inserted,
"updated": report.updated,
"seed_marked": report.seed_marked,
"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
@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]:
"""토큰 발급만 시도해서 키 유효성 확인."""
if not _has_keys():

View File

@@ -78,8 +78,11 @@ def seed_symbols() -> SeedReport:
_upsert_seed_tickers()
seed_marked = len(SEED_TICKERS)
logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked)
except Exception: # noqa: BLE001
logger.exception("seed_symbols: seed-tickers upsert failed (critical)")
except Exception as e: # noqa: BLE001
# 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
# 2) KRX 전 종목 — fetch 실패해도 부분 성공 허용
@@ -92,8 +95,8 @@ def seed_symbols() -> SeedReport:
for code, name in listing:
all_rows.append((code, name, market))
logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing))
except Exception: # noqa: BLE001
logger.exception("seed_symbols: KRX %s fetch failed — skip market", market)
except Exception as e: # noqa: BLE001
logger.error("seed_symbols: KRX %s fetch failed — skip market: %s", market, repr(e)[:300])
market_counts[market] = 0
inserted = updated = 0
@@ -122,8 +125,8 @@ def seed_symbols() -> SeedReport:
inserted += 1
else:
updated += 1
except Exception: # noqa: BLE001
logger.exception("seed_symbols: KRX bulk upsert failed (transaction rolled back)")
except Exception as e: # noqa: BLE001
logger.error("seed_symbols: KRX bulk upsert failed (transaction rolled back): %s", repr(e)[:300])
logger.info(
"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(),
# 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)
device = _resolve_device()
# bf16 은 RTX 30xx 이상에서 지원. cpu 에선 fp32.
dtype = torch.bfloat16 if device == "cuda" else torch.float32
# dtype 선택:
# - 이전엔 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)
pipe = ChronosPipeline.from_pretrained(
MODEL_NAME,
@@ -70,6 +84,26 @@ def _load() -> None:
_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(
series: list[float],
*,
@@ -88,14 +122,31 @@ def forecast(
import numpy as np
import torch
pipe = _state["pipe"]
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
with torch.no_grad():
samples = pipe.predict(
context=context,
prediction_length=horizon,
num_samples=num_samples,
)
def _do_predict():
pipe = _state["pipe"]
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
with torch.no_grad():
return pipe.predict(
context=context,
prediction_length=horizon,
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)
arr = samples[0].cpu().float().numpy()
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] = []
cf: ChronosForecast | None = None
chronos_err: str | None = None
try:
cf = chronos_forecast(closes, horizon=max_h, num_samples=30)
sources_used.append("chronos")
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] = []
lgbm_raw: dict[int, LgbmForecast] = {}
for h in horizons:
lf: LgbmForecast | None = None
lgbm_err: str | None = None
try:
lf = lgbm_predict(code, h)
if lf is not None:
sources_used.append(f"lgbm_h{h}")
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
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).
w = load_weights(code, h)
wc, wl = w.w_chronos, w.w_lgbm
# 한쪽이 없으면 다른 쪽 전부.
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:
wc, wl = 0.0, 1.0
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 {
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<ChartPayload | null>(null);
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [interval, setIntervalKind] = useState<ChartInterval>("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 } }) {
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<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={1095}> 3</option>
</select>
<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
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>
{chart && (
<div className="mb-4">
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-2xl font-semibold text-zinc-100">
{chart.name}{" "}
<span className="text-sm font-normal text-zinc-500">
{chart.code} · {chart.market}
</span>
</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>
)}

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) {
const [pred, setPred] = useState<LatestPredictionResponse | null>(initial ?? null);
const [loading, setLoading] = useState(false);
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() {
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 +105,49 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
</button>
</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>}
{pred?.found ? (

View File

@@ -16,12 +16,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 +47,9 @@ export function StockChart({ chart, prediction }: Props) {
const predLowRef = 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(() => {
if (!containerRef.current) return;
const c = createChart(containerRef.current, {
@@ -46,7 +62,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 +87,30 @@ 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);
// 오늘 표시는 차트 본체 위가 아니라 컨테이너 아래 캡션 (return JSX) 으로 옮김.
// lightweight-charts 의 timeScale tick 자체에 라벨을 끼울 공식 API 가 없어서,
// 시각적으로 동일한 위치 (시간축 바로 아래) 에 별도 div 로 렌더.
chartRef.current?.timeScale().fitContent();
}, [chart]);
}, [chart, isIntraday]);
// push prediction overlay
// push prediction overlay (10분봉에서는 표시 안 함 — 예측은 일봉 기준)
useEffect(() => {
if (!chartRef.current) return;
// remove previous overlay
if (predRef.current) {
chartRef.current.removeSeries(predRef.current);
predRef.current = null;
@@ -101,6 +123,7 @@ export function StockChart({ chart, prediction }: Props) {
chartRef.current.removeSeries(predHighRef.current);
predHighRef.current = null;
}
if (isIntraday) return;
if (!prediction || !prediction.found || !prediction.steps?.length) return;
const baseDate = prediction.base_date!;
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 med: LineData[] = [
{ time: dateToUtcTs(baseDate), value: baseClose },
{ time: isoToUtcTs(baseDate), value: baseClose },
...sorted
.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[] = [
{ time: dateToUtcTs(baseDate), value: baseClose },
{ time: isoToUtcTs(baseDate), value: baseClose },
...sorted
.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[] = [
{ time: dateToUtcTs(baseDate), value: baseClose },
{ time: isoToUtcTs(baseDate), value: baseClose },
...sorted
.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({
@@ -157,11 +180,31 @@ export function StockChart({ chart, prediction }: Props) {
predLowRef.current = loLine;
predHighRef.current = hiLine;
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 (
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
<div ref={containerRef} className="h-full w-full" />
<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>
{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 = {
// 1d/1w/1mo: 'YYYY-MM-DD' / 10m: 'YYYY-MM-DDTHH:MM:SS' (KST naive ISO)
date: string;
open: number | null;
high: number | null;
@@ -50,6 +51,8 @@ export type OhlcvPoint = {
volume: number | null;
};
export type ChartInterval = "10m" | "1d" | "1w" | "1mo";
export type SentimentPoint = {
date: string;
n_articles: number;
@@ -68,7 +71,10 @@ export type ChartPayload = {
code: string;
name: string;
market: string;
interval: ChartInterval;
intraday_status: string | null;
range: { from: string; to: string };
today: string;
ohlcv: OhlcvPoint[];
sentiment: SentimentPoint[];
trading_value: TradingValuePoint[];
@@ -178,13 +184,17 @@ export const api = {
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`,
),
getSymbol: (code: string) => getJson<Symbol>(`/api/symbols/${encodeURIComponent(code)}`),
getChart: (code: string, days = 180) =>
getJson<ChartPayload>(`/api/chart/${encodeURIComponent(code)}?days=${days}`),
predict: (code: string, horizons = "1,3,5") =>
getJson<PredictResponse>(
`/api/predict/${encodeURIComponent(code)}?horizons=${encodeURIComponent(horizons)}`,
{ method: "POST" },
getChart: (code: string, days = 180, interval: ChartInterval = "1d") =>
getJson<ChartPayload>(
`/api/chart/${encodeURIComponent(code)}?days=${days}&interval=${encodeURIComponent(interval)}`,
),
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) =>
getJson<LatestPredictionResponse>(`/api/predict/${encodeURIComponent(code)}/latest`),
metrics: (code: string, windowDays = 30) =>