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).
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
"""차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시).
|
||||
|
||||
UI: /code 페이지 첫 로드 시 호출 → lightweight-charts 캔들 데이터로 사용.
|
||||
UI: /code 페이지가 호출 → lightweight-charts 캔들 데이터로 사용.
|
||||
|
||||
첫 방문 시 ohlcv_daily 가 비어 있으면 (symbols 만 시드됨, daily_batch 아직 안 돔)
|
||||
즉시 pykrx 로 자동 갱신 — 사용자 입장에선 한 번의 차트 요청으로 데이터까지 충전.
|
||||
interval 파라미터로 캔들 단위 선택:
|
||||
- "10m" : 당일 10분봉. ohlcv_1m 을 time_bucket 으로 10분 단위 집계.
|
||||
stale (>10분) 이면 KIS inquire-time-itemchartprice 로 즉시 보충.
|
||||
- "1d" : 일봉. ohlcv_daily 직접 조회. 비어있으면 pykrx auto-refresh.
|
||||
- "1w" : 주봉. ohlcv_daily 를 date_trunc('week') 로 집계.
|
||||
- "1mo" : 월봉. ohlcv_daily 를 date_trunc('month') 로 집계.
|
||||
|
||||
10m 외에는 date 필드가 'YYYY-MM-DD' ISO date 문자열,
|
||||
10m 일 때는 'YYYY-MM-DDTHH:MM:SS' ISO datetime (KST) 으로 통일.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, time as dtime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
@@ -19,8 +26,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/chart", tags=["chart"])
|
||||
|
||||
ALLOWED_INTERVALS = ("10m", "1d", "1w", "1mo")
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
def _query_ohlcv(conn, code: str, start: date, end: date):
|
||||
|
||||
def _query_ohlcv_daily(conn, code: str, start: date, end: date):
|
||||
return conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -34,13 +44,150 @@ def _query_ohlcv(conn, code: str, start: date, end: date):
|
||||
).all()
|
||||
|
||||
|
||||
def _query_ohlcv_bucketed(conn, code: str, start: date, end: date, trunc: str):
|
||||
"""1d → 1w/1mo 집계. date_trunc 로 bucket 잡고, 첫/마지막/최고/최저/합 집계.
|
||||
|
||||
open=bucket 첫 거래일 시가, close=마지막 거래일 종가. PostgreSQL window 함수로 구한다.
|
||||
"""
|
||||
return conn.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH base AS (
|
||||
SELECT date_trunc(:trunc, date)::date AS bucket,
|
||||
date, open, high, low, close, volume
|
||||
FROM ohlcv_daily
|
||||
WHERE code = :c AND date BETWEEN :s AND :e
|
||||
),
|
||||
ranked AS (
|
||||
SELECT bucket, date, open, high, low, close, volume,
|
||||
ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY date ASC) AS rn_first,
|
||||
ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY date DESC) AS rn_last
|
||||
FROM base
|
||||
)
|
||||
SELECT bucket AS date,
|
||||
MAX(open) FILTER (WHERE rn_first = 1) AS open,
|
||||
MAX(high) AS high,
|
||||
MIN(low) AS low,
|
||||
MAX(close) FILTER (WHERE rn_last = 1) AS close,
|
||||
SUM(volume) AS volume
|
||||
FROM ranked
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
"""
|
||||
),
|
||||
{"c": code, "s": start, "e": end, "trunc": trunc},
|
||||
).all()
|
||||
|
||||
|
||||
def _query_ohlcv_10m(conn, code: str, start_ts: datetime, end_ts: datetime):
|
||||
"""ohlcv_1m → 10분봉. TimescaleDB time_bucket 으로 10분 단위 집계.
|
||||
|
||||
first()/last() 는 TimescaleDB 의 집계함수.
|
||||
"""
|
||||
return conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT time_bucket(INTERVAL '10 minutes', ts) AS bucket,
|
||||
first(open, ts) AS open,
|
||||
MAX(high) AS high,
|
||||
MIN(low) AS low,
|
||||
last(close, ts) AS close,
|
||||
SUM(volume) AS volume
|
||||
FROM ohlcv_1m
|
||||
WHERE code = :c AND ts >= :s AND ts < :e
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
"""
|
||||
),
|
||||
{"c": code, "s": start_ts, "e": end_ts},
|
||||
).all()
|
||||
|
||||
|
||||
def _upsert_ohlcv_1m(conn, code: str, rows: list[dict]) -> int:
|
||||
"""KIS 분봉 응답을 ohlcv_1m 에 UPSERT. 같은 (code, ts) 는 덮어쓰기 (장중 갱신용)."""
|
||||
if not rows:
|
||||
return 0
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ohlcv_1m (code, ts, open, high, low, close, volume)
|
||||
VALUES (:code, :ts, :open, :high, :low, :close, :volume)
|
||||
ON CONFLICT (code, ts) DO UPDATE SET
|
||||
open = EXCLUDED.open,
|
||||
high = EXCLUDED.high,
|
||||
low = EXCLUDED.low,
|
||||
close = EXCLUDED.close,
|
||||
volume = EXCLUDED.volume
|
||||
"""
|
||||
),
|
||||
[{"code": code, **r} for r in rows],
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _intraday_window_today() -> tuple[datetime, datetime]:
|
||||
"""오늘 KST 의 장 시간대 윈도우 (08:50 ~ 16:00). 토/일은 직전 영업일."""
|
||||
now = datetime.now(KST)
|
||||
d = now.date()
|
||||
# 주말이면 직전 금요일로
|
||||
while d.weekday() >= 5:
|
||||
d -= timedelta(days=1)
|
||||
start = datetime.combine(d, dtime(8, 50), tzinfo=KST)
|
||||
end = datetime.combine(d, dtime(16, 0), tzinfo=KST)
|
||||
return start, end
|
||||
|
||||
|
||||
def _ensure_intraday_fresh(conn, code: str) -> str:
|
||||
"""마지막 ohlcv_1m 데이터가 10분 이상 오래됐으면 KIS 에서 보충.
|
||||
|
||||
Returns: 'fresh' | 'refreshed' | 'skipped_missing_key' | 'failed' | 'no_data'
|
||||
"""
|
||||
last_ts = conn.execute(
|
||||
text("SELECT MAX(ts) FROM ohlcv_1m WHERE code = :c"),
|
||||
{"c": code},
|
||||
).scalar()
|
||||
now = datetime.now(KST)
|
||||
# 평일 장중 (09:00~15:30) 이 아니면 데이터가 더 들어올 일이 없으니 마지막 캐시 그대로.
|
||||
market_open = dtime(9, 0)
|
||||
market_close = dtime(15, 35)
|
||||
in_session = (
|
||||
now.weekday() < 5
|
||||
and market_open <= now.time() <= market_close
|
||||
)
|
||||
if last_ts is not None and (now - last_ts) < timedelta(minutes=10) and in_session:
|
||||
return "fresh"
|
||||
|
||||
try:
|
||||
from app.fetch.kis import SkippedMissingKey, fetch_minute_price
|
||||
except Exception: # noqa: BLE001
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
rows = fetch_minute_price(code)
|
||||
except SkippedMissingKey:
|
||||
return "skipped_missing_key"
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("intraday refresh failed for %s", code)
|
||||
return "failed"
|
||||
|
||||
if not rows:
|
||||
return "no_data"
|
||||
_upsert_ohlcv_1m(conn, code, rows)
|
||||
conn.commit()
|
||||
return "refreshed"
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_chart(
|
||||
code: str,
|
||||
days: int = Query(default=180, ge=10, le=3650),
|
||||
days: int = Query(default=180, ge=1, le=3650),
|
||||
interval: str = Query(default="1d"),
|
||||
include_sentiment: bool = Query(default=True),
|
||||
include_trading_value: bool = Query(default=True),
|
||||
) -> dict:
|
||||
if interval not in ALLOWED_INTERVALS:
|
||||
raise HTTPException(status_code=400, detail=f"interval must be one of {ALLOWED_INTERVALS}")
|
||||
|
||||
eng = get_engine()
|
||||
end = date.today()
|
||||
start = end - timedelta(days=days)
|
||||
@@ -52,33 +199,58 @@ def get_chart(
|
||||
if not symbol:
|
||||
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
||||
|
||||
ohlcv_rows = _query_ohlcv(conn, code, start, end)
|
||||
if not ohlcv_rows:
|
||||
# 첫 방문 — ohlcv_daily 가 비어있다. pykrx 로 즉시 채우고 재조회.
|
||||
# refresh_code 는 별도 트랜잭션으로 ohlcv/trading_value/news 모두 commit 하므로
|
||||
# 이 conn 에서 다시 SELECT 하면 새 행이 보인다. lookback 은 차트 요청 범위 +
|
||||
# 예측 모델 학습용 마진 (Chronos/LightGBM 이 충분한 과거 시계열을 요구) 으로 365 하한.
|
||||
try:
|
||||
from app.pipelines.refresh_one import refresh_code
|
||||
logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code)
|
||||
refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365))
|
||||
ohlcv_rows = _query_ohlcv(conn, code, start, end)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("chart: auto-refresh failed for %s", code)
|
||||
ohlcv = [
|
||||
{
|
||||
"date": str(r[0]),
|
||||
"open": float(r[1]) if r[1] is not None else None,
|
||||
"high": float(r[2]) if r[2] is not None else None,
|
||||
"low": float(r[3]) if r[3] is not None else None,
|
||||
"close": float(r[4]) if r[4] is not None else None,
|
||||
"volume": int(r[5]) if r[5] is not None else None,
|
||||
}
|
||||
for r in ohlcv_rows
|
||||
]
|
||||
ohlcv: list[dict] = []
|
||||
intraday_status: str | None = None
|
||||
|
||||
if interval == "10m":
|
||||
intraday_status = _ensure_intraday_fresh(conn, code)
|
||||
win_start, win_end = _intraday_window_today()
|
||||
rows = _query_ohlcv_10m(conn, code, win_start, win_end)
|
||||
ohlcv = [
|
||||
{
|
||||
# KST aware datetime → ISO datetime. 프론트에서 Date 파싱.
|
||||
"date": (r[0].astimezone(KST) if r[0].tzinfo else r[0].replace(tzinfo=KST))
|
||||
.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"open": float(r[1]) if r[1] is not None else None,
|
||||
"high": float(r[2]) if r[2] is not None else None,
|
||||
"low": float(r[3]) if r[3] is not None else None,
|
||||
"close": float(r[4]) if r[4] is not None else None,
|
||||
"volume": int(r[5]) if r[5] is not None else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
else:
|
||||
if interval == "1d":
|
||||
rows = _query_ohlcv_daily(conn, code, start, end)
|
||||
elif interval == "1w":
|
||||
rows = _query_ohlcv_bucketed(conn, code, start, end, "week")
|
||||
else: # "1mo"
|
||||
rows = _query_ohlcv_bucketed(conn, code, start, end, "month")
|
||||
|
||||
if not rows and interval == "1d":
|
||||
# 첫 방문 → pykrx auto-refresh.
|
||||
try:
|
||||
from app.pipelines.refresh_one import refresh_code
|
||||
logger.info("chart: ohlcv_daily empty for %s — auto-refresh", code)
|
||||
refresh_code(symbol[0], symbol[1], lookback_days=max(days, 365))
|
||||
rows = _query_ohlcv_daily(conn, code, start, end)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("chart: auto-refresh failed for %s", code)
|
||||
|
||||
ohlcv = [
|
||||
{
|
||||
"date": str(r[0]),
|
||||
"open": float(r[1]) if r[1] is not None else None,
|
||||
"high": float(r[2]) if r[2] is not None else None,
|
||||
"low": float(r[3]) if r[3] is not None else None,
|
||||
"close": float(r[4]) if r[4] is not None else None,
|
||||
"volume": int(r[5]) if r[5] is not None else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
sentiment: list[dict] = []
|
||||
if include_sentiment:
|
||||
if include_sentiment and interval != "10m":
|
||||
try:
|
||||
s_rows = conn.execute(
|
||||
text(
|
||||
@@ -101,11 +273,10 @@ def get_chart(
|
||||
for r in s_rows
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
# v_sentiment_daily 뷰 아직 없을 수 있음 (마이그레이션 미실행)
|
||||
sentiment = []
|
||||
|
||||
trading: list[dict] = []
|
||||
if include_trading_value:
|
||||
if include_trading_value and interval != "10m":
|
||||
tv_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -131,7 +302,10 @@ def get_chart(
|
||||
"code": symbol[0],
|
||||
"name": symbol[1],
|
||||
"market": symbol[2],
|
||||
"interval": interval,
|
||||
"intraday_status": intraday_status,
|
||||
"range": {"from": str(start), "to": str(end)},
|
||||
"today": date.today().isoformat(),
|
||||
"ohlcv": ohlcv,
|
||||
"sentiment": sentiment,
|
||||
"trading_value": trading,
|
||||
|
||||
Reference in New Issue
Block a user