Compare commits
2 Commits
928c2160f9
...
e610599879
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e610599879 | ||
|
|
0a5c634680 |
@@ -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,19 +227,44 @@ 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 하한.
|
||||
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))
|
||||
ohlcv_rows = _query_ohlcv(conn, code, start, end)
|
||||
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]),
|
||||
@@ -74,11 +274,11 @@ def get_chart(
|
||||
"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
|
||||
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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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);
|
||||
|
||||
const load = () => {
|
||||
api
|
||||
.getChart(code, days)
|
||||
.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,6 +87,23 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 검색으로
|
||||
</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
|
||||
value={days}
|
||||
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={180}>최근 6개월</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type LineData,
|
||||
type SeriesMarker,
|
||||
type Time,
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||
@@ -16,12 +18,26 @@ type Props = {
|
||||
prediction?: LatestPredictionResponse | null;
|
||||
};
|
||||
|
||||
function dateToUtcTs(d: string): UTCTimestamp {
|
||||
// 'YYYY-MM-DD' → UTC midnight epoch seconds
|
||||
// 'YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS' (KST naive, 백엔드가 +09:00 시각의 wall-clock 을
|
||||
// 그대로 ISO 로 직렬화) 를 UTCTimestamp 로. lightweight-charts 는 timestamp 가 UTC 라고
|
||||
// 가정하지만, 우리는 KST wall-clock 을 UTC 인 척 넣는다 — timeScale 의 표시도 KST 그대로
|
||||
// 나와서 한국 사용자에겐 가장 직관적.
|
||||
function isoToUtcTs(s: string): UTCTimestamp {
|
||||
if (s.length <= 10) {
|
||||
return (Date.UTC(
|
||||
Number(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)),
|
||||
) / 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;
|
||||
}
|
||||
|
||||
@@ -33,7 +49,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 +64,11 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
horzLines: { color: "#1f2937" },
|
||||
},
|
||||
rightPriceScale: { borderColor: "#374151" },
|
||||
timeScale: { borderColor: "#374151", timeVisible: false },
|
||||
timeScale: {
|
||||
borderColor: "#374151",
|
||||
timeVisible: isIntraday,
|
||||
secondsVisible: false,
|
||||
},
|
||||
autoSize: true,
|
||||
});
|
||||
const candle = c.addCandlestickSeries({
|
||||
@@ -67,28 +89,49 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
predLowRef.current = null;
|
||||
predHighRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [isIntraday]);
|
||||
|
||||
// push candle data
|
||||
// push candle data + today marker
|
||||
useEffect(() => {
|
||||
if (!candleRef.current) return;
|
||||
const data: CandlestickData[] = chart.ohlcv
|
||||
.filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null)
|
||||
.map((p) => ({
|
||||
time: dateToUtcTs(p.date),
|
||||
time: isoToUtcTs(p.date),
|
||||
open: p.open as number,
|
||||
high: p.high as number,
|
||||
low: p.low as number,
|
||||
close: p.close as number,
|
||||
}));
|
||||
candleRef.current.setData(data);
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}, [chart]);
|
||||
|
||||
// push prediction overlay
|
||||
// 오늘 날짜 마커: 일/주/월봉에서만 표시 (10분봉은 데이터 자체가 오늘 하루라 무의미).
|
||||
// markers 는 데이터 포인트의 time 과 일치해야 표시되므로, 오늘 또는 가장 가까운 과거
|
||||
// 거래일을 찾는다.
|
||||
if (!isIntraday && chart.today) {
|
||||
const todayTs = isoToUtcTs(chart.today);
|
||||
// 차트의 마지막 데이터가 오늘이면 그 위에, 아니면 마지막 데이터 위에 "오늘" 표시.
|
||||
const lastTs = data.length > 0 ? (data[data.length - 1].time as UTCTimestamp) : null;
|
||||
const markerTime = (lastTs && lastTs <= todayTs ? lastTs : todayTs) as UTCTimestamp;
|
||||
const markers: SeriesMarker<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(() => {
|
||||
if (!chartRef.current) return;
|
||||
// remove previous overlay
|
||||
if (predRef.current) {
|
||||
chartRef.current.removeSeries(predRef.current);
|
||||
predRef.current = null;
|
||||
@@ -101,6 +144,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 +152,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,7 +201,7 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
predLowRef.current = loLine;
|
||||
predHighRef.current = hiLine;
|
||||
chartRef.current.timeScale().fitContent();
|
||||
}, [prediction]);
|
||||
}, [prediction, isIntraday]);
|
||||
|
||||
return (
|
||||
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user