Compare commits
21 Commits
5e6ce11491
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf898d78be | ||
|
|
73593adb5c | ||
|
|
323061df02 | ||
|
|
ea885973c7 | ||
|
|
e0edc8f1e3 | ||
|
|
44873ddb39 | ||
|
|
e610599879 | ||
|
|
0a5c634680 | ||
|
|
928c2160f9 | ||
|
|
78388d347e | ||
|
|
659871118f | ||
|
|
bd47198088 | ||
|
|
fa817b31e4 | ||
|
|
96b7afd443 | ||
|
|
89651251a4 | ||
|
|
296bd6dccd | ||
|
|
2c42c1151c | ||
|
|
9c7c02703a | ||
|
|
e08f3b0765 | ||
|
|
eb56025d9c | ||
|
|
6c792305a9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ build/
|
||||
# Models / artifacts (downloaded HF caches, trained LGBM)
|
||||
backend/artifacts/
|
||||
backend/.cache/
|
||||
backend/data/
|
||||
.huggingface/
|
||||
|
||||
# Node
|
||||
|
||||
@@ -7,27 +7,55 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PYTHONPATH=/app \
|
||||
TZ=Asia/Seoul
|
||||
|
||||
# Ubuntu 22.04 의 python3-pip 는 python3.10 을 가리키므로 설치하지 않고,
|
||||
# python3.11 + get-pip.py 로 3.11 전용 pip 를 부트스트랩한다.
|
||||
# (Debian/Ubuntu 의 시스템 python 은 ensurepip 가 막혀 있어 get-pip.py 가 가장 깔끔함.)
|
||||
# 이후 모든 호출은 `python -m pip` 로 통일해 인터프리터/스크립트 불일치를 차단.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
python3.11 python3.11-venv \
|
||||
build-essential git curl ca-certificates tzdata \
|
||||
libgomp1 \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/local/bin/python \
|
||||
&& ln -sf /usr/bin/python3.11 /usr/local/bin/python3 \
|
||||
&& curl -sSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py \
|
||||
&& python /tmp/get-pip.py \
|
||||
&& rm /tmp/get-pip.py \
|
||||
&& python -m pip install --upgrade pip wheel \
|
||||
&& python -m pip install "setuptools<80" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# setuptools 80+ 은 pkg_resources 모듈을 제거함. pykrx 가 `import pkg_resources` 를
|
||||
# 하므로 80 미만으로 핀. 아래 reqs.txt 단계에서 다른 deps 가 setuptools 재upgrade 를
|
||||
# 트리거하지 않도록 별도 명령으로 고정.
|
||||
|
||||
# Sanity check: 이 출력은 빌드 로그에 박혀서 다음에 인터프리터 불일치 의심될 때 즉시 확인 가능.
|
||||
RUN python -V && python -m pip -V
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
# Install PyTorch (CUDA 12.1 wheels) first so the rest of deps don't downgrade it.
|
||||
RUN pip install --extra-index-url https://download.pytorch.org/whl/cu121 \
|
||||
RUN python -m pip install --extra-index-url https://download.pytorch.org/whl/cu121 \
|
||||
torch==2.3.1 torchvision==0.18.1
|
||||
RUN pip install --no-deps -e . || true
|
||||
RUN pip install -e .
|
||||
|
||||
# Install runtime deps from pyproject.toml WITHOUT installing the project itself.
|
||||
# - 이전 `pip install -e .` 은 app/ 가 아직 COPY 되기 전이라 packages.find 결과가 비고,
|
||||
# ubuntu 22.04 기본 pip 의 PEP 660 editable hook 과 충돌해 실패했음.
|
||||
# - 런타임에는 PYTHONPATH=/app 으로 `app.*` 임포트가 동작하므로 프로젝트 설치 자체가 불필요.
|
||||
# - deps 만 별도 레이어로 캐시 → 코드 변경 시 ML 휠 재빌드 회피.
|
||||
RUN python -c "import tomllib; \
|
||||
deps = tomllib.load(open('pyproject.toml','rb'))['project']['dependencies']; \
|
||||
open('/tmp/reqs.txt','w').write('\n'.join(deps))" \
|
||||
&& python -m pip install -r /tmp/reqs.txt \
|
||||
&& python -m pip install "setuptools<80" \
|
||||
&& python -c "import pkg_resources; print('pkg_resources OK from', pkg_resources.__file__)"
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
# uvicorn 콘솔 스크립트 대신 `python -m uvicorn` 으로 호출 — 3.11 인터프리터에서 실행됨을 보장.
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
@@ -1,26 +1,221 @@
|
||||
"""차트 데이터 API: OHLCV + 보조 데이터 (감성, 거시).
|
||||
|
||||
UI: /code 페이지 첫 로드 시 호출 → lightweight-charts 캔들 데이터로 사용.
|
||||
UI: /code 페이지가 호출 → lightweight-charts 캔들 데이터로 사용.
|
||||
|
||||
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
|
||||
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
from datetime import date, datetime, time as dtime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
|
||||
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_daily(conn, code: str, start: date, end: date):
|
||||
return conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT date, open, high, low, close, volume
|
||||
FROM ohlcv_daily
|
||||
WHERE code = :c AND date BETWEEN :s AND :e
|
||||
ORDER BY date
|
||||
"""
|
||||
),
|
||||
{"c": code, "s": start, "e": end},
|
||||
).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)
|
||||
@@ -32,17 +227,44 @@ def get_chart(
|
||||
if not symbol:
|
||||
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
||||
|
||||
ohlcv_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT date, open, high, low, close, volume
|
||||
FROM ohlcv_daily
|
||||
WHERE code = :c AND date BETWEEN :s AND :e
|
||||
ORDER BY date
|
||||
"""
|
||||
),
|
||||
{"c": code, "s": start, "e": end},
|
||||
).all()
|
||||
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]),
|
||||
@@ -52,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(
|
||||
@@ -79,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(
|
||||
"""
|
||||
@@ -109,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,
|
||||
|
||||
@@ -4,6 +4,10 @@ POST /api/refresh/{code}
|
||||
body: 없음
|
||||
query: ?lookback_days=7 (기본)
|
||||
resp: refresh_one.RefreshReport.to_dict()
|
||||
|
||||
POST /api/refresh/seed/symbols
|
||||
symbols 테이블 강제 재시드 (SEED 10 + KRX 전 종목). 부팅 시 시드가 실패한
|
||||
경우 컨테이너 재기동 없이 복구하기 위한 admin 엔드포인트.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +15,7 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
from app.fetch.symbols_seed import seed_symbols
|
||||
from app.pipelines.refresh_one import refresh_code
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["refresh"])
|
||||
@@ -33,3 +38,35 @@ def refresh_endpoint(
|
||||
raise HTTPException(status_code=404, detail=f"unknown code: {code} (symbols 테이블에 없음. 시드 필요)")
|
||||
report = refresh_code(code, name, lookback_days=lookback_days)
|
||||
return report.to_dict()
|
||||
|
||||
|
||||
@router.post("/refresh/seed/symbols")
|
||||
def reseed_symbols() -> dict:
|
||||
"""symbols 테이블 강제 재시드.
|
||||
|
||||
호출 예 (Windows cmd):
|
||||
curl -X POST http://localhost:8000/api/refresh/seed/symbols
|
||||
|
||||
KRX 가 주말/장 마감 시간에 비정상 응답을 줄 때도 SEED 10 종목은 항상 보장하므로
|
||||
엔드포인트는 200 을 돌려준다. 부분 성공 정보는 응답 body 에 담아 사용자가 판단.
|
||||
"""
|
||||
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],
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ status='skipped_missing_key' 처리.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -30,6 +33,16 @@ logger = logging.getLogger(__name__)
|
||||
KIS_BASE = "https://openapi.koreainvestment.com:9443"
|
||||
USER_AGENT = "stock_chart_site/0.1 (+personal)"
|
||||
|
||||
# 토큰 디스크 캐시 경로. 기본값은 컨테이너 안 /app/.cache/kis_token.json — docker-compose
|
||||
# 의 `./backend:/app` 바인드 마운트 덕에 호스트 `./backend/.cache/` 에 영속된다.
|
||||
# `backend/.cache/` 는 .gitignore 에 들어있어 secrets 가 커밋되지 않는다.
|
||||
#
|
||||
# 왜 디스크 캐시가 필요한가:
|
||||
# KIS 는 access_token 발급을 1분 1회, 하루 N회로 강하게 제한한다. 메모리만 쓰면
|
||||
# `restart.bat` / `build.bat` / 컨테이너 재기동 때마다 새 발급 → 403 (EGW00133 등) 빈발.
|
||||
# 토큰 자체는 24시간 유효하므로, 컨테이너 인스턴스가 바뀌어도 같은 토큰을 재사용한다.
|
||||
_TOKEN_CACHE_PATH = Path(os.environ.get("KIS_TOKEN_CACHE_PATH", "/app/.cache/kis_token.json"))
|
||||
|
||||
|
||||
class SkippedMissingKey(RuntimeError):
|
||||
"""KIS 키 미설정 시 발생. 호출 측에서 skipped 로 매핑."""
|
||||
@@ -49,6 +62,54 @@ def _has_keys() -> bool:
|
||||
return bool(settings.kis_app_key and settings.kis_app_secret)
|
||||
|
||||
|
||||
def _current_key_prefix() -> str:
|
||||
# app_key 가 바뀌었는데 옛 키로 받은 토큰을 그대로 쓰면 401. 캐시 무효화 키로 사용.
|
||||
return (settings.kis_app_key or "")[:8]
|
||||
|
||||
|
||||
def _load_disk_cache() -> _Token | None:
|
||||
try:
|
||||
with _TOKEN_CACHE_PATH.open() as f:
|
||||
data = json.load(f)
|
||||
if data.get("key_prefix") != _current_key_prefix():
|
||||
# .env 에서 app_key 가 바뀌었을 가능성 → 캐시 폐기
|
||||
return None
|
||||
tok = _Token(value=str(data["value"]), expires_at=float(data["expires_at"]))
|
||||
if tok.expires_at <= time.time():
|
||||
return None
|
||||
return tok
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
logger.warning("kis token disk-cache read failed (%s): %s", _TOKEN_CACHE_PATH, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _save_disk_cache(tok: _Token) -> None:
|
||||
try:
|
||||
_TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _TOKEN_CACHE_PATH.with_suffix(".json.tmp")
|
||||
# atomic write: 부분 쓰기 중 컨테이너가 죽어도 다음 시작 시 깨진 파일 안 읽음
|
||||
with tmp.open("w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"value": tok.value,
|
||||
"expires_at": tok.expires_at,
|
||||
"key_prefix": _current_key_prefix(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
os.replace(tmp, _TOKEN_CACHE_PATH)
|
||||
# 토큰 파일은 키 동등의 secret. 0600 권한.
|
||||
try:
|
||||
os.chmod(_TOKEN_CACHE_PATH, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
# 캐시 쓰기 실패는 치명적이지 않음 — 메모리 캐시로만 동작 가능. 경고만.
|
||||
logger.warning("kis token disk-cache write failed (%s): %s", _TOKEN_CACHE_PATH, exc)
|
||||
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=8),
|
||||
@@ -75,13 +136,29 @@ def _issue_token() -> _Token:
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
"""캐시된 토큰 반환. 만료 60초 전부터 재발급. 키 없으면 SkippedMissingKey."""
|
||||
"""캐시된 토큰 반환. 메모리 → 디스크 → 신규 발급 순. 키 없으면 SkippedMissingKey.
|
||||
|
||||
디스크 캐시는 컨테이너 재기동 시 토큰 재발급 1분 제한 (EGW00133) 회피용.
|
||||
"""
|
||||
global _token_cache
|
||||
with _token_lock:
|
||||
if _token_cache and _token_cache.expires_at > time.time():
|
||||
return _token_cache.value
|
||||
disk = _load_disk_cache()
|
||||
if disk is not None:
|
||||
_token_cache = disk
|
||||
logger.info(
|
||||
"kis token loaded from disk, expires_at=%s",
|
||||
datetime.fromtimestamp(disk.expires_at),
|
||||
)
|
||||
return disk.value
|
||||
_token_cache = _issue_token()
|
||||
logger.info("kis token issued, expires_at=%s", datetime.fromtimestamp(_token_cache.expires_at))
|
||||
_save_disk_cache(_token_cache)
|
||||
logger.info(
|
||||
"kis token issued (and cached to %s), expires_at=%s",
|
||||
_TOKEN_CACHE_PATH,
|
||||
datetime.fromtimestamp(_token_cache.expires_at),
|
||||
)
|
||||
return _token_cache.value
|
||||
|
||||
|
||||
@@ -156,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():
|
||||
|
||||
@@ -41,21 +41,70 @@ def _fetch_market_listing(market: str) -> list[tuple[str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
def _upsert_seed_tickers() -> int:
|
||||
"""SEED 10종목 강제 upsert. 네트워크 불필요 → KRX 실패와 무관하게 항상 성공.
|
||||
|
||||
별도 트랜잭션이라 KRX 시드가 나중에 실패해도 살아남는다.
|
||||
"""
|
||||
engine = get_engine()
|
||||
with engine.begin() as conn:
|
||||
for t in SEED_TICKERS:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO symbols (code, name, market, is_seed)
|
||||
VALUES (:code, :name, :market, TRUE)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
market = EXCLUDED.market,
|
||||
is_seed = TRUE
|
||||
"""
|
||||
),
|
||||
{"code": t.code, "name": t.name, "market": t.market},
|
||||
)
|
||||
return len(SEED_TICKERS)
|
||||
|
||||
|
||||
def seed_symbols() -> SeedReport:
|
||||
"""KOSPI + KOSDAQ 전 종목을 upsert. SEED 10 종목은 is_seed=TRUE."""
|
||||
rows: list[tuple[str, str, str]] = [] # (code, name, market)
|
||||
"""KOSPI + KOSDAQ 전 종목을 upsert. SEED 10 종목은 is_seed=TRUE.
|
||||
|
||||
순서:
|
||||
1) SEED_TICKERS 먼저 별도 트랜잭션으로 강제 upsert (KRX 실패와 무관하게 검색 가능)
|
||||
2) KRX 리스팅 fetch (네트워크 의존) → 별도 트랜잭션으로 일괄 upsert.
|
||||
시장별 fetch 실패 시 해당 시장만 스킵하고 나머지 진행.
|
||||
"""
|
||||
# 1) SEED_TICKERS — 항상 보장
|
||||
try:
|
||||
_upsert_seed_tickers()
|
||||
seed_marked = len(SEED_TICKERS)
|
||||
logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked)
|
||||
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 실패해도 부분 성공 허용
|
||||
market_counts: dict[str, int] = {}
|
||||
all_rows: list[tuple[str, str, str]] = []
|
||||
for market in ("KOSPI", "KOSDAQ"):
|
||||
try:
|
||||
listing = _fetch_market_listing(market)
|
||||
market_counts[market] = len(listing)
|
||||
for code, name in listing:
|
||||
rows.append((code, name, market))
|
||||
all_rows.append((code, name, market))
|
||||
logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing))
|
||||
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
|
||||
|
||||
engine = get_engine()
|
||||
inserted = updated = 0
|
||||
seed_marked = 0
|
||||
if all_rows:
|
||||
engine = get_engine()
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
for code, name, market in rows:
|
||||
for code, name, market in all_rows:
|
||||
is_seed = code in SEED_CODES
|
||||
res = conn.execute(
|
||||
text(
|
||||
@@ -76,21 +125,8 @@ def seed_symbols() -> SeedReport:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
if is_seed:
|
||||
seed_marked += 1
|
||||
|
||||
# SEED_TICKERS 중 KRX 리스팅에 없으면 (상장폐지 등) 그래도 명시적으로 시드 row 보장
|
||||
for t in SEED_TICKERS:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO symbols (code, name, market, is_seed)
|
||||
VALUES (:code, :name, :market, TRUE)
|
||||
ON CONFLICT (code) DO UPDATE SET is_seed = TRUE
|
||||
"""
|
||||
),
|
||||
{"code": t.code, "name": t.name, "market": t.market},
|
||||
)
|
||||
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",
|
||||
|
||||
@@ -48,19 +48,26 @@ def _bootstrap_db() -> None:
|
||||
logger.exception("bootstrap migrate failed")
|
||||
return # 스키마 없으면 시드 불가
|
||||
|
||||
# 2) symbols 시드 (비어있을 때만 — pykrx 호출이 비싸므로 항상 돌리지 않음)
|
||||
# 2) symbols 시드
|
||||
# - SEED 10종목은 매 부팅마다 무조건 upsert (10회 upsert, ms 단위, 네트워크 무관)
|
||||
# → KRX 접근 실패한 환경에서도 최소 10종목 검색 보장
|
||||
# - KRX 전 종목 fetch 는 symbols 가 비어있을 때만 (호출 비용 큼)
|
||||
try:
|
||||
from app.fetch.symbols_seed import _upsert_seed_tickers, seed_symbols
|
||||
n_seed = _upsert_seed_tickers()
|
||||
logger.info("bootstrap seed-tickers ensured (%d)", n_seed)
|
||||
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(text("SELECT COUNT(*) FROM symbols")).first()
|
||||
count = int(row[0]) if row else 0
|
||||
if count == 0:
|
||||
logger.info("symbols empty — running initial seed")
|
||||
from app.fetch.symbols_seed import seed_symbols
|
||||
if count <= n_seed:
|
||||
# symbols 가 SEED 만큼 또는 그 이하 → KRX 전 종목 fetch 시도
|
||||
logger.info("symbols sparse (count=%d) — running KRX listing seed", count)
|
||||
report = seed_symbols()
|
||||
logger.info("bootstrap seed_symbols: %s", report)
|
||||
else:
|
||||
logger.info("symbols already populated (count=%d) — skip seed", count)
|
||||
logger.info("symbols already populated (count=%d) — skip KRX listing seed", count)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("bootstrap seed_symbols failed")
|
||||
|
||||
@@ -125,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)",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
def _do_predict():
|
||||
pipe = _state["pipe"]
|
||||
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
|
||||
with torch.no_grad():
|
||||
samples = pipe.predict(
|
||||
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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -31,7 +31,7 @@ dependencies = [
|
||||
"transformers==4.41.2",
|
||||
"tokenizers==0.19.1",
|
||||
"sentencepiece==0.2.0",
|
||||
"accelerate==0.30.1",
|
||||
"accelerate==0.34.2",
|
||||
"chronos-forecasting==1.4.1",
|
||||
"scikit-learn==1.5.0",
|
||||
"lightgbm==4.3.0",
|
||||
|
||||
@@ -8,5 +8,8 @@ services:
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
MODEL_DEVICE: cuda
|
||||
# MODEL_DEVICE 는 .env 로 덮어쓰기 가능. GPU 빌드라도 PyTorch/CUDA 호환 문제 (예:
|
||||
# 'no kernel image is available for execution on the device') 발생 시 .env 에
|
||||
# MODEL_DEVICE=cpu 를 두고 `docker compose ... up -d backend` 로 회피.
|
||||
MODEL_DEVICE: ${MODEL_DEVICE:-cuda}
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
|
||||
57
restart-ci.bat
Normal file
57
restart-ci.bat
Normal 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
|
||||
94
restart.bat
Normal file
94
restart.bat
Normal file
@@ -0,0 +1,94 @@
|
||||
@echo off
|
||||
REM stock_chart_site - Windows 재시작 스크립트
|
||||
REM
|
||||
REM build.bat 와의 차이:
|
||||
REM - build.bat: 이미지 재빌드 포함. Dockerfile / pyproject.toml / package*.json /
|
||||
REM compose 설정 등 의존성/이미지 구성이 바뀌었을 때 사용.
|
||||
REM - restart.bat: 재빌드 없이 컨테이너만 재시작. backend/app/ 또는 web/app/ 안의
|
||||
REM 코드만 바뀐 경우. docker-compose.yml 의 바인드 마운트 (./backend:/app,
|
||||
REM ./web:/app) 덕에 새 코드가 즉시 컨테이너 안에서 보이고, 재시작으로
|
||||
REM lifespan (부팅 시드 등) 도 다시 돌릴 수 있다.
|
||||
REM
|
||||
REM 즉 일반적으로 git pull 후:
|
||||
REM - pyproject.toml / Dockerfile / package*.json 변경 있음 → build.bat
|
||||
REM - app/ 코드만 변경 → restart.bat
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo === stock_chart_site restart ===
|
||||
|
||||
REM 1) Docker 확인
|
||||
where docker >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] docker 명령을 찾을 수 없습니다. Docker Desktop 설치/실행을 확인하세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
docker info >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Docker Desktop이 실행 중이 아닙니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 2) GPU 감지 (build.bat 과 동일 — compose 파일 조합 일치 위해)
|
||||
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] NVIDIA GPU detected. Using GPU profile.
|
||||
set COMPOSE_FILES=-f docker-compose.yml -f docker-compose.gpu.yml
|
||||
) else (
|
||||
echo [CPU] NVIDIA GPU not detected. Using CPU profile.
|
||||
set COMPOSE_FILES=-f docker-compose.yml
|
||||
)
|
||||
|
||||
REM 3) backend/web 컨테이너 살아있는지 확인 — 없으면 build.bat 안내
|
||||
REM (db 까지 포함해서 세면 db 만 떠있어도 통과돼버려서 부정확)
|
||||
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 [INFO] 실행 중인 backend/web 컨테이너가 없습니다. 처음이거나 down 된 상태입니다.
|
||||
echo build.bat 으로 빌드 + 기동하세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 4) backend + web 만 재기동 — db 는 건드리지 않음 (--no-deps).
|
||||
REM
|
||||
REM 왜 `restart` 가 아니라 `up -d --force-recreate` 인가:
|
||||
REM - `docker compose restart` 는 기존 컨테이너를 stop/start 만 한다. 그래서
|
||||
REM `.env` 변경 (예: KIS_APP_KEY 갱신) 이 반영되지 않는다. env_file 은
|
||||
REM 컨테이너 "생성" 시점에만 읽힌다.
|
||||
REM - `up -d --force-recreate` 는 새 컨테이너 인스턴스를 만들어서 env_file 을
|
||||
REM 다시 읽는다. 이게 사용자가 .env 만 고치고 restart.bat 돌렸을 때 직관에 맞는다.
|
||||
REM - `--no-deps` 로 db 는 절대 건드리지 않음. db 는 postgres_data 볼륨이 영속이라
|
||||
REM 재기동할 이유 없고, depends_on.condition: service_healthy 와 무관하게 안전.
|
||||
echo.
|
||||
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 실패.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === 상태 ===
|
||||
docker compose %COMPOSE_FILES% ps
|
||||
|
||||
echo.
|
||||
echo 접속:
|
||||
echo Web http://localhost:3000
|
||||
echo Backend http://localhost:8000/health
|
||||
echo DB ext http://localhost:8000/health/db
|
||||
echo.
|
||||
echo 로그 보기: docker compose logs -f backend
|
||||
echo 정지: docker compose down
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(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 +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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
// Backend API client.
|
||||
// NEXT_PUBLIC_API_BASE 는 docker-compose 에서 http://localhost:8000 으로 주입됨.
|
||||
//
|
||||
// API 베이스 해석 우선순위:
|
||||
// 1) NEXT_PUBLIC_API_BASE 가 localhost/127.0.0.1 이 아닌 명시값 → 그대로 사용
|
||||
// (예: 프로덕션 https://api.example.com)
|
||||
// 2) 브라우저 환경 → window.location.hostname:8000 (LAN 접속도 자동 대응)
|
||||
// 3) SSR 폴백 → http://localhost:8000
|
||||
//
|
||||
// docker-compose 가 NEXT_PUBLIC_API_BASE=http://localhost:8000 을 주입하는 경우가 흔한데,
|
||||
// LAN 의 다른 PC 에서 http://<host>:3000 으로 접속하면 inline 된 localhost 가 그쪽 PC 의
|
||||
// localhost 를 가리켜 깨진다. 그래서 localhost/127.0.0.1 값은 신뢰하지 않고 페이지 host 로
|
||||
// 폴백.
|
||||
|
||||
const RAW_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
export const API_BASE = RAW_BASE.replace(/\/$/, "");
|
||||
function resolveApiBase(): string {
|
||||
const raw = process.env.NEXT_PUBLIC_API_BASE;
|
||||
const env = raw && raw.length > 0 ? raw.replace(/\/$/, "") : "";
|
||||
const envIsLocal = !env || /\/\/(localhost|127\.0\.0\.1)(?::|$)/.test(env);
|
||||
if (typeof window !== "undefined") {
|
||||
if (envIsLocal) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:8000`;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
// SSR
|
||||
return env || "http://localhost:8000";
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase();
|
||||
|
||||
export type Symbol = {
|
||||
code: string;
|
||||
@@ -19,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;
|
||||
@@ -27,6 +51,8 @@ export type OhlcvPoint = {
|
||||
volume: number | null;
|
||||
};
|
||||
|
||||
export type ChartInterval = "10m" | "1d" | "1w" | "1mo";
|
||||
|
||||
export type SentimentPoint = {
|
||||
date: string;
|
||||
n_articles: number;
|
||||
@@ -45,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[];
|
||||
@@ -155,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) =>
|
||||
|
||||
2
web/next-env.d.ts
vendored
2
web/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
431
web/package-lock.json
generated
431
web/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"lightweight-charts": "4.1.7",
|
||||
"next": "14.2.3",
|
||||
"next": "^14.2.33",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
@@ -19,7 +19,7 @@
|
||||
"@types/react-dom": "18.3.0",
|
||||
"autoprefixer": "10.4.19",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"eslint-config-next": "^14.2.33",
|
||||
"postcss": "8.4.38",
|
||||
"tailwindcss": "3.4.4",
|
||||
"typescript": "5.4.5"
|
||||
@@ -260,23 +260,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz",
|
||||
"integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA=="
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
|
||||
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ=="
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.3.tgz",
|
||||
"integrity": "sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==",
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz",
|
||||
"integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"glob": "10.3.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz",
|
||||
"integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
|
||||
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -289,9 +289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
|
||||
"integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
|
||||
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -304,9 +304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
|
||||
"integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
|
||||
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -319,9 +319,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
|
||||
"integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
|
||||
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -334,9 +334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
|
||||
"integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
|
||||
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -349,9 +349,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
|
||||
"integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
|
||||
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -364,9 +364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
|
||||
"integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
|
||||
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -379,9 +379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
|
||||
"integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
|
||||
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -394,9 +394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
|
||||
"integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
|
||||
"version": "14.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
|
||||
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -538,58 +538,152 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
|
||||
"integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==",
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.2.0",
|
||||
"@typescript-eslint/types": "7.2.0",
|
||||
"@typescript-eslint/typescript-estree": "7.2.0",
|
||||
"@typescript-eslint/visitor-keys": "7.2.0",
|
||||
"debug": "^4.3.4"
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/type-utils": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.56.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz",
|
||||
"integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.2.0",
|
||||
"@typescript-eslint/visitor-keys": "7.2.0"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz",
|
||||
"integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==",
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||
"@typescript-eslint/utils": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -597,72 +691,118 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz",
|
||||
"integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.2.0",
|
||||
"@typescript-eslint/visitor-keys": "7.2.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "9.0.3",
|
||||
"semver": "^7.5.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
"@typescript-eslint/project-service": "8.59.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz",
|
||||
"integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==",
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.2.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
@@ -1101,15 +1241,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/array.prototype.findlast": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
|
||||
@@ -1731,18 +1862,6 @@
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"path-type": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -2040,14 +2159,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.3.tgz",
|
||||
"integrity": "sha512-ZkNztm3Q7hjqvB1rRlOX8P9E/cXRL9ajRcs8jufEtwMfTVYRqnmtnaSu57QqHyBlovMuiB8LEzfLBkh5RYV6Fg==",
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz",
|
||||
"integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "14.2.3",
|
||||
"@next/eslint-plugin-next": "14.2.35",
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
@@ -2776,26 +2896,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
|
||||
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"array-union": "^2.1.0",
|
||||
"dir-glob": "^3.0.1",
|
||||
"fast-glob": "^3.2.9",
|
||||
"ignore": "^5.2.0",
|
||||
"merge2": "^1.4.1",
|
||||
"slash": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -3713,12 +3813,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
|
||||
"integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
|
||||
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.3",
|
||||
"@next/env": "14.2.35",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -3733,15 +3832,15 @@
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.3",
|
||||
"@next/swc-darwin-x64": "14.2.3",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.3",
|
||||
"@next/swc-linux-arm64-musl": "14.2.3",
|
||||
"@next/swc-linux-x64-gnu": "14.2.3",
|
||||
"@next/swc-linux-x64-musl": "14.2.3",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.3",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.3",
|
||||
"@next/swc-win32-x64-msvc": "14.2.3"
|
||||
"@next/swc-darwin-arm64": "14.2.33",
|
||||
"@next/swc-darwin-x64": "14.2.33",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.33",
|
||||
"@next/swc-linux-arm64-musl": "14.2.33",
|
||||
"@next/swc-linux-x64-gnu": "14.2.33",
|
||||
"@next/swc-linux-x64-musl": "14.2.33",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.33",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.33",
|
||||
"@next/swc-win32-x64-msvc": "14.2.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -4098,15 +4197,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4761,15 +4851,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -5281,15 +5362,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||
"integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.2.0"
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"check": "npm run typecheck && npm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.3",
|
||||
"next": "^14.2.33",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"lightweight-charts": "4.1.7"
|
||||
@@ -25,6 +25,6 @@
|
||||
"postcss": "8.4.38",
|
||||
"autoprefixer": "10.4.19",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.3"
|
||||
"eslint-config-next": "^14.2.33"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user