Compare commits
90 Commits
bc016ab76d
...
codex/owne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a09540e9f | ||
|
|
c172f244e3 | ||
|
|
0aebdc9bdb | ||
|
|
2b2d1ea661 | ||
|
|
be1f8870b4 | ||
|
|
fabc21e262 | ||
|
|
9e8c32e70c | ||
|
|
959e9afa80 | ||
|
|
232b94755a | ||
|
|
3b06b95586 | ||
|
|
bd6e1dac6d | ||
|
|
b46f6f1b3a | ||
|
|
70d56b14ec | ||
|
|
d53ff7a745 | ||
|
|
f9027f5365 | ||
|
|
b1323373a5 | ||
|
|
31e7a67a0d | ||
|
|
488fb32acb | ||
|
|
900c56550c | ||
|
|
a6cd6f3a35 | ||
|
|
faa0163089 | ||
|
|
7312d0aadc | ||
|
|
cab2ed13a3 | ||
|
|
d6fc9d8a0b | ||
|
|
318eae67df | ||
|
|
093ac86d1a | ||
|
|
6f71aef451 | ||
|
|
9f215acaaa | ||
|
|
1e0c917f68 | ||
|
|
f18493fad4 | ||
|
|
442cc38c32 | ||
|
|
a4a4a7f98c | ||
|
|
ecf8b9112b | ||
|
|
ef127ae2e8 | ||
|
|
759ea20daa | ||
|
|
89f3a865af | ||
|
|
38db4b8c24 | ||
|
|
52733df235 | ||
|
|
a79e784550 | ||
|
|
59dc8c4e7a | ||
|
|
7cd865c450 | ||
|
|
d83ac137a9 | ||
|
|
fed8ff287e | ||
|
|
1a6d953d17 | ||
|
|
9e43663b23 | ||
|
|
5aeea1a559 | ||
|
|
d5acf79e05 | ||
|
|
0f89b2c042 | ||
|
|
139403c133 | ||
|
|
fd7026ad09 | ||
|
|
01e5eba3c1 | ||
|
|
645e93e7bf | ||
|
|
aa58761176 | ||
|
|
300f7beef0 | ||
|
|
85da979fbe | ||
|
|
0b4795b03f | ||
|
|
9b25bc6970 | ||
|
|
da36195cf3 | ||
|
|
ae4f07d675 | ||
|
|
66a28cc7ca | ||
|
|
21db09f65f | ||
|
|
04358469d1 | ||
|
|
832e73b5ee | ||
|
|
16aeba1a20 | ||
|
|
6ab3679331 | ||
|
|
b16f2b578d | ||
|
|
bf898d78be | ||
|
|
73593adb5c | ||
|
|
323061df02 | ||
|
|
ea885973c7 | ||
|
|
e0edc8f1e3 | ||
|
|
44873ddb39 | ||
|
|
e610599879 | ||
|
|
0a5c634680 | ||
|
|
928c2160f9 | ||
|
|
78388d347e | ||
|
|
659871118f | ||
|
|
bd47198088 | ||
|
|
fa817b31e4 | ||
|
|
96b7afd443 | ||
|
|
89651251a4 | ||
|
|
296bd6dccd | ||
|
|
2c42c1151c | ||
|
|
9c7c02703a | ||
|
|
e08f3b0765 | ||
|
|
eb56025d9c | ||
|
|
6c792305a9 | ||
|
|
5e6ce11491 | ||
|
|
0af556396e | ||
|
|
f84b460e54 |
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
|
||||
|
||||
10
README.md
10
README.md
@@ -155,9 +155,13 @@ stock_chart_site/
|
||||
|
||||
## 동작 모델 메모
|
||||
|
||||
- 예측 트리거: 사용자가 "예상차트 보기" 누른 종목에 대해 즉시 inference. 결과는 `predictions(user_triggered=TRUE)` 로 저장.
|
||||
- 매칭 배치: 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30, 종가 확정 후 16:00 ~ 16:30 KST 사이) `user_triggered=TRUE` 인 예측 중 `target_date == 오늘 거래일`인 행들에 대해 실제 종가/방향과 매칭 → `prediction_outcomes` 적재. 주말/공휴일이면 다음 거래일로 이월.
|
||||
- 주간 02:00 (일요일): 종목/모델별 최근 30일 hit rate 기반으로 앙상블 가중치를 자동 보정. hit rate가 임계 미만이면 LGBM 재학습.
|
||||
- 예측 트리거: 사용자가 "예상차트 보기" 누른 종목에 대해 즉시 inference. 결과는 세 종류 행으로 적재:
|
||||
- `model='ensemble'` (user_triggered=TRUE) — UI 가 표시하는 최종 예측
|
||||
- `model='chronos'` (user_triggered=FALSE, shadow) — Chronos 단독 성능 추적용
|
||||
- `model='lgbm'` (user_triggered=FALSE, shadow) — LGBM 단독 성능 추적용
|
||||
- 매칭 배치: 평일 16:30 KST. `target_date <= today AND outcomes 미존재` 인 모든 행에 대해 `target_date` 이상 `today` 이하 범위의 **최초 거래일 종가**를 actual_close 로 사용 → 주말/공휴일 자동 이월. shadow 행도 함께 매칭됨.
|
||||
- 주간 02:00 (일요일): 시드 10종목 × horizons LGBM 재학습. 최근 30일 prediction_outcomes 의 chronos vs lgbm hit_rate 비교 → `w_chronos = clamp(0.1, hr_c/(hr_c+hr_l), 0.9)` 공식으로 `ensemble_weights` upsert. 모델별 표본이 10 미만이면 기본값(0.6/0.4) 유지.
|
||||
- DB bootstrap: 백엔드 첫 부팅 시 lifespan 에서 idempotent migration + symbols 시드(비어있을 때만 pykrx 전 종목 적재) 자동 수행. `BOOTSTRAP_DISABLED=1` 로 비활성화 가능.
|
||||
|
||||
## 안전/한계
|
||||
|
||||
|
||||
@@ -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,31 +227,58 @@ 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 = [
|
||||
{
|
||||
"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(
|
||||
@@ -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,
|
||||
|
||||
63
backend/app/api/fundamentals.py
Normal file
63
backend/app/api/fundamentals.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""기업 펀더멘털 (시총/PER/PBR/EPS/BPS/배당) API.
|
||||
|
||||
pykrx 한 번 호출 = 종목당 ~1~2초. 인메모리 daily 캐시(get_fundamentals) 가 처리.
|
||||
SkippedMissingKey 같은 거 없음 — pykrx 는 무인증 KRX 스크래핑.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
from app.fetch.fundamentals import get_fundamentals
|
||||
|
||||
router = APIRouter(prefix="/api/fundamentals", tags=["fundamentals"])
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def code_fundamentals(code: str) -> dict:
|
||||
"""code 의 최신 영업일 fundamentals 스냅샷."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
sym = conn.execute(
|
||||
text("SELECT code, name, market FROM symbols WHERE code = :c"),
|
||||
{"c": code},
|
||||
).first()
|
||||
if not sym:
|
||||
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
||||
|
||||
f = get_fundamentals(code)
|
||||
if f is None:
|
||||
return {
|
||||
"code": sym[0],
|
||||
"name": sym[1],
|
||||
"market": sym[2],
|
||||
"status": "no_data",
|
||||
"snapshot_date": None,
|
||||
"market_cap": None,
|
||||
"shares_outstanding": None,
|
||||
"per": None,
|
||||
"pbr": None,
|
||||
"eps": None,
|
||||
"bps": None,
|
||||
"div": None,
|
||||
"dvd_yield": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"code": sym[0],
|
||||
"name": sym[1],
|
||||
"market": sym[2],
|
||||
"status": "ok",
|
||||
"snapshot_date": f.snapshot_date.isoformat(),
|
||||
"market_cap": f.market_cap,
|
||||
"shares_outstanding": f.shares_outstanding,
|
||||
"per": f.per,
|
||||
"pbr": f.pbr,
|
||||
"eps": f.eps,
|
||||
"bps": f.bps,
|
||||
"div": f.div,
|
||||
"dvd_yield": f.dvd_yield,
|
||||
}
|
||||
223
backend/app/api/markets.py
Normal file
223
backend/app/api/markets.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""시장 overview API.
|
||||
|
||||
GET /api/markets/movers
|
||||
?market=KOSPI|KOSDAQ|ALL (기본 ALL)
|
||||
?limit=10 (각 카테고리 별 상위 N)
|
||||
|
||||
응답 (4 카테고리):
|
||||
- gainers : 등락률 상위 (전일종가 대비 상승%)
|
||||
- losers : 등락률 하위 (하락%)
|
||||
- by_volume : 거래량 상위 (당일 volume)
|
||||
- by_trading_value: 거래대금 상위 (close × volume, 원 단위)
|
||||
|
||||
ohlcv_daily 에서 가장 최근 2 거래일을 잡아 비교한다. 데이터가 한 종목당
|
||||
2 일치 이상 없을 때는 그 종목은 자연 누락된다.
|
||||
|
||||
GET /api/markets/related/{code}
|
||||
?limit=8
|
||||
같은 market 에서 등락률이 비슷한 (또는 가장 활발한) 종목 N 개.
|
||||
sector 컬럼은 비어있는 경우가 많아, market 동질성만으로 묶는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
|
||||
router = APIRouter(prefix="/api/markets", tags=["markets"])
|
||||
|
||||
|
||||
def _movers_sql(where_market: str) -> str:
|
||||
"""ohlcv_daily 에서 종목별 최신 2일 비교 + 카테고리 분류.
|
||||
|
||||
market filter 는 symbols 테이블에서 적용. WITH … AS 안에 :market 바인딩이
|
||||
있는 단일 쿼리.
|
||||
"""
|
||||
return f"""
|
||||
WITH last_dates AS (
|
||||
SELECT DISTINCT date
|
||||
FROM ohlcv_daily
|
||||
ORDER BY date DESC
|
||||
LIMIT 2
|
||||
),
|
||||
ordered AS (
|
||||
SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last_dates
|
||||
),
|
||||
symb AS (
|
||||
SELECT code, name, market FROM symbols
|
||||
WHERE active = TRUE {where_market}
|
||||
),
|
||||
latest AS (
|
||||
SELECT o.code, o.close AS close0, o.volume AS volume0
|
||||
FROM ohlcv_daily o, ordered
|
||||
WHERE o.date = ordered.d0
|
||||
),
|
||||
prev AS (
|
||||
SELECT o.code, o.close AS close1
|
||||
FROM ohlcv_daily o, ordered
|
||||
WHERE o.date = ordered.d1
|
||||
)
|
||||
SELECT s.code, s.name, s.market,
|
||||
l.close0 AS close,
|
||||
p.close1 AS prev_close,
|
||||
l.volume0 AS volume,
|
||||
CASE WHEN p.close1 > 0
|
||||
THEN (l.close0 - p.close1) / p.close1 * 100
|
||||
ELSE NULL
|
||||
END AS pct_change,
|
||||
(l.close0 * l.volume0) AS trading_value
|
||||
FROM symb s
|
||||
JOIN latest l ON l.code = s.code
|
||||
JOIN prev p ON p.code = s.code
|
||||
WHERE l.close0 IS NOT NULL AND p.close1 IS NOT NULL
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/movers")
|
||||
def movers(
|
||||
market: str = Query(default="ALL", pattern="^(ALL|KOSPI|KOSDAQ)$"),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
) -> dict:
|
||||
where_market = "" if market == "ALL" else "AND market = :market"
|
||||
params: dict[str, object] = {"lim": limit}
|
||||
if market != "ALL":
|
||||
params["market"] = market
|
||||
|
||||
eng = get_engine()
|
||||
base_sql = _movers_sql(where_market)
|
||||
with eng.connect() as conn:
|
||||
gainers = conn.execute(
|
||||
text(base_sql + " ORDER BY pct_change DESC NULLS LAST LIMIT :lim"),
|
||||
params,
|
||||
).all()
|
||||
losers = conn.execute(
|
||||
text(base_sql + " ORDER BY pct_change ASC NULLS LAST LIMIT :lim"),
|
||||
params,
|
||||
).all()
|
||||
by_volume = conn.execute(
|
||||
text(base_sql + " ORDER BY volume DESC NULLS LAST LIMIT :lim"),
|
||||
params,
|
||||
).all()
|
||||
by_trading_value = conn.execute(
|
||||
text(base_sql + " ORDER BY trading_value DESC NULLS LAST LIMIT :lim"),
|
||||
params,
|
||||
).all()
|
||||
|
||||
def _row(r) -> dict:
|
||||
return {
|
||||
"code": r[0],
|
||||
"name": r[1],
|
||||
"market": r[2],
|
||||
"close": float(r[3]) if r[3] is not None else None,
|
||||
"prev_close": float(r[4]) if r[4] is not None else None,
|
||||
"volume": int(r[5]) if r[5] is not None else None,
|
||||
"pct_change": float(r[6]) if r[6] is not None else None,
|
||||
"trading_value": float(r[7]) if r[7] is not None else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"market": market,
|
||||
"limit": limit,
|
||||
"gainers": [_row(r) for r in gainers],
|
||||
"losers": [_row(r) for r in losers],
|
||||
"by_volume": [_row(r) for r in by_volume],
|
||||
"by_trading_value": [_row(r) for r in by_trading_value],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/indices")
|
||||
def indices(days: int = Query(default=60, ge=2, le=400)) -> dict:
|
||||
"""KOSPI / KOSDAQ 지수 N일 종가 시계열.
|
||||
|
||||
macro_daily 에 yfinance 가 채우는 'kospi','kosdaq' key 를 그대로 읽음.
|
||||
채워져 있지 않으면 series 가 빈 배열로 반환되어 프런트가 '데이터 준비 중' 안내.
|
||||
"""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT date, key, value
|
||||
FROM macro_daily
|
||||
WHERE key IN ('kospi','kosdaq')
|
||||
AND date >= (CURRENT_DATE - (:d || ' day')::interval)
|
||||
ORDER BY date ASC
|
||||
"""
|
||||
)
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(sql, {"d": days}).all()
|
||||
|
||||
series: dict[str, list[dict]] = {"kospi": [], "kosdaq": []}
|
||||
for r in rows:
|
||||
d, k, v = r[0], r[1], r[2]
|
||||
if v is None:
|
||||
continue
|
||||
series.setdefault(k, []).append({"date": str(d), "value": float(v)})
|
||||
|
||||
def _summary(pts: list[dict]) -> dict:
|
||||
if not pts:
|
||||
return {"latest": None, "prev": None, "pct_change": None}
|
||||
latest = pts[-1]["value"]
|
||||
prev = pts[-2]["value"] if len(pts) >= 2 else None
|
||||
pct = ((latest - prev) / prev * 100) if (prev and prev > 0) else None
|
||||
return {"latest": latest, "prev": prev, "pct_change": pct}
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"indices": [
|
||||
{
|
||||
"key": "kospi",
|
||||
"name": "KOSPI",
|
||||
"points": series["kospi"],
|
||||
**_summary(series["kospi"]),
|
||||
},
|
||||
{
|
||||
"key": "kosdaq",
|
||||
"name": "KOSDAQ",
|
||||
"points": series["kosdaq"],
|
||||
**_summary(series["kosdaq"]),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/related/{code}")
|
||||
def related(code: str, limit: int = Query(default=8, ge=1, le=30)) -> dict:
|
||||
"""같은 market 에서 등락률 절대값 기준 가까운 종목 N 개.
|
||||
|
||||
sector 컬럼은 시드 단계에서 NULL 인 경우가 많아 market 만으로 동질성을 잡는다.
|
||||
백엔드가 sector 분류를 채우게 되면 같은 sector 우선 정렬로 강화할 수 있음.
|
||||
"""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
target = conn.execute(
|
||||
text("SELECT market FROM symbols WHERE code = :c"),
|
||||
{"c": code},
|
||||
).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
||||
market = target[0]
|
||||
|
||||
rows = conn.execute(
|
||||
text(
|
||||
_movers_sql("AND market = :market AND code <> :c")
|
||||
+ " ORDER BY volume DESC NULLS LAST LIMIT :lim"
|
||||
),
|
||||
{"market": market, "c": code, "lim": limit},
|
||||
).all()
|
||||
|
||||
return {
|
||||
"code": code,
|
||||
"market": market,
|
||||
"items": [
|
||||
{
|
||||
"code": r[0],
|
||||
"name": r[1],
|
||||
"market": r[2],
|
||||
"close": float(r[3]) if r[3] is not None else None,
|
||||
"prev_close": float(r[4]) if r[4] is not None else None,
|
||||
"volume": int(r[5]) if r[5] is not None else None,
|
||||
"pct_change": float(r[6]) if r[6] is not None else None,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
60
backend/app/api/orderbook.py
Normal file
60
backend/app/api/orderbook.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""호가(orderbook) API — KIS 10단계 호가 스냅샷.
|
||||
|
||||
장중에 호출하면 호가 + 잔량 + 현재가가 한 번의 KIS 호출로 따라온다. 실시간 ws 가
|
||||
아니라 폴링 친화 (프론트가 5~10s 주기로 재호출) 설계 — 표시 전용.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
from app.fetch.kis import SkippedMissingKey, fetch_orderbook
|
||||
|
||||
router = APIRouter(prefix="/api/orderbook", tags=["orderbook"])
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_orderbook(code: str) -> dict:
|
||||
"""code 의 10단계 호가 스냅샷.
|
||||
|
||||
- symbols 에 없는 코드는 404.
|
||||
- KIS 키 미설정이면 status="skipped_missing_key" + 빈 호가 (프론트에서 안내).
|
||||
- KIS 호출 실패는 502.
|
||||
"""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
sym = conn.execute(
|
||||
text("SELECT code, name, market FROM symbols WHERE code = :c"),
|
||||
{"c": code},
|
||||
).first()
|
||||
if not sym:
|
||||
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
||||
|
||||
try:
|
||||
snap = fetch_orderbook(code)
|
||||
except SkippedMissingKey:
|
||||
return {
|
||||
"code": sym[0],
|
||||
"name": sym[1],
|
||||
"market": sym[2],
|
||||
"status": "skipped_missing_key",
|
||||
"ts": None,
|
||||
"current": None,
|
||||
"prev_close_diff": None,
|
||||
"prev_close_pct": None,
|
||||
"asks": [],
|
||||
"bids": [],
|
||||
"ask_total": 0,
|
||||
"bid_total": 0,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"kis orderbook failed: {exc}") from exc
|
||||
|
||||
return {
|
||||
"code": sym[0],
|
||||
"name": sym[1],
|
||||
"market": sym[2],
|
||||
"status": "ok",
|
||||
**snap,
|
||||
}
|
||||
@@ -40,8 +40,11 @@ def predict_endpoint(
|
||||
|
||||
try:
|
||||
hs = tuple(int(x) for x in horizons.split(",") if x.strip())
|
||||
if not hs or any(h < 1 or h > 30 for h in hs):
|
||||
raise ValueError("invalid horizons")
|
||||
# cap 을 252 거래일 (대략 1년) 까지 허용. 30 거래일 너머는 모델 학습 범위 밖이라
|
||||
# 신뢰성이 떨어지지만, 프론트가 1년 프리셋을 요청하기 때문에 게이트만 풀어둔다.
|
||||
# 사용자에게는 신뢰구간 폭으로 불확실성이 자연 전달됨.
|
||||
if not hs or any(h < 1 or h > 252 for h in hs):
|
||||
raise ValueError("invalid horizons (1..252)")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"bad horizons: {exc}")
|
||||
|
||||
|
||||
@@ -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,37 @@ 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,
|
||||
"static_fallback_used": report.static_fallback_used,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
# seed_symbols 내부에서 다 잡지만, 만에 하나 외부로 새는 예외 (logger 포매터
|
||||
# 자체 버그 등) 도 200 으로 흡수해서 SEED 10 만이라도 살리는 게 UX 목표.
|
||||
return {
|
||||
"ok": False,
|
||||
"inserted": 0,
|
||||
"updated": 0,
|
||||
"seed_marked": 0,
|
||||
"markets": {},
|
||||
"static_fallback_used": False,
|
||||
"error": repr(e)[:300],
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ def search_symbols(
|
||||
q: str = Query(..., min_length=1, max_length=40, description="종목명 또는 코드 prefix/부분 일치"),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
seed_only: bool = Query(default=False, description="true 면 학습/배치 대상 10종목만"),
|
||||
with_sparkline: bool = Query(
|
||||
default=False,
|
||||
description="true 면 각 결과에 최근 30 종가 + 전일대비 동봉 (검색 결과 미니차트용)",
|
||||
),
|
||||
) -> dict:
|
||||
"""이름은 trigram + ILIKE, 코드는 prefix 매치.
|
||||
|
||||
@@ -21,6 +25,11 @@ def search_symbols(
|
||||
1) 코드가 정확히 같으면 가장 위
|
||||
2) 이름 prefix 매치
|
||||
3) 이름 부분 매치 (trigram similarity)
|
||||
|
||||
with_sparkline=true 시:
|
||||
- 매치된 코드들에 대해 ohlcv_daily 최근 30 거래일 종가 한 번에 조회
|
||||
- items[*].sparkline (list[float]) + items[*].close + items[*].pct_change 채움
|
||||
- 데이터 없는 종목은 sparkline=[], close=None
|
||||
"""
|
||||
q_norm = q.strip()
|
||||
if not q_norm:
|
||||
@@ -61,19 +70,51 @@ def search_symbols(
|
||||
"lim": limit,
|
||||
},
|
||||
).all()
|
||||
|
||||
codes = [r[0] for r in rows]
|
||||
spark_by_code: dict[str, list[float]] = {c: [] for c in codes}
|
||||
if with_sparkline and codes:
|
||||
# 최근 30 거래일 종가 — 종목별로 ORDER BY date ASC.
|
||||
spark_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT code, date, close FROM (
|
||||
SELECT code, date, close,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY date DESC) AS rn
|
||||
FROM ohlcv_daily
|
||||
WHERE code = ANY(:codes) AND close IS NOT NULL
|
||||
) t
|
||||
WHERE rn <= 30
|
||||
ORDER BY code, date ASC
|
||||
"""
|
||||
),
|
||||
{"codes": codes},
|
||||
).all()
|
||||
for code, _d, close in spark_rows:
|
||||
spark_by_code.setdefault(code, []).append(float(close))
|
||||
|
||||
def _item(r) -> dict:
|
||||
code = r[0]
|
||||
pts = spark_by_code.get(code, []) if with_sparkline else None
|
||||
out: dict[str, object] = {
|
||||
"code": code,
|
||||
"name": r[1],
|
||||
"market": r[2],
|
||||
"sector": r[3],
|
||||
"is_seed": bool(r[4]),
|
||||
}
|
||||
if with_sparkline:
|
||||
out["sparkline"] = pts or []
|
||||
out["close"] = pts[-1] if pts else None
|
||||
out["pct_change"] = (
|
||||
(pts[-1] - pts[-2]) / pts[-2] * 100 if (pts and len(pts) >= 2 and pts[-2]) else None
|
||||
)
|
||||
return out
|
||||
|
||||
return {
|
||||
"q": q_norm,
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"code": r[0],
|
||||
"name": r[1],
|
||||
"market": r[2],
|
||||
"sector": r[3],
|
||||
"is_seed": bool(r[4]),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"items": [_item(r) for r in rows],
|
||||
}
|
||||
|
||||
|
||||
|
||||
217
backend/app/api/themes.py
Normal file
217
backend/app/api/themes.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""테마/카테고리 API.
|
||||
|
||||
토스 '주식 골라보기' 의 테마 그리드와 비슷한 결.
|
||||
정적 매핑(seed/themes.py)을 베이스로, DB 에서 최신 종가/등락만 채워 응답.
|
||||
|
||||
GET /api/themes
|
||||
- 모든 테마 인덱스 (slug/name/description/code_count)
|
||||
|
||||
GET /api/themes/{slug}
|
||||
?limit=20
|
||||
- 해당 테마의 종목 카드 (종목명/시장/종가/등락률). 거래량 기준 정렬.
|
||||
- ohlcv_daily 가 아직 안 채워져 있으면 close/pct_change 가 NULL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
from app.seed.themes import THEMES, theme_by_slug, themes_for_code, themes_index
|
||||
|
||||
router = APIRouter(prefix="/api/themes", tags=["themes"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_themes() -> dict:
|
||||
return {"items": themes_index(), "total": len(THEMES)}
|
||||
|
||||
|
||||
@router.get("/peer/{code}")
|
||||
def peer_compare(
|
||||
code: str,
|
||||
window: int = Query(default=21, ge=2, le=252),
|
||||
limit: int = Query(default=5, ge=1, le=20),
|
||||
) -> dict:
|
||||
"""동일 테마(들) 내 동종목 비교.
|
||||
|
||||
- 대상 종목이 속한 모든 테마의 코드 합집합 (자기 자신 제외) 을 peer 풀로 잡고,
|
||||
각 종목의 최근 거래일 종가 / window 영업일 전 종가로 누적수익률을 구한다.
|
||||
- peer 평균, 대상 종목 값, 상/하위 N 개 반환.
|
||||
- 테마 무소속이면 themes=[] 빈 응답 (404 아님 — UI 가 그냥 패널을 숨기게).
|
||||
"""
|
||||
themes = themes_for_code(code)
|
||||
if not themes:
|
||||
return {
|
||||
"code": code,
|
||||
"window": window,
|
||||
"themes": [],
|
||||
"self_pct": None,
|
||||
"peer_avg_pct": None,
|
||||
"top": [],
|
||||
"bottom": [],
|
||||
}
|
||||
|
||||
# 테마 합집합 코드 풀 (자기 자신 제외)
|
||||
peer_codes: set[str] = set()
|
||||
for t in themes:
|
||||
peer_codes.update(t.codes)
|
||||
peer_codes.discard(code)
|
||||
pool = [code, *sorted(peer_codes)]
|
||||
|
||||
sql = text(
|
||||
"""
|
||||
WITH ranked AS (
|
||||
SELECT code, date, close,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY date DESC) AS rn
|
||||
FROM ohlcv_daily
|
||||
WHERE code = ANY(:codes) AND close IS NOT NULL
|
||||
),
|
||||
latest AS (
|
||||
SELECT code, close AS close_now, date AS date_now FROM ranked WHERE rn = 1
|
||||
),
|
||||
base AS (
|
||||
SELECT code, close AS close_base, date AS date_base FROM ranked WHERE rn = :w
|
||||
)
|
||||
SELECT s.code, s.name, s.market,
|
||||
l.close_now, b.close_base,
|
||||
CASE WHEN b.close_base > 0
|
||||
THEN (l.close_now - b.close_base) / b.close_base * 100
|
||||
ELSE NULL
|
||||
END AS pct
|
||||
FROM symbols s
|
||||
LEFT JOIN latest l ON l.code = s.code
|
||||
LEFT JOIN base b ON b.code = s.code
|
||||
WHERE s.code = ANY(:codes)
|
||||
"""
|
||||
)
|
||||
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(sql, {"codes": pool, "w": window}).all()
|
||||
|
||||
by_code: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
by_code[r[0]] = {
|
||||
"code": r[0],
|
||||
"name": r[1],
|
||||
"market": r[2],
|
||||
"close": float(r[3]) if r[3] is not None else None,
|
||||
"base_close": float(r[4]) if r[4] is not None else None,
|
||||
"pct_change": float(r[5]) if r[5] is not None else None,
|
||||
}
|
||||
|
||||
self_row = by_code.get(code)
|
||||
self_pct = self_row["pct_change"] if self_row else None
|
||||
|
||||
peers = [
|
||||
v for k, v in by_code.items() if k != code and v["pct_change"] is not None
|
||||
]
|
||||
if peers:
|
||||
peer_avg_pct = sum(p["pct_change"] for p in peers) / len(peers)
|
||||
peers_sorted = sorted(peers, key=lambda x: x["pct_change"], reverse=True)
|
||||
top = peers_sorted[:limit]
|
||||
bottom = peers_sorted[-limit:][::-1]
|
||||
else:
|
||||
peer_avg_pct = None
|
||||
top = []
|
||||
bottom = []
|
||||
|
||||
return {
|
||||
"code": code,
|
||||
"name": self_row["name"] if self_row else code,
|
||||
"window": window,
|
||||
"themes": [
|
||||
{"slug": t.slug, "name": t.name, "code_count": len(t.codes)}
|
||||
for t in themes
|
||||
],
|
||||
"self_pct": self_pct,
|
||||
"peer_avg_pct": peer_avg_pct,
|
||||
"peer_count": len(peers),
|
||||
"top": top,
|
||||
"bottom": bottom,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{slug}")
|
||||
def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict:
|
||||
th = theme_by_slug(slug)
|
||||
if th is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown theme: {slug}")
|
||||
|
||||
codes = list(th.codes)[:limit]
|
||||
if not codes:
|
||||
return {
|
||||
"slug": th.slug,
|
||||
"name": th.name,
|
||||
"description": th.description,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
sql = text(
|
||||
"""
|
||||
WITH last2 AS (
|
||||
SELECT DISTINCT date FROM ohlcv_daily ORDER BY date DESC LIMIT 2
|
||||
),
|
||||
bounds AS (SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last2),
|
||||
latest AS (
|
||||
SELECT o.code, o.close AS close0, o.volume AS vol0
|
||||
FROM ohlcv_daily o, bounds WHERE o.date = bounds.d0
|
||||
),
|
||||
prev AS (
|
||||
SELECT o.code, o.close AS close1
|
||||
FROM ohlcv_daily o, bounds WHERE o.date = bounds.d1
|
||||
)
|
||||
SELECT s.code, s.name, s.market,
|
||||
l.close0 AS close,
|
||||
p.close1 AS prev_close,
|
||||
l.vol0 AS volume,
|
||||
CASE WHEN p.close1 > 0 THEN (l.close0 - p.close1) / p.close1 * 100 ELSE NULL END AS pct_change
|
||||
FROM symbols s
|
||||
LEFT JOIN latest l ON l.code = s.code
|
||||
LEFT JOIN prev p ON p.code = s.code
|
||||
WHERE s.code = ANY(:codes)
|
||||
ORDER BY vol0 DESC NULLS LAST, s.name ASC
|
||||
"""
|
||||
)
|
||||
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(sql, {"codes": codes}).all()
|
||||
|
||||
# symbols 테이블에 시드만 있고 codes 일부가 누락된 경우, 그래도 코드/이름 자리는 비워서 카드 표시.
|
||||
by_code = {r[0]: r for r in rows}
|
||||
items: list[dict] = []
|
||||
for c in codes:
|
||||
r = by_code.get(c)
|
||||
if r is None:
|
||||
items.append(
|
||||
{
|
||||
"code": c,
|
||||
"name": c, # symbols 미등재 — 코드로 노출
|
||||
"market": None,
|
||||
"close": None,
|
||||
"prev_close": None,
|
||||
"volume": None,
|
||||
"pct_change": None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
{
|
||||
"code": r[0],
|
||||
"name": r[1],
|
||||
"market": r[2],
|
||||
"close": float(r[3]) if r[3] is not None else None,
|
||||
"prev_close": float(r[4]) if r[4] is not None else None,
|
||||
"volume": int(r[5]) if r[5] is not None else None,
|
||||
"pct_change": float(r[6]) if r[6] is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"slug": th.slug,
|
||||
"name": th.name,
|
||||
"description": th.description,
|
||||
"items": items,
|
||||
}
|
||||
98
backend/app/api/views.py
Normal file
98
backend/app/api/views.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""종목 페이지 익명 조회 카운터.
|
||||
|
||||
토스 같은 social-proof — "오늘 N명이 봤어요" 배지를 위한 가벼운 PG 카운터.
|
||||
|
||||
- POST /api/views/{code} : views(code, today) UPSERT +1, 새 today_views 반환
|
||||
- GET /api/views/{code} : today_views + 최근 7일 시계열 반환
|
||||
|
||||
dedupe 는 클라이언트(localStorage) 에서 — 하루 1회만 POST. 서버는 단순 +1.
|
||||
부하 들어와도 같은 코드는 하루 1 increment 라 row 하나에 hot write 가 누적되지만
|
||||
유즘량 수십~수백 수준이라 lock contention 무시 가능. 진짜 늘면 그때 sharded 카운터로.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
|
||||
router = APIRouter(prefix="/api/views", tags=["views"])
|
||||
|
||||
|
||||
def _kst_today() -> date:
|
||||
# PG NOW() AT TIME ZONE 'Asia/Seoul' 과 같은 의미로 KST 기준 오늘 날짜.
|
||||
# 컨테이너 TZ 가 KST 면 date.today() 가 같지만, UTC 면 안 맞음 → 명시.
|
||||
from datetime import datetime, timezone
|
||||
utc = datetime.now(timezone.utc)
|
||||
# KST = UTC+9
|
||||
return (utc + timedelta(hours=9)).date()
|
||||
|
||||
|
||||
@router.post("/{code}")
|
||||
def record_view(code: str) -> dict:
|
||||
"""code 의 오늘 카운터 +1. UPSERT 후 today_views 반환.
|
||||
|
||||
symbols 테이블 매칭 검사 안 함 — 카운터 자체는 코드 존재 여부와 독립적으로 동작해야 함
|
||||
(오타/리스트 빠진 종목도 추적). 다만 빈 code 만 거부.
|
||||
"""
|
||||
code = code.strip()
|
||||
if not code:
|
||||
return {"ok": False, "today_views": 0}
|
||||
today = _kst_today()
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO symbol_views_daily (code, view_date, views)
|
||||
VALUES (:c, :d, 1)
|
||||
ON CONFLICT (code, view_date)
|
||||
DO UPDATE SET views = symbol_views_daily.views + 1
|
||||
RETURNING views
|
||||
"""
|
||||
),
|
||||
{"c": code, "d": today},
|
||||
).first()
|
||||
today_views = int(row[0]) if row else 0
|
||||
return {"ok": True, "code": code, "today_views": today_views}
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_views(code: str) -> dict:
|
||||
"""code 의 오늘 + 최근 7일 일별 시계열.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": "005930",
|
||||
"today_views": 42,
|
||||
"last_7d_views": 320,
|
||||
"trend": [{"date": "YYYY-MM-DD", "views": N}, ...] # 오래된 → 최신
|
||||
}
|
||||
"""
|
||||
code = code.strip()
|
||||
today = _kst_today()
|
||||
start = today - timedelta(days=6)
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT view_date, views
|
||||
FROM symbol_views_daily
|
||||
WHERE code = :c AND view_date BETWEEN :s AND :e
|
||||
ORDER BY view_date ASC
|
||||
"""
|
||||
),
|
||||
{"c": code, "s": start, "e": today},
|
||||
).all()
|
||||
trend = [{"date": r[0].isoformat(), "views": int(r[1])} for r in rows]
|
||||
today_views = next((t["views"] for t in trend if t["date"] == today.isoformat()), 0)
|
||||
last_7d = sum(t["views"] for t in trend)
|
||||
return {
|
||||
"code": code,
|
||||
"today_views": today_views,
|
||||
"last_7d_views": last_7d,
|
||||
"trend": trend,
|
||||
}
|
||||
19
backend/app/db/migrations/004_symbol_views.sql
Normal file
19
backend/app/db/migrations/004_symbol_views.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Phase 5: 종목 페이지 익명 조회 카운터.
|
||||
-- (code, view_date) PK 로 일 단위 집계. Redis/KV 없이 PG 만으로 충분 — 쓰기 빈도가
|
||||
-- 낮고(페이지 진입 시 클라이언트 측에서 하루 1회로 dedupe), UPSERT 1회 비용은 무시 가능.
|
||||
-- symbols(code) FK 는 일부러 안 걺 — 삭제된/오타 코드 방문에도 카운터가 죽지 않게.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
CREATE TABLE IF NOT EXISTS symbol_views_daily (
|
||||
code TEXT NOT NULL,
|
||||
view_date DATE NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (code, view_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS symbol_views_daily_date_idx
|
||||
ON symbol_views_daily (view_date DESC);
|
||||
|
||||
COMMENT ON TABLE symbol_views_daily IS
|
||||
'Phase 5: 종목 페이지 익명 조회 카운터. 일 단위 UPSERT(+1). 클라이언트가 localStorage 로 dedupe.';
|
||||
122
backend/app/fetch/fundamentals.py
Normal file
122
backend/app/fetch/fundamentals.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""기업 펀더멘털 (시가총액 / PER / PBR / EPS / BPS / 배당수익률) — pykrx 기반.
|
||||
|
||||
KRX 가 일 단위로 산정한 멀티플 + 시총 스냅샷을 가져온다. pykrx 호출은 KRX 페이지 스크래핑
|
||||
이라 종목당 1~2초가 든다. 같은 (code, snapshot_date) 는 인메모리 캐시로 중복 호출을 막는다.
|
||||
|
||||
API 호출이 자주 들어와도 같은 날 같은 종목은 한 번만 KRX 를 때린다. 컨테이너 재기동 시
|
||||
캐시는 비어 다시 채워진다 (디스크 캐시까지는 안 만든다 — KRX 는 인증/쿼터가 없어서
|
||||
재발급 비용이 없음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한국 시장 마지막 영업일 후보 — 토/일이면 금요일로, 공휴일은 KRX 가 빈 응답을 줘서
|
||||
# 한 단계씩 거꾸로 시도 (최대 7일). 7일 모두 비면 fundamentals 데이터 없음.
|
||||
_MAX_BACKOFF_DAYS = 7
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fundamentals:
|
||||
code: str
|
||||
snapshot_date: date
|
||||
market_cap: float | None # 원
|
||||
shares_outstanding: int | None # 상장주식수
|
||||
per: float | None
|
||||
pbr: float | None
|
||||
eps: float | None
|
||||
bps: float | None
|
||||
div: float | None # 배당금 (원)
|
||||
dvd_yield: float | None # 배당수익률 (%)
|
||||
|
||||
|
||||
# (code, today) → Fundamentals. None 도 캐시해서 KRX 없는 종목 반복 호출 방지.
|
||||
_cache: dict[tuple[str, date], Fundamentals | None] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _to_pykrx(d: date) -> str:
|
||||
return d.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _fetch_one(code: str, target: date) -> Fundamentals | None:
|
||||
"""target 일 기준 KRX fundamentals 스냅샷. KRX 가 그 날 빈 응답이면 직전 영업일로 backoff.
|
||||
|
||||
KRX 는 토/일/공휴일에 데이터를 안 주므로 target 부터 최대 _MAX_BACKOFF_DAYS 일 이전까지
|
||||
역순으로 시도. 시총 / 멀티플 두 호출을 같은 target_date 로 정렬.
|
||||
"""
|
||||
try:
|
||||
from pykrx import stock as krx
|
||||
except ImportError:
|
||||
logger.warning("pykrx unavailable")
|
||||
return None
|
||||
|
||||
cur = target
|
||||
for _ in range(_MAX_BACKOFF_DAYS):
|
||||
ds = _to_pykrx(cur)
|
||||
try:
|
||||
cap_df = krx.get_market_cap(ds, ds, code)
|
||||
fund_df = krx.get_market_fundamental(ds, ds, code)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# logger.exception 은 예전에 pykrx/KRX 비정상 응답 traceback 포매팅 중
|
||||
# UnicodeDecodeError 로 API 500 을 만든 적이 있음 → %r 로 안전하게 직렬화.
|
||||
logger.error("pykrx fundamentals failed code=%s date=%s err=%r", code, ds, exc)
|
||||
return None
|
||||
|
||||
cap_ok = cap_df is not None and not cap_df.empty
|
||||
fund_ok = fund_df is not None and not fund_df.empty
|
||||
if cap_ok or fund_ok:
|
||||
# 한쪽이라도 있으면 그 날짜로 채택. 결측은 None.
|
||||
def _val(df: Any, key: str) -> float | None:
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
v = df.iloc[0].get(key)
|
||||
try:
|
||||
if v is None:
|
||||
return None
|
||||
f = float(v)
|
||||
if f != f: # NaN
|
||||
return None
|
||||
return f
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# pykrx fundamental 컬럼: ['BPS', 'PER', 'PBR', 'EPS', 'DIV', 'DPS']
|
||||
# DIV = 배당수익률 (%), DPS = 주당배당금 (원).
|
||||
mc = _val(cap_df, "시가총액")
|
||||
shares_f = _val(cap_df, "상장주식수")
|
||||
shares: int | None = int(shares_f) if shares_f is not None else None
|
||||
return Fundamentals(
|
||||
code=code,
|
||||
snapshot_date=cur,
|
||||
market_cap=mc,
|
||||
shares_outstanding=shares,
|
||||
per=_val(fund_df, "PER"),
|
||||
pbr=_val(fund_df, "PBR"),
|
||||
eps=_val(fund_df, "EPS"),
|
||||
bps=_val(fund_df, "BPS"),
|
||||
div=_val(fund_df, "DPS"),
|
||||
dvd_yield=_val(fund_df, "DIV"),
|
||||
)
|
||||
cur = cur - timedelta(days=1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_fundamentals(code: str, *, today: date | None = None) -> Fundamentals | None:
|
||||
"""code 의 최신 영업일 fundamentals. 같은 (code, today) 는 인메모리 캐시."""
|
||||
t = today or date.today()
|
||||
key = (code, t)
|
||||
with _cache_lock:
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
val = _fetch_one(code, t)
|
||||
with _cache_lock:
|
||||
_cache[key] = val
|
||||
return val
|
||||
@@ -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,219 @@ 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"])
|
||||
|
||||
|
||||
@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_orderbook(code: str) -> dict[str, Any]:
|
||||
"""10단계 호가 + 잔량 + 현재가 스냅샷 (read-only).
|
||||
|
||||
KIS `inquire-asking-price-exp-ccn` (TR FHKST01010200) 호출. 실시간 ws 가 아니라
|
||||
HTTP 요청 시점의 호가 스냅샷이므로 프론트에서 5~10초 폴링이 자연스럽다.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ts": "HH:MM:SS" (KIS 가 준 aspr_acpt_hour),
|
||||
"current": float | None, # stck_prpr
|
||||
"prev_close_diff": float | None,
|
||||
"prev_close_pct": float | None,
|
||||
"asks": [{level, price, qty}] (level 1..10, 1=가장 낮은 매도호가),
|
||||
"bids": [{level, price, qty}] (level 1..10, 1=가장 높은 매수호가),
|
||||
"ask_total": int,
|
||||
"bid_total": int,
|
||||
}
|
||||
"""
|
||||
if not _has_keys():
|
||||
raise SkippedMissingKey("kis app_key/secret missing")
|
||||
url = f"{KIS_BASE}/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": code,
|
||||
}
|
||||
with httpx.Client(timeout=10.0) as cli:
|
||||
resp = cli.get(url, headers=_headers("FHKST01010200"), 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')})")
|
||||
|
||||
o1 = (data.get("output1") or {}) if isinstance(data.get("output1"), dict) else {}
|
||||
o2 = (data.get("output2") or {}) if isinstance(data.get("output2"), dict) else {}
|
||||
|
||||
def _f(d: dict[str, Any], k: str) -> float | None:
|
||||
v = d.get(k)
|
||||
if v in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _i(d: dict[str, Any], k: str) -> int:
|
||||
v = d.get(k)
|
||||
if v in (None, ""):
|
||||
return 0
|
||||
try:
|
||||
return int(float(v))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
asks = []
|
||||
bids = []
|
||||
for lv in range(1, 11):
|
||||
ap = _f(o1, f"askp{lv}")
|
||||
aq = _i(o1, f"askp_rsqn{lv}")
|
||||
bp = _f(o1, f"bidp{lv}")
|
||||
bq = _i(o1, f"bidp_rsqn{lv}")
|
||||
if ap is not None and ap > 0:
|
||||
asks.append({"level": lv, "price": ap, "qty": aq})
|
||||
if bp is not None and bp > 0:
|
||||
bids.append({"level": lv, "price": bp, "qty": bq})
|
||||
|
||||
raw_hour = o1.get("aspr_acpt_hour") or ""
|
||||
if isinstance(raw_hour, str) and len(raw_hour) == 6:
|
||||
ts = f"{raw_hour[0:2]}:{raw_hour[2:4]}:{raw_hour[4:6]}"
|
||||
else:
|
||||
ts = None
|
||||
|
||||
return {
|
||||
"ts": ts,
|
||||
"current": _f(o2, "stck_prpr"),
|
||||
"prev_close_diff": _f(o2, "prdy_vrss"),
|
||||
"prev_close_pct": _f(o2, "prdy_ctrt"),
|
||||
"asks": asks,
|
||||
"bids": bids,
|
||||
"ask_total": _i(o1, "total_askp_rsqn"),
|
||||
"bid_total": _i(o1, "total_bidp_rsqn"),
|
||||
}
|
||||
|
||||
|
||||
def ping() -> dict[str, Any]:
|
||||
"""토큰 발급만 시도해서 키 유효성 확인."""
|
||||
if not _has_keys():
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
from app.seed.krx_static import KRX_STATIC_MAJORS
|
||||
from app.seed.seed_tickers import SEED_CODES, SEED_TICKERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,6 +23,7 @@ class SeedReport:
|
||||
updated: int
|
||||
seed_marked: int
|
||||
markets: dict[str, int]
|
||||
static_fallback_used: bool = False
|
||||
|
||||
|
||||
def _fetch_market_listing(market: str) -> list[tuple[str, str]]:
|
||||
@@ -41,59 +43,116 @@ def _fetch_market_listing(market: str) -> list[tuple[str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
def seed_symbols() -> SeedReport:
|
||||
"""KOSPI + KOSDAQ 전 종목을 upsert. SEED 10 종목은 is_seed=TRUE."""
|
||||
rows: list[tuple[str, str, str]] = [] # (code, name, market)
|
||||
market_counts: dict[str, int] = {}
|
||||
for market in ("KOSPI", "KOSDAQ"):
|
||||
listing = _fetch_market_listing(market)
|
||||
market_counts[market] = len(listing)
|
||||
for code, name in listing:
|
||||
rows.append((code, name, market))
|
||||
def _upsert_seed_tickers() -> int:
|
||||
"""SEED 10종목 강제 upsert. 네트워크 불필요 → KRX 실패와 무관하게 항상 성공.
|
||||
|
||||
별도 트랜잭션이라 KRX 시드가 나중에 실패해도 살아남는다.
|
||||
"""
|
||||
engine = get_engine()
|
||||
inserted = updated = 0
|
||||
seed_marked = 0
|
||||
with engine.begin() as conn:
|
||||
for code, name, market in rows:
|
||||
is_seed = code in SEED_CODES
|
||||
res = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO symbols (code, name, market, is_seed)
|
||||
VALUES (:code, :name, :market, :is_seed)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
market = EXCLUDED.market,
|
||||
is_seed = symbols.is_seed OR EXCLUDED.is_seed
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
"""
|
||||
),
|
||||
{"code": code, "name": name, "market": market, "is_seed": is_seed},
|
||||
)
|
||||
row = res.first()
|
||||
if row and row[0]:
|
||||
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
|
||||
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.
|
||||
|
||||
순서:
|
||||
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:
|
||||
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
|
||||
|
||||
# 2b) KRX 가 0건만 줬으면 정적 스냅샷 (krx_static.KRX_STATIC_MAJORS) 으로 fallback.
|
||||
# KRX 가 LOGOUT/JSONDecode 로 막혀도 주요 종목 검색은 동작하게 만든다.
|
||||
# KRX 가 정상으로 돌아오면 다음 호출에서 all_rows 가 채워져 자동으로 덮어쓰여진다.
|
||||
static_fallback_used = False
|
||||
if not all_rows:
|
||||
logger.warning(
|
||||
"seed_symbols: KRX live fetch empty — fallback to static snapshot (%d majors)",
|
||||
len(KRX_STATIC_MAJORS),
|
||||
)
|
||||
all_rows = list(KRX_STATIC_MAJORS)
|
||||
static_fallback_used = True
|
||||
# market_counts 도 fallback 으로 채워 응답에 반영
|
||||
for _c, _n, m in KRX_STATIC_MAJORS:
|
||||
market_counts[m] = market_counts.get(m, 0) + 1
|
||||
|
||||
inserted = updated = 0
|
||||
if all_rows:
|
||||
engine = get_engine()
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
for code, name, market in all_rows:
|
||||
is_seed = code in SEED_CODES
|
||||
res = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO symbols (code, name, market, is_seed)
|
||||
VALUES (:code, :name, :market, :is_seed)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
market = EXCLUDED.market,
|
||||
is_seed = symbols.is_seed OR EXCLUDED.is_seed
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
"""
|
||||
),
|
||||
{"code": code, "name": name, "market": market, "is_seed": is_seed},
|
||||
)
|
||||
row = res.first()
|
||||
if row and row[0]:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
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",
|
||||
inserted, updated, seed_marked, market_counts,
|
||||
"seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s static_fallback=%s",
|
||||
inserted, updated, seed_marked, market_counts, static_fallback_used,
|
||||
)
|
||||
return SeedReport(
|
||||
inserted=inserted,
|
||||
updated=updated,
|
||||
seed_marked=seed_marked,
|
||||
markets=market_counts,
|
||||
static_fallback_used=static_fallback_used,
|
||||
)
|
||||
return SeedReport(inserted=inserted, updated=updated, seed_marked=seed_marked, markets=market_counts)
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.chart import router as chart_router
|
||||
from app.api.fundamentals import router as fundamentals_router
|
||||
from app.api.markets import router as markets_router
|
||||
from app.api.metrics import router as metrics_router
|
||||
from app.api.news import router as news_router
|
||||
from app.api.orderbook import router as orderbook_router
|
||||
from app.api.predict import router as predict_router
|
||||
from app.api.refresh import router as refresh_router
|
||||
from app.api.symbols import router as symbols_router
|
||||
from app.api.themes import router as themes_router
|
||||
from app.api.views import router as views_router
|
||||
from app.config import settings
|
||||
from app.db.connection import ping as db_ping
|
||||
from app.db.connection import get_engine, ping as db_ping
|
||||
from app.fetch import dart as dart_mod
|
||||
from app.fetch import kis as kis_mod
|
||||
from app.pipelines.scheduler import shutdown_scheduler, start_scheduler
|
||||
@@ -25,13 +32,62 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bootstrap_db() -> None:
|
||||
"""첫 부팅 자동화:
|
||||
1) migrations/*.sql idempotent 적용 (timescale/pgvector 확장 + 스키마)
|
||||
2) symbols 테이블 비어있으면 pykrx 로 전 종목 시드 (SEED 10 마크 포함)
|
||||
|
||||
BOOTSTRAP_DISABLED=1 이면 스킵 (테스트/CI 용). 어떤 단계든 실패해도 서버는
|
||||
뜬다 — /health/db 가 진단을 알려준다.
|
||||
"""
|
||||
if os.environ.get("BOOTSTRAP_DISABLED") == "1":
|
||||
logger.info("bootstrap skipped (BOOTSTRAP_DISABLED=1)")
|
||||
return
|
||||
|
||||
# 1) migrations
|
||||
try:
|
||||
from app.db.migrate import apply_all
|
||||
res = apply_all()
|
||||
logger.info("bootstrap migrate: %s", res)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("bootstrap migrate failed")
|
||||
return # 스키마 없으면 시드 불가
|
||||
|
||||
# 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 <= 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 KRX listing seed", count)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("bootstrap seed_symbols failed")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
_bootstrap_db()
|
||||
# 스케줄러는 옵션. CI/테스트에서 disable 하고 싶으면 SCHEDULER_DISABLED 같은 env 추가 가능.
|
||||
try:
|
||||
start_scheduler()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("scheduler start failed")
|
||||
if os.environ.get("SCHEDULER_DISABLED") == "1":
|
||||
logger.info("scheduler skipped (SCHEDULER_DISABLED=1)")
|
||||
else:
|
||||
try:
|
||||
start_scheduler()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("scheduler start failed")
|
||||
yield
|
||||
shutdown_scheduler()
|
||||
|
||||
@@ -51,6 +107,11 @@ app.include_router(chart_router)
|
||||
app.include_router(predict_router)
|
||||
app.include_router(metrics_router)
|
||||
app.include_router(news_router)
|
||||
app.include_router(markets_router)
|
||||
app.include_router(themes_router)
|
||||
app.include_router(orderbook_router)
|
||||
app.include_router(fundamentals_router)
|
||||
app.include_router(views_router)
|
||||
|
||||
|
||||
def _resolved_device() -> str:
|
||||
@@ -81,3 +142,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
|
||||
|
||||
pipe = _state["pipe"]
|
||||
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
|
||||
with torch.no_grad():
|
||||
samples = pipe.predict(
|
||||
context=context,
|
||||
prediction_length=horizon,
|
||||
num_samples=num_samples,
|
||||
)
|
||||
def _do_predict():
|
||||
pipe = _state["pipe"]
|
||||
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
|
||||
with torch.no_grad():
|
||||
return pipe.predict(
|
||||
context=context,
|
||||
prediction_length=horizon,
|
||||
num_samples=num_samples,
|
||||
)
|
||||
|
||||
try:
|
||||
samples = _do_predict()
|
||||
except RuntimeError as exc:
|
||||
# cuda 빌드/드라이버 미스매치는 inference 시점에야 드러나는 경우가 많음.
|
||||
# 'no kernel image is available' / 'CUDA error' 같은 신호 잡아서 CPU 로 폴백.
|
||||
msg = str(exc)
|
||||
if _state.get("device") == "cuda" and (
|
||||
"no kernel image" in msg
|
||||
or "CUDA error" in msg
|
||||
or "CUBLAS" in msg
|
||||
):
|
||||
_reload_cpu()
|
||||
samples = _do_predict()
|
||||
else:
|
||||
raise
|
||||
# samples: (1, num_samples, prediction_length)
|
||||
arr = samples[0].cpu().float().numpy()
|
||||
q10 = np.quantile(arr, 0.10, axis=0).tolist()
|
||||
|
||||
@@ -19,7 +19,7 @@ Chronos 도 실패하면 LGBM 단독으로 진행.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -53,6 +53,10 @@ class EnsemblePrediction:
|
||||
horizons: list[int]
|
||||
steps: list[EnsembleStep]
|
||||
sources_used: list[str]
|
||||
# shadow 저장용 원본 출력 (predict_one.py 가 ensemble + chronos 단독 + lgbm 단독
|
||||
# 3 종을 predictions 에 적재해서 retrain_weekly 가 모델별 hit_rate 비교 가능하게 함).
|
||||
chronos_raw: ChronosForecast | None = None
|
||||
lgbm_raw: dict[int, LgbmForecast] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _chronos_direction(samples: list[list[float]], base_close: float, horizon: int) -> tuple[float, float, float]:
|
||||
@@ -83,28 +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:
|
||||
@@ -171,4 +188,6 @@ def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePred
|
||||
horizons=list(horizons),
|
||||
steps=steps,
|
||||
sources_used=sources_used,
|
||||
chronos_raw=cf,
|
||||
lgbm_raw=lgbm_raw,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""prediction_outcomes 매칭 배치.
|
||||
|
||||
평일 16:30 KST 에 실행. 다음 거래일 장 종료 후 (KRX 정규장 마감 15:30) 의
|
||||
확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, target_date == 오늘인
|
||||
user_triggered=TRUE 예측을 그 종가와 매칭.
|
||||
확정 종가가 16:00~16:30 사이 pykrx 로 들어온 뒤, 매칭 미해결 예측을 실제
|
||||
종가와 매칭한다.
|
||||
|
||||
cold-start / 휴장일 대비: 인자로 받은 target_date 의 ohlcv_daily 에 종가가
|
||||
없으면 자연스럽게 skip. 다음 거래일 매칭 잡이 다시 시도하면 그 날짜는
|
||||
여전히 매칭되지 않으므로 (매칭 sql 이 target_date 기준), 영원히 매칭 안되는
|
||||
잘못된 calendar date 예측은 cleanup CLI 로 별도 정리 가능 (Phase 7).
|
||||
이월/공휴일 정책:
|
||||
target_date 가 calendar date 라서 비거래일이면 ohlcv_daily 에 행이 없다.
|
||||
그래서 `target_date <= today` 인 미해결 행을 전부 후보로 잡고, 각 행마다
|
||||
`target_date <= ohlcv_daily.date <= today` 범위의 최초 거래일 종가로
|
||||
매칭한다 (=다음 거래일로 자동 이월).
|
||||
|
||||
shadow prediction 도 같은 방식으로 매칭한다 (user_triggered 필터 없음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,7 +32,7 @@ FLAT_BAND = 0.003
|
||||
|
||||
@dataclass
|
||||
class MatchSummary:
|
||||
target_date: str
|
||||
today: str
|
||||
candidates: int
|
||||
matched: int
|
||||
skipped_no_actual: int
|
||||
@@ -44,64 +47,62 @@ def _direction_label(ret: float) -> str:
|
||||
return "flat"
|
||||
|
||||
|
||||
def match_for_date(d: date) -> MatchSummary:
|
||||
"""target_date == d 인 user_triggered=TRUE 예측을 매칭."""
|
||||
def match_up_to(today: date) -> MatchSummary:
|
||||
"""target_date <= today 인 모든 미해결 예측을 매칭.
|
||||
|
||||
각 행마다 ohlcv_daily 에서 target_date 이상, today 이하 범위의 최초
|
||||
거래일 종가를 actual_close 로 사용 — 공휴일/주말 이월 자연 처리.
|
||||
"""
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
# 매칭 대상 예측 + 매칭 안 됐는지 확인.
|
||||
candidate_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT p.id, p.code, p.base_date, p.horizon, p.point_forecast,
|
||||
p.direction, p.model
|
||||
SELECT p.id, p.code, p.base_date, p.target_date, p.horizon,
|
||||
p.point_forecast, p.direction, p.model
|
||||
FROM predictions p
|
||||
LEFT JOIN prediction_outcomes po ON po.prediction_id = p.id
|
||||
WHERE p.target_date = :d
|
||||
AND p.user_triggered = TRUE
|
||||
WHERE p.target_date <= :today
|
||||
AND po.prediction_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"d": d},
|
||||
{"today": today},
|
||||
).all()
|
||||
candidates = len(candidate_rows)
|
||||
if not candidates:
|
||||
return MatchSummary(str(d), 0, 0, 0, 0)
|
||||
|
||||
# 종목별로 actual close 조회 (한번에 batch).
|
||||
codes = list({r[1] for r in candidate_rows})
|
||||
actual_map: dict[tuple[str, date], float] = {}
|
||||
for code in codes:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"
|
||||
),
|
||||
{"c": code, "d": d},
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
actual_map[(code, d)] = float(row[0])
|
||||
|
||||
# base_close (각 예측의 base_date 종가) 도 필요 — direction 판정용.
|
||||
base_close_map: dict[tuple[str, date], float] = {}
|
||||
for pid, code, base_date, *_ in candidate_rows:
|
||||
key = (code, base_date)
|
||||
if key in base_close_map:
|
||||
continue
|
||||
row = conn.execute(
|
||||
text("SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"),
|
||||
{"c": code, "d": base_date},
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
base_close_map[key] = float(row[0])
|
||||
return MatchSummary(str(today), 0, 0, 0, 0)
|
||||
|
||||
matched = 0
|
||||
skipped = 0
|
||||
already = 0
|
||||
for pid, code, base_date, horizon, point_forecast, pred_dir, model in candidate_rows:
|
||||
actual = actual_map.get((code, d))
|
||||
base_close = base_close_map.get((code, base_date))
|
||||
if actual is None or base_close is None:
|
||||
for pid, code, base_date, target_date, horizon, point_forecast, pred_dir, model in candidate_rows:
|
||||
# 첫 거래일 종가 (target_date <= date <= today)
|
||||
actual_row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT date, close FROM ohlcv_daily
|
||||
WHERE code = :c AND date >= :td AND date <= :today
|
||||
ORDER BY date ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"c": code, "td": target_date, "today": today},
|
||||
).first()
|
||||
if not actual_row or actual_row[1] is None:
|
||||
skipped += 1
|
||||
continue
|
||||
actual_date = actual_row[0]
|
||||
actual = float(actual_row[1])
|
||||
|
||||
base_close_row = conn.execute(
|
||||
text("SELECT close FROM ohlcv_daily WHERE code = :c AND date = :d"),
|
||||
{"c": code, "d": base_date},
|
||||
).first()
|
||||
if not base_close_row or base_close_row[0] is None:
|
||||
skipped += 1
|
||||
continue
|
||||
base_close = float(base_close_row[0])
|
||||
|
||||
actual_ret = actual / base_close - 1.0
|
||||
actual_dir = _direction_label(actual_ret)
|
||||
dir_hit = (pred_dir == actual_dir)
|
||||
@@ -122,7 +123,8 @@ def match_for_date(d: date) -> MatchSummary:
|
||||
{
|
||||
"pid": pid,
|
||||
"code": code,
|
||||
"d": d,
|
||||
# 실제 매칭된 거래일 (이월된 경우 target_date 와 다를 수 있음)
|
||||
"d": actual_date,
|
||||
"h": horizon,
|
||||
"m": model,
|
||||
"pc": float(point_forecast) if point_forecast is not None else None,
|
||||
@@ -138,7 +140,7 @@ def match_for_date(d: date) -> MatchSummary:
|
||||
already += 1
|
||||
|
||||
return MatchSummary(
|
||||
target_date=str(d),
|
||||
today=str(today),
|
||||
candidates=candidates,
|
||||
matched=matched,
|
||||
skipped_no_actual=skipped,
|
||||
@@ -146,13 +148,19 @@ def match_for_date(d: date) -> MatchSummary:
|
||||
)
|
||||
|
||||
|
||||
# 하위 호환 alias — 이전 시그니처를 쓰던 호출자 (예: 단일 날짜 매칭 테스트)
|
||||
def match_for_date(d: date) -> MatchSummary:
|
||||
"""legacy: target_date == d 만 매칭하던 동작 → 이제 target_date <= d 전체 처리."""
|
||||
return match_up_to(d)
|
||||
|
||||
|
||||
def match_today() -> dict[str, Any]:
|
||||
"""평일 16:30 KST 호출용. target_date == today (KST) 인 행 매칭."""
|
||||
"""평일 16:30 KST 호출용. target_date <= today (KST) 인 미해결 행 일괄 매칭."""
|
||||
from datetime import datetime, timezone, timedelta as td
|
||||
|
||||
kst = timezone(td(hours=9))
|
||||
today = datetime.now(kst).date()
|
||||
summary = match_for_date(today)
|
||||
summary = match_up_to(today)
|
||||
return {
|
||||
"today": str(today),
|
||||
"summary": summary.__dict__,
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
POST /api/predict/{code} 에서 호출. 사용자가 "예상차트 보기" 누른 시점.
|
||||
- ensemble.predict() 로 horizons (1,3,5) 결과 계산
|
||||
- base_date = 마지막 ohlcv_daily.date, target_date = base_date + horizon 영업일
|
||||
(대충 calendar 일로 +h * 1.4 — KRX 영업일 추정. Phase 4 단순화: base_date + h 영업일은
|
||||
ohlcv 상의 다음 h 거래일이 아닌, "거래일 카운트" 대신 단순 calendar+h 로 저장하고
|
||||
매칭 잡에서 ohlcv_daily 에 그 날짜 행이 있는지로 자연 보정.)
|
||||
(주말만 스킵하는 단순 카운트. 공휴일은 match_outcomes 가 "target_date 이후
|
||||
최초 거래일 종가"로 자동 이월하여 보정.)
|
||||
|
||||
대안 정확도 위해: 매칭 잡은 "예측의 target_date 이 오늘"인 행을 그날 종가와 비교.
|
||||
calendar date 가 비거래일이면 매칭이 안 되니, 매칭 잡은 매일 실행되어 모일 때 처리.
|
||||
세 종류의 행을 함께 저장한다:
|
||||
- model='ensemble' : 사용자에게 보여주는 최종 예측. user_triggered 플래그 따라감.
|
||||
- model='chronos' : Chronos 단독 (shadow). user_triggered=FALSE 로 항상 적재.
|
||||
- model='lgbm' : LGBM 단독 (shadow). user_triggered=FALSE 로 항상 적재.
|
||||
|
||||
shadow 행은 retrain_weekly 가 모델별 hit_rate 를 비교해 ensemble_weights 를
|
||||
자동 보정하는 입력이 된다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,6 +31,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
# ±0.3% flat band — features.FLAT_BAND, match_outcomes.FLAT_BAND 와 동일.
|
||||
FLAT_BAND = 0.003
|
||||
|
||||
|
||||
def _next_trading_target(base_date: date, horizon: int) -> date:
|
||||
"""base_date + horizon 거래일 (주말만 스킵, 공휴일은 무시 — 매칭잡이 자연 보정)."""
|
||||
@@ -49,77 +56,194 @@ def _last_trading_date(code: str) -> date | None:
|
||||
return row[0] if row and row[0] else None
|
||||
|
||||
|
||||
def _direction_label(ret: float) -> str:
|
||||
if ret > FLAT_BAND:
|
||||
return "up"
|
||||
if ret < -FLAT_BAND:
|
||||
return "down"
|
||||
return "flat"
|
||||
|
||||
|
||||
_INSERT_PREDICTION_SQL = text(
|
||||
"""
|
||||
INSERT INTO predictions
|
||||
(code, predicted_at, base_date, target_date, horizon, model,
|
||||
direction, prob_up, prob_flat, prob_down, expected_return,
|
||||
point_forecast, ci_low, ci_high, features_snapshot, user_triggered)
|
||||
VALUES
|
||||
(:code, :predicted_at, :base_date, :target_date, :horizon, :model,
|
||||
:direction, :p_up, :p_fl, :p_dn, :exp_ret,
|
||||
:point, :lo, :hi, CAST(:feats AS JSONB), :ut)
|
||||
ON CONFLICT (code, base_date, target_date, horizon, model)
|
||||
DO UPDATE SET
|
||||
predicted_at = EXCLUDED.predicted_at,
|
||||
direction = EXCLUDED.direction,
|
||||
prob_up = EXCLUDED.prob_up,
|
||||
prob_flat = EXCLUDED.prob_flat,
|
||||
prob_down = EXCLUDED.prob_down,
|
||||
expected_return = EXCLUDED.expected_return,
|
||||
point_forecast = EXCLUDED.point_forecast,
|
||||
ci_low = EXCLUDED.ci_low,
|
||||
ci_high = EXCLUDED.ci_high,
|
||||
features_snapshot = EXCLUDED.features_snapshot,
|
||||
user_triggered = predictions.user_triggered OR EXCLUDED.user_triggered
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _insert_prediction(conn, *, model: str, code: str, predicted_at: datetime,
|
||||
base_date: date, target_date: date, horizon: int,
|
||||
direction: str, p_up: float, p_fl: float, p_dn: float,
|
||||
expected_return: float, point: float | None,
|
||||
lo: float | None, hi: float | None,
|
||||
features_snap: dict, user_triggered: bool) -> int | None:
|
||||
row = conn.execute(
|
||||
_INSERT_PREDICTION_SQL,
|
||||
{
|
||||
"code": code,
|
||||
"predicted_at": predicted_at,
|
||||
"base_date": base_date,
|
||||
"target_date": target_date,
|
||||
"horizon": horizon,
|
||||
"model": model,
|
||||
"direction": direction,
|
||||
"p_up": p_up,
|
||||
"p_fl": p_fl,
|
||||
"p_dn": p_dn,
|
||||
"exp_ret": expected_return,
|
||||
"point": point,
|
||||
"lo": lo,
|
||||
"hi": hi,
|
||||
"feats": json.dumps(features_snap),
|
||||
"ut": user_triggered,
|
||||
},
|
||||
).first()
|
||||
return int(row[0]) if row else None
|
||||
|
||||
|
||||
def predict_and_store(
|
||||
code: str,
|
||||
*,
|
||||
horizons: tuple[int, ...] = (1, 3, 5),
|
||||
user_triggered: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""앙상블 예측 실행 + predictions 테이블 적재. 결과 JSON-serializable dict 반환."""
|
||||
"""앙상블 예측 실행 + predictions 테이블 적재.
|
||||
|
||||
적재 행:
|
||||
- 'ensemble' (user_triggered 인자 반영)
|
||||
- 'chronos' (shadow, user_triggered=FALSE) — Chronos 가 성공했을 때만
|
||||
- 'lgbm' (shadow, user_triggered=FALSE) — LGBM 이 성공한 horizon 만
|
||||
"""
|
||||
base_date = _last_trading_date(code)
|
||||
if base_date is None:
|
||||
raise RuntimeError(f"no ohlcv_daily for {code}; refresh first")
|
||||
|
||||
pred: EnsemblePrediction = ensemble_predict(code, horizons=horizons)
|
||||
now = datetime.now(KST)
|
||||
base_close = pred.base_close
|
||||
|
||||
eng = get_engine()
|
||||
saved_ids: list[int] = []
|
||||
saved_ids: dict[str, list[int]] = {"ensemble": [], "chronos": [], "lgbm": []}
|
||||
with eng.begin() as conn:
|
||||
for step in pred.steps:
|
||||
target_date = _next_trading_target(base_date, step.horizon)
|
||||
|
||||
# --- ensemble row ---
|
||||
features_snap = {
|
||||
"base_close": pred.base_close,
|
||||
"base_close": base_close,
|
||||
"sources_used": pred.sources_used,
|
||||
"direction": step.direction,
|
||||
}
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO predictions
|
||||
(code, predicted_at, base_date, target_date, horizon, model,
|
||||
direction, prob_up, prob_flat, prob_down, expected_return,
|
||||
point_forecast, ci_low, ci_high, features_snapshot, user_triggered)
|
||||
VALUES
|
||||
(:code, :predicted_at, :base_date, :target_date, :horizon, 'ensemble',
|
||||
:direction, :p_up, :p_fl, :p_dn, :exp_ret,
|
||||
:point, :lo, :hi, CAST(:feats AS JSONB), :ut)
|
||||
ON CONFLICT (code, base_date, target_date, horizon, model)
|
||||
DO UPDATE SET
|
||||
predicted_at = EXCLUDED.predicted_at,
|
||||
direction = EXCLUDED.direction,
|
||||
prob_up = EXCLUDED.prob_up,
|
||||
prob_flat = EXCLUDED.prob_flat,
|
||||
prob_down = EXCLUDED.prob_down,
|
||||
expected_return = EXCLUDED.expected_return,
|
||||
point_forecast = EXCLUDED.point_forecast,
|
||||
ci_low = EXCLUDED.ci_low,
|
||||
ci_high = EXCLUDED.ci_high,
|
||||
features_snapshot = EXCLUDED.features_snapshot,
|
||||
user_triggered = predictions.user_triggered OR EXCLUDED.user_triggered
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"code": code,
|
||||
"predicted_at": now,
|
||||
"base_date": base_date,
|
||||
"target_date": target_date,
|
||||
"horizon": step.horizon,
|
||||
"direction": step.direction,
|
||||
"p_up": step.prob_up,
|
||||
"p_fl": step.prob_flat,
|
||||
"p_dn": step.prob_down,
|
||||
"exp_ret": step.expected_return,
|
||||
"point": step.point_close,
|
||||
"lo": step.ci_low,
|
||||
"hi": step.ci_high,
|
||||
"feats": json.dumps(features_snap),
|
||||
"ut": user_triggered,
|
||||
},
|
||||
).first()
|
||||
if row:
|
||||
saved_ids.append(int(row[0]))
|
||||
pid = _insert_prediction(
|
||||
conn,
|
||||
model="ensemble",
|
||||
code=code,
|
||||
predicted_at=now,
|
||||
base_date=base_date,
|
||||
target_date=target_date,
|
||||
horizon=step.horizon,
|
||||
direction=step.direction,
|
||||
p_up=step.prob_up,
|
||||
p_fl=step.prob_flat,
|
||||
p_dn=step.prob_down,
|
||||
expected_return=step.expected_return,
|
||||
point=step.point_close,
|
||||
lo=step.ci_low,
|
||||
hi=step.ci_high,
|
||||
features_snap=features_snap,
|
||||
user_triggered=user_triggered,
|
||||
)
|
||||
if pid is not None:
|
||||
saved_ids["ensemble"].append(pid)
|
||||
|
||||
# --- chronos shadow row ---
|
||||
cf = pred.chronos_raw
|
||||
if cf is not None:
|
||||
c_med = float(cf.median[step.horizon - 1])
|
||||
c_q10 = float(cf.q10[step.horizon - 1])
|
||||
c_q90 = float(cf.q90[step.horizon - 1])
|
||||
# direction prob: chronos sample 분포
|
||||
try:
|
||||
import numpy as np
|
||||
arr = np.array(cf.samples)[:, step.horizon - 1]
|
||||
ret = arr / base_close - 1.0
|
||||
cp_up = float((ret > FLAT_BAND).mean())
|
||||
cp_dn = float((ret < -FLAT_BAND).mean())
|
||||
cp_fl = max(0.0, 1.0 - cp_up - cp_dn)
|
||||
except Exception: # noqa: BLE001
|
||||
cp_up = cp_fl = cp_dn = 1.0 / 3.0
|
||||
exp_ret_c = c_med / base_close - 1.0
|
||||
c_dir = _direction_label(exp_ret_c)
|
||||
pid_c = _insert_prediction(
|
||||
conn,
|
||||
model="chronos",
|
||||
code=code,
|
||||
predicted_at=now,
|
||||
base_date=base_date,
|
||||
target_date=target_date,
|
||||
horizon=step.horizon,
|
||||
direction=c_dir,
|
||||
p_up=cp_up,
|
||||
p_fl=cp_fl,
|
||||
p_dn=cp_dn,
|
||||
expected_return=exp_ret_c,
|
||||
point=c_med,
|
||||
lo=c_q10,
|
||||
hi=c_q90,
|
||||
features_snap={"shadow": True, "base_close": base_close},
|
||||
user_triggered=False,
|
||||
)
|
||||
if pid_c is not None:
|
||||
saved_ids["chronos"].append(pid_c)
|
||||
|
||||
# --- lgbm shadow row ---
|
||||
lf = pred.lgbm_raw.get(step.horizon)
|
||||
if lf is not None:
|
||||
l_close = float(lf.predicted_close)
|
||||
exp_ret_l = l_close / base_close - 1.0
|
||||
l_dir = _direction_label(exp_ret_l)
|
||||
pid_l = _insert_prediction(
|
||||
conn,
|
||||
model="lgbm",
|
||||
code=code,
|
||||
predicted_at=now,
|
||||
base_date=base_date,
|
||||
target_date=target_date,
|
||||
horizon=step.horizon,
|
||||
direction=l_dir,
|
||||
p_up=float(lf.prob_up),
|
||||
p_fl=float(lf.prob_flat),
|
||||
p_dn=float(lf.prob_down),
|
||||
expected_return=exp_ret_l,
|
||||
point=l_close,
|
||||
lo=l_close * 0.97,
|
||||
hi=l_close * 1.03,
|
||||
features_snap={"shadow": True, "base_close": base_close},
|
||||
user_triggered=False,
|
||||
)
|
||||
if pid_l is not None:
|
||||
saved_ids["lgbm"].append(pid_l)
|
||||
|
||||
return {
|
||||
"code": code,
|
||||
@@ -133,6 +257,11 @@ def predict_and_store(
|
||||
}
|
||||
for s in pred.steps
|
||||
],
|
||||
"saved_prediction_ids": saved_ids,
|
||||
# UI 는 ensemble id 만 본다. shadow 는 디버깅/검증용으로 별도 키.
|
||||
"saved_prediction_ids": saved_ids["ensemble"],
|
||||
"saved_shadow_ids": {
|
||||
"chronos": saved_ids["chronos"],
|
||||
"lgbm": saved_ids["lgbm"],
|
||||
},
|
||||
"user_triggered": user_triggered,
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
일요일 02:00 KST 실행:
|
||||
1. 시드 10종목 × horizon (1,3,5) 별로 LGBM 학습 (train_one).
|
||||
2. 최근 30일 prediction_outcomes 의 model 별 hit_rate 산출, model_performance 적재.
|
||||
3. 같은 30일 윈도우에서 chronos vs lgbm hit_rate 로 ensemble_weights 갱신.
|
||||
(방법: w_chronos = clamp(0.1, hr_c / (hr_c + hr_l), 0.9), w_lgbm = 1 - w_chronos.
|
||||
hit_rate 데이터가 부족하면 default 0.6/0.4 유지.)
|
||||
2. 최근 30일 prediction_outcomes 의 (code, model, horizon) 별 hit_rate / mae
|
||||
산출, model_performance 적재.
|
||||
3. shadow 행 (model='chronos' / 'lgbm') 의 hit_rate 를 비교해서
|
||||
ensemble_weights 자동 보정.
|
||||
|
||||
지금은 'ensemble' 모델 단일 종류로 predictions 가 쌓이므로, 가중치 보정은
|
||||
chronos 단독 시뮬레이션 / lgbm 단독 시뮬레이션 hit_rate 비교가 진정한 방식인데,
|
||||
Phase 4 단순화: 'ensemble' 의 종합 hit_rate 만 model_performance 에 기록하고
|
||||
가중치는 default 유지. 진짜 비교는 Phase 7 (chronos 단독 + lgbm 단독 예측을
|
||||
shadow 로 같이 적재하는 구조) 로 확장.
|
||||
가중치 공식:
|
||||
w_c = clamp(0.1, hr_c / (hr_c + hr_l), 0.9)
|
||||
w_l = 1 - w_c
|
||||
단 sample_count_c < MIN_SAMPLE 또는 sample_count_l < MIN_SAMPLE 이면
|
||||
기본값 유지 (DB row 미생성). hr_c + hr_l == 0 (둘 다 0%) 이면 50:50.
|
||||
|
||||
predict_one 이 매 호출마다 chronos/lgbm shadow 행을 함께 적재하고
|
||||
match_outcomes 가 user_triggered 무관하게 매칭하므로, hit_rate 데이터는
|
||||
사용자가 예측을 한 번이라도 본 종목에 대해 자연스럽게 쌓인다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,12 +28,16 @@ from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
from app.models.lgbm import train_one
|
||||
from app.models.weights import upsert_weights
|
||||
from app.seed.seed_tickers import SEED_TICKERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HORIZONS = (1, 3, 5)
|
||||
WINDOW_DAYS = 30
|
||||
MIN_SAMPLE = 10 # 모델당 최소 매칭 표본
|
||||
W_CHRONOS_MIN = 0.1
|
||||
W_CHRONOS_MAX = 0.9
|
||||
|
||||
|
||||
def retrain_all() -> list[dict[str, Any]]:
|
||||
@@ -103,6 +111,82 @@ def record_performance(as_of: date) -> list[dict[str, Any]]:
|
||||
return summary
|
||||
|
||||
|
||||
def adjust_weights(as_of: date) -> list[dict[str, Any]]:
|
||||
"""shadow chronos/lgbm hit_rate 로 ensemble_weights 자동 보정.
|
||||
|
||||
반환: (code, horizon, w_chronos, w_lgbm, hr_c, hr_l, n_c, n_l, action) 의
|
||||
리스트. action ∈ {'updated', 'skipped_insufficient', 'skipped_zero'}.
|
||||
"""
|
||||
eng = get_engine()
|
||||
start = as_of - timedelta(days=WINDOW_DAYS)
|
||||
out: list[dict[str, Any]] = []
|
||||
with eng.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT code, horizon, model,
|
||||
AVG(CASE WHEN direction_hit THEN 1.0 ELSE 0.0 END) AS hit_rate,
|
||||
COUNT(*) AS n
|
||||
FROM prediction_outcomes
|
||||
WHERE resolved_at >= :start
|
||||
AND model IN ('chronos', 'lgbm')
|
||||
GROUP BY code, horizon, model
|
||||
"""
|
||||
),
|
||||
{"start": start},
|
||||
).all()
|
||||
|
||||
# (code, horizon) -> {'chronos': (hr, n), 'lgbm': (hr, n)}
|
||||
agg: dict[tuple[str, int], dict[str, tuple[float, int]]] = {}
|
||||
for code, horizon, model, hr, n in rows:
|
||||
key = (code, int(horizon))
|
||||
agg.setdefault(key, {})[str(model)] = (
|
||||
float(hr) if hr is not None else 0.0,
|
||||
int(n),
|
||||
)
|
||||
|
||||
for (code, horizon), m in agg.items():
|
||||
c = m.get("chronos")
|
||||
l = m.get("lgbm")
|
||||
if c is None or l is None:
|
||||
out.append({
|
||||
"code": code, "horizon": horizon,
|
||||
"action": "skipped_missing_model",
|
||||
"have": list(m.keys()),
|
||||
})
|
||||
continue
|
||||
hr_c, n_c = c
|
||||
hr_l, n_l = l
|
||||
if n_c < MIN_SAMPLE or n_l < MIN_SAMPLE:
|
||||
out.append({
|
||||
"code": code, "horizon": horizon,
|
||||
"hr_chronos": hr_c, "hr_lgbm": hr_l,
|
||||
"n_chronos": n_c, "n_lgbm": n_l,
|
||||
"action": "skipped_insufficient",
|
||||
})
|
||||
continue
|
||||
total = hr_c + hr_l
|
||||
if total <= 0:
|
||||
w_c = 0.5
|
||||
else:
|
||||
w_c = hr_c / total
|
||||
w_c = max(W_CHRONOS_MIN, min(W_CHRONOS_MAX, w_c))
|
||||
w_l = 1.0 - w_c
|
||||
upsert_weights(
|
||||
code, horizon, w_c, w_l,
|
||||
hit_rate_chronos=hr_c, hit_rate_lgbm=hr_l,
|
||||
sample_count=min(n_c, n_l),
|
||||
)
|
||||
out.append({
|
||||
"code": code, "horizon": horizon,
|
||||
"hr_chronos": hr_c, "hr_lgbm": hr_l,
|
||||
"n_chronos": n_c, "n_lgbm": n_l,
|
||||
"w_chronos": w_c, "w_lgbm": w_l,
|
||||
"action": "updated",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def run_weekly() -> dict[str, Any]:
|
||||
"""일요일 02:00 KST 호출 entry-point."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -113,6 +197,7 @@ def run_weekly() -> dict[str, Any]:
|
||||
"as_of": str(as_of),
|
||||
"trained": retrain_all(),
|
||||
"performance": record_performance(as_of),
|
||||
"weights": adjust_weights(as_of),
|
||||
}
|
||||
|
||||
|
||||
|
||||
178
backend/app/seed/krx_static.py
Normal file
178
backend/app/seed/krx_static.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""KRX 주요 종목 정적 스냅샷.
|
||||
|
||||
용도: pykrx → KRX 실시간 호출이 LOGOUT/JSONDecodeError 로 실패할 때
|
||||
symbols 테이블 검색 universe 를 최소한 보장하기 위한 fallback.
|
||||
|
||||
수록 범위:
|
||||
- KOSPI/KOSDAQ 시가총액 상위 + 일반 사용자가 검색할 법한 친숙한 이름 위주.
|
||||
- 종목코드(KRX 공시)와 종목명(현재 등록명)은 공개된 사실 정보.
|
||||
- 전 종목은 KRX 실시간이 복구되면 seed_symbols() 가 자동 덮어쓴다.
|
||||
|
||||
정확성 주의:
|
||||
- 회사 합병/분할/사명변경/상장폐지가 잦아 일부 항목은 시간 지나면 stale.
|
||||
- KRX 호출이 정상화되면 자동 upsert 로 덮어쓰므로 stale 항목은 자연 보정.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# (code, name, market)
|
||||
KRX_STATIC_MAJORS: list[tuple[str, str, str]] = [
|
||||
# ---------------- KOSPI (시총 상위 + 익숙한 종목) ----------------
|
||||
("005930", "삼성전자", "KOSPI"),
|
||||
("005935", "삼성전자우", "KOSPI"),
|
||||
("000660", "SK하이닉스", "KOSPI"),
|
||||
("373220", "LG에너지솔루션", "KOSPI"),
|
||||
("207940", "삼성바이오로직스", "KOSPI"),
|
||||
("005380", "현대차", "KOSPI"),
|
||||
("000270", "기아", "KOSPI"),
|
||||
("035420", "NAVER", "KOSPI"),
|
||||
("068270", "셀트리온", "KOSPI"),
|
||||
("005490", "POSCO홀딩스", "KOSPI"),
|
||||
("105560", "KB금융", "KOSPI"),
|
||||
("035720", "카카오", "KOSPI"),
|
||||
("028260", "삼성물산", "KOSPI"),
|
||||
("055550", "신한지주", "KOSPI"),
|
||||
("012330", "현대모비스", "KOSPI"),
|
||||
("086790", "하나금융지주", "KOSPI"),
|
||||
("051910", "LG화학", "KOSPI"),
|
||||
("006400", "삼성SDI", "KOSPI"),
|
||||
("003550", "LG", "KOSPI"),
|
||||
("015760", "한국전력공사", "KOSPI"),
|
||||
("032830", "삼성생명", "KOSPI"),
|
||||
("003670", "포스코퓨처엠", "KOSPI"),
|
||||
("017670", "SK텔레콤", "KOSPI"),
|
||||
("009150", "삼성전기", "KOSPI"),
|
||||
("011200", "HMM", "KOSPI"),
|
||||
("138040", "메리츠금융지주", "KOSPI"),
|
||||
("316140", "우리금융지주", "KOSPI"),
|
||||
("018260", "삼성에스디에스", "KOSPI"),
|
||||
("010130", "고려아연", "KOSPI"),
|
||||
("034730", "SK", "KOSPI"),
|
||||
("030200", "KT", "KOSPI"),
|
||||
("066570", "LG전자", "KOSPI"),
|
||||
("011070", "LG이노텍", "KOSPI"),
|
||||
("010950", "S-Oil", "KOSPI"),
|
||||
("047810", "한국항공우주", "KOSPI"),
|
||||
("024110", "기업은행", "KOSPI"),
|
||||
("138930", "BNK금융지주", "KOSPI"),
|
||||
("010140", "삼성중공업", "KOSPI"),
|
||||
("251270", "넷마블", "KOSPI"),
|
||||
("028050", "삼성E&A", "KOSPI"),
|
||||
("042660", "한화오션", "KOSPI"),
|
||||
("086280", "현대글로비스", "KOSPI"),
|
||||
("064350", "현대로템", "KOSPI"),
|
||||
("042700", "한미반도체", "KOSPI"),
|
||||
("267260", "HD현대일렉트릭", "KOSPI"),
|
||||
("329180", "HD현대중공업", "KOSPI"),
|
||||
("010620", "현대미포조선", "KOSPI"),
|
||||
("161390", "한국타이어앤테크놀로지", "KOSPI"),
|
||||
("097950", "CJ제일제당", "KOSPI"),
|
||||
("271560", "오리온", "KOSPI"),
|
||||
("175330", "JB금융지주", "KOSPI"),
|
||||
("139480", "이마트", "KOSPI"),
|
||||
("023530", "롯데쇼핑", "KOSPI"),
|
||||
("088350", "한화생명", "KOSPI"),
|
||||
("088980", "맥쿼리인프라", "KOSPI"),
|
||||
("004020", "현대제철", "KOSPI"),
|
||||
("047050", "포스코인터내셔널", "KOSPI"),
|
||||
("069960", "현대백화점", "KOSPI"),
|
||||
("003490", "대한항공", "KOSPI"),
|
||||
("035250", "강원랜드", "KOSPI"),
|
||||
("071050", "한국금융지주", "KOSPI"),
|
||||
("000810", "삼성화재", "KOSPI"),
|
||||
("011170", "롯데케미칼", "KOSPI"),
|
||||
("000720", "현대건설", "KOSPI"),
|
||||
("000150", "두산", "KOSPI"),
|
||||
("034020", "두산에너빌리티", "KOSPI"),
|
||||
("282330", "BGF리테일", "KOSPI"),
|
||||
("007070", "GS리테일", "KOSPI"),
|
||||
("078930", "GS", "KOSPI"),
|
||||
("011780", "금호석유", "KOSPI"),
|
||||
("002790", "아모레G", "KOSPI"),
|
||||
("090430", "아모레퍼시픽", "KOSPI"),
|
||||
("001040", "CJ", "KOSPI"),
|
||||
("005180", "빙그레", "KOSPI"),
|
||||
("049770", "동원F&B", "KOSPI"),
|
||||
("005300", "롯데칠성", "KOSPI"),
|
||||
("000080", "하이트진로", "KOSPI"),
|
||||
("004990", "롯데지주", "KOSPI"),
|
||||
("000100", "유한양행", "KOSPI"),
|
||||
("009540", "HD한국조선해양", "KOSPI"),
|
||||
("010060", "OCI홀딩스", "KOSPI"),
|
||||
("011790", "SKC", "KOSPI"),
|
||||
("004170", "신세계", "KOSPI"),
|
||||
("008770", "호텔신라", "KOSPI"),
|
||||
("002380", "KCC", "KOSPI"),
|
||||
("014820", "동원시스템즈", "KOSPI"),
|
||||
("005830", "DB손해보험", "KOSPI"),
|
||||
("089860", "롯데렌탈", "KOSPI"),
|
||||
("009830", "한화솔루션", "KOSPI"),
|
||||
("006650", "대한유화", "KOSPI"),
|
||||
("000240", "한국앤컴퍼니", "KOSPI"),
|
||||
("026960", "동서", "KOSPI"),
|
||||
("001450", "현대해상", "KOSPI"),
|
||||
("003410", "쌍용C&E", "KOSPI"),
|
||||
("003090", "대웅", "KOSPI"),
|
||||
("069620", "대웅제약", "KOSPI"),
|
||||
("128940", "한미약품", "KOSPI"),
|
||||
("004000", "롯데정밀화학", "KOSPI"),
|
||||
("000120", "CJ대한통운", "KOSPI"),
|
||||
("005880", "대한해운", "KOSPI"),
|
||||
("001230", "동국홀딩스", "KOSPI"),
|
||||
("000990", "DB하이텍", "KOSPI"),
|
||||
("036570", "엔씨소프트", "KOSPI"),
|
||||
("267250", "HD현대", "KOSPI"),
|
||||
|
||||
# ---------------- KOSDAQ (시총/거래대금/이름 인지도) ----------------
|
||||
("247540", "에코프로비엠", "KOSDAQ"),
|
||||
("086520", "에코프로", "KOSDAQ"),
|
||||
("035760", "CJ ENM", "KOSDAQ"),
|
||||
("058470", "리노공업", "KOSDAQ"),
|
||||
("196170", "알테오젠", "KOSDAQ"),
|
||||
("091990", "셀트리온헬스케어", "KOSDAQ"),
|
||||
("028300", "HLB", "KOSDAQ"),
|
||||
("357780", "솔브레인", "KOSDAQ"),
|
||||
("263750", "펄어비스", "KOSDAQ"),
|
||||
("293490", "카카오게임즈", "KOSDAQ"),
|
||||
("145020", "휴젤", "KOSDAQ"),
|
||||
("277810", "레인보우로보틱스", "KOSDAQ"),
|
||||
("240810", "원익IPS", "KOSDAQ"),
|
||||
("357180", "LX세미콘", "KOSDAQ"),
|
||||
("376300", "디어유", "KOSDAQ"),
|
||||
("095610", "테스", "KOSDAQ"),
|
||||
("067160", "아프리카TV", "KOSDAQ"),
|
||||
("067310", "하나마이크론", "KOSDAQ"),
|
||||
("388790", "케이엠더블유", "KOSDAQ"),
|
||||
("089030", "테크윙", "KOSDAQ"),
|
||||
("086900", "메디톡스", "KOSDAQ"),
|
||||
("035900", "JYP Ent.", "KOSDAQ"),
|
||||
("041510", "에스엠", "KOSDAQ"),
|
||||
("122870", "와이지엔터테인먼트", "KOSDAQ"),
|
||||
("352820", "하이브", "KOSDAQ"),
|
||||
("039030", "이오테크닉스", "KOSDAQ"),
|
||||
("066970", "엘앤에프", "KOSDAQ"),
|
||||
("112040", "위메이드", "KOSDAQ"),
|
||||
("194700", "네오위즈", "KOSDAQ"),
|
||||
("095700", "제넥신", "KOSDAQ"),
|
||||
("214150", "클래시스", "KOSDAQ"),
|
||||
("121600", "나노신소재", "KOSDAQ"),
|
||||
("064550", "바이오니아", "KOSDAQ"),
|
||||
("304100", "솔트룩스", "KOSDAQ"),
|
||||
("078340", "컴투스", "KOSDAQ"),
|
||||
("226330", "신테카바이오", "KOSDAQ"),
|
||||
("215000", "골프존", "KOSDAQ"),
|
||||
("950140", "잉글우드랩", "KOSDAQ"),
|
||||
("328130", "루닛", "KOSDAQ"),
|
||||
("000250", "삼천당제약", "KOSDAQ"),
|
||||
("237690", "에스티팜", "KOSDAQ"),
|
||||
("196300", "쇼박스", "KOSDAQ"),
|
||||
("215600", "신라젠", "KOSDAQ"),
|
||||
("357580", "엠로", "KOSDAQ"),
|
||||
("403870", "HPSP", "KOSDAQ"),
|
||||
("078160", "메디포스트", "KOSDAQ"),
|
||||
("141080", "리가켐바이오", "KOSDAQ"),
|
||||
("306200", "세아메카닉스", "KOSDAQ"),
|
||||
("213420", "덕산네오룩스", "KOSDAQ"),
|
||||
("085660", "차바이오텍", "KOSDAQ"),
|
||||
("950130", "엑세스바이오", "KOSDAQ"),
|
||||
("950170", "JTC", "KOSDAQ"),
|
||||
]
|
||||
236
backend/app/seed/themes.py
Normal file
236
backend/app/seed/themes.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""테마/카테고리 → 종목코드 정적 매핑.
|
||||
|
||||
토스 '주식 골라보기' 의 테마 분류와 비슷한 결을 흉내. 종목/테마 페어링은 공개
|
||||
정보 기준이며, 시장 사정에 따라 합병/분할/사명변경 등으로 stale 될 수 있다.
|
||||
KRX 정상화 + sector 컬럼 채우기가 끝나면 DB-driven 분류로 전환 가능.
|
||||
|
||||
각 테마는 KRX_STATIC_MAJORS 안의 코드 위주 — symbols 테이블에 무조건 존재해
|
||||
검색/카드 표시가 깨지지 않음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
codes: tuple[str, ...]
|
||||
|
||||
|
||||
THEMES: tuple[Theme, ...] = (
|
||||
Theme(
|
||||
slug="semiconductor",
|
||||
name="반도체",
|
||||
description="메모리·파운드리·장비 핵심.",
|
||||
codes=(
|
||||
"005930", # 삼성전자
|
||||
"000660", # SK하이닉스
|
||||
"042700", # 한미반도체
|
||||
"240810", # 원익IPS
|
||||
"357180", # LX세미콘
|
||||
"403870", # HPSP
|
||||
"039030", # 이오테크닉스
|
||||
"089030", # 테크윙
|
||||
"095610", # 테스
|
||||
"067310", # 하나마이크론
|
||||
"000990", # DB하이텍
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="battery",
|
||||
name="2차전지",
|
||||
description="셀·소재·장비 전반.",
|
||||
codes=(
|
||||
"373220", # LG에너지솔루션
|
||||
"006400", # 삼성SDI
|
||||
"051910", # LG화학
|
||||
"247540", # 에코프로비엠
|
||||
"086520", # 에코프로
|
||||
"066970", # 엘앤에프
|
||||
"003670", # 포스코퓨처엠
|
||||
"121600", # 나노신소재
|
||||
"010130", # 고려아연
|
||||
"213420", # 덕산네오룩스
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="bio",
|
||||
name="바이오·헬스케어",
|
||||
description="제약·바이오시밀러·CDMO.",
|
||||
codes=(
|
||||
"207940", # 삼성바이오로직스
|
||||
"068270", # 셀트리온
|
||||
"091990", # 셀트리온헬스케어
|
||||
"196170", # 알테오젠
|
||||
"145020", # 휴젤
|
||||
"086900", # 메디톡스
|
||||
"069620", # 대웅제약
|
||||
"128940", # 한미약품
|
||||
"000100", # 유한양행
|
||||
"328130", # 루닛
|
||||
"237690", # 에스티팜
|
||||
"141080", # 리가켐바이오
|
||||
"000250", # 삼천당제약
|
||||
"028300", # HLB
|
||||
"078160", # 메디포스트
|
||||
"085660", # 차바이오텍
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="game",
|
||||
name="게임",
|
||||
description="모바일·PC·콘솔 게임사.",
|
||||
codes=(
|
||||
"035720", # 카카오 (게임즈 모회사)
|
||||
"036570", # 엔씨소프트
|
||||
"251270", # 넷마블
|
||||
"263750", # 펄어비스
|
||||
"293490", # 카카오게임즈
|
||||
"112040", # 위메이드
|
||||
"194700", # 네오위즈
|
||||
"078340", # 컴투스
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="entertainment",
|
||||
name="엔터테인먼트",
|
||||
description="K-팝/엔터/플랫폼.",
|
||||
codes=(
|
||||
"352820", # 하이브
|
||||
"035900", # JYP Ent.
|
||||
"041510", # 에스엠
|
||||
"122870", # 와이지엔터테인먼트
|
||||
"067160", # 아프리카TV
|
||||
"376300", # 디어유
|
||||
"035760", # CJ ENM
|
||||
"196300", # 쇼박스
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="auto",
|
||||
name="자동차",
|
||||
description="완성차·부품·타이어.",
|
||||
codes=(
|
||||
"005380", # 현대차
|
||||
"000270", # 기아
|
||||
"012330", # 현대모비스
|
||||
"086280", # 현대글로비스
|
||||
"161390", # 한국타이어앤테크놀로지
|
||||
"000240", # 한국앤컴퍼니
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="finance",
|
||||
name="금융",
|
||||
description="은행·증권·보험·지주.",
|
||||
codes=(
|
||||
"105560", # KB금융
|
||||
"055550", # 신한지주
|
||||
"086790", # 하나금융지주
|
||||
"316140", # 우리금융지주
|
||||
"138040", # 메리츠금융지주
|
||||
"175330", # JB금융지주
|
||||
"138930", # BNK금융지주
|
||||
"024110", # 기업은행
|
||||
"032830", # 삼성생명
|
||||
"088350", # 한화생명
|
||||
"000810", # 삼성화재
|
||||
"005830", # DB손해보험
|
||||
"001450", # 현대해상
|
||||
"071050", # 한국금융지주
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="shipbuilding-defense",
|
||||
name="조선·방산",
|
||||
description="조선·중공업·항공우주·방산.",
|
||||
codes=(
|
||||
"329180", # HD현대중공업
|
||||
"009540", # HD한국조선해양
|
||||
"267260", # HD현대일렉트릭
|
||||
"010620", # 현대미포조선
|
||||
"042660", # 한화오션
|
||||
"010140", # 삼성중공업
|
||||
"012450", # 한화에어로스페이스 (SEED)
|
||||
"047810", # 한국항공우주
|
||||
"064350", # 현대로템
|
||||
"267250", # HD현대
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="platform-ai",
|
||||
name="플랫폼·AI",
|
||||
description="검색·메신저·콘텐츠·로봇.",
|
||||
codes=(
|
||||
"035420", # NAVER
|
||||
"035720", # 카카오
|
||||
"277810", # 레인보우로보틱스
|
||||
"304100", # 솔트룩스
|
||||
"388790", # 케이엠더블유
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="consumer-retail",
|
||||
name="유통·소비재",
|
||||
description="대형마트·백화점·식음료·뷰티.",
|
||||
codes=(
|
||||
"139480", # 이마트
|
||||
"023530", # 롯데쇼핑
|
||||
"069960", # 현대백화점
|
||||
"004170", # 신세계
|
||||
"282330", # BGF리테일
|
||||
"007070", # GS리테일
|
||||
"097950", # CJ제일제당
|
||||
"271560", # 오리온
|
||||
"005180", # 빙그레
|
||||
"049770", # 동원F&B
|
||||
"005300", # 롯데칠성
|
||||
"000080", # 하이트진로
|
||||
"002790", # 아모레G
|
||||
"090430", # 아모레퍼시픽
|
||||
"008770", # 호텔신라
|
||||
),
|
||||
),
|
||||
Theme(
|
||||
slug="energy-utility",
|
||||
name="에너지·유틸리티",
|
||||
description="전력·정유·가스·풍력.",
|
||||
codes=(
|
||||
"015760", # 한국전력공사
|
||||
"017670", # SK텔레콤 (필수통신 묶음)
|
||||
"030200", # KT
|
||||
"010950", # S-Oil
|
||||
"047050", # 포스코인터내셔널
|
||||
"036460", # 한국가스공사 (SEED)
|
||||
"034020", # 두산에너빌리티 (SEED)
|
||||
"088980", # 맥쿼리인프라
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def themes_index() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"slug": t.slug,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"code_count": len(t.codes),
|
||||
}
|
||||
for t in THEMES
|
||||
]
|
||||
|
||||
|
||||
def theme_by_slug(slug: str) -> Theme | None:
|
||||
for t in THEMES:
|
||||
if t.slug == slug:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def themes_for_code(code: str) -> list[Theme]:
|
||||
"""주어진 종목 코드가 속한 테마들. 한 종목이 여러 테마에 동시 등재될 수 있음."""
|
||||
return [t for t in THEMES if code in t.codes]
|
||||
@@ -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
|
||||
@@ -1,40 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertsPanel } from "../../components/AlertsPanel";
|
||||
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
||||
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
||||
import { IntradayReturns } from "../../components/IntradayReturns";
|
||||
import { InvestmentNote } from "../../components/InvestmentNote";
|
||||
import { InvestorCumulative } from "../../components/InvestorCumulative";
|
||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||
import { PeerComparePanel } from "../../components/PeerComparePanel";
|
||||
import { NewsList } from "../../components/NewsList";
|
||||
import { OrderbookPanel } from "../../components/OrderbookPanel";
|
||||
import { PeriodReturns } from "../../components/PeriodReturns";
|
||||
import { PeriodTabs, periodNeighbor, periodSpec, type Period } from "../../components/PeriodTabs";
|
||||
import { PredictionPanel } from "../../components/PredictionPanel";
|
||||
import { PriceHero } from "../../components/PriceHero";
|
||||
import { PriceTargets } from "../../components/PriceTargets";
|
||||
import { RelatedStocks } from "../../components/RelatedStocks";
|
||||
import { ShareButton } from "../../components/ShareButton";
|
||||
import { StarButton } from "../../components/StarButton";
|
||||
import { StockChart } from "../../components/StockChart";
|
||||
import {
|
||||
api,
|
||||
type ChartPayload,
|
||||
type LatestPredictionResponse,
|
||||
} from "../../lib/api";
|
||||
import { SymbolSidebar } from "../../components/SymbolSidebar";
|
||||
import { TradingValuePanel } from "../../components/TradingValuePanel";
|
||||
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
||||
import { recent } from "../../lib/recent";
|
||||
import { readTargets, type Targets } from "../../lib/targets";
|
||||
import { watchlist } from "../../lib/watchlist";
|
||||
import { markRecorded, wasRecordedToday } from "../../lib/views";
|
||||
|
||||
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 [days, setDays] = useState(180);
|
||||
const [period, setPeriod] = useState<Period>("3M");
|
||||
const [viewsToday, setViewsToday] = useState<number | null>(null);
|
||||
const [targets, setTargets] = useState<Targets>({});
|
||||
|
||||
const spec = periodSpec(period);
|
||||
const isIntraday = spec.interval === "10m";
|
||||
|
||||
// 종목 페이지 방문 시 최근 본 종목에 push.
|
||||
useEffect(() => {
|
||||
recent.push(code);
|
||||
}, [code]);
|
||||
|
||||
// 조회 카운터.
|
||||
// - 같은 (code, KST today) 는 localStorage 마크로 dedupe → POST 1회.
|
||||
// - dedupe 실패 (스토리지 잠금 등) 해도 GET 으로 today_views 는 조회.
|
||||
// - POST 응답이 today_views 를 주므로 별도 GET 안 해도 됨 (한 라운드트립으로 끝).
|
||||
// - 이미 봤던 종목이면 GET 만.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const apply = (n: number) => {
|
||||
if (alive) setViewsToday(n);
|
||||
};
|
||||
if (!wasRecordedToday(code)) {
|
||||
api.recordView(code)
|
||||
.then((r) => {
|
||||
// POST 성공 후에만 mark — 실패 시 다음 방문에 다시 시도 가능.
|
||||
markRecorded(code);
|
||||
apply(r.today_views);
|
||||
})
|
||||
.catch(() => {
|
||||
// POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌).
|
||||
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
|
||||
});
|
||||
} else {
|
||||
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
// 저장된 목표가/손절가 — 마운트 + 종목 전환 시 localStorage 에서 로드.
|
||||
useEffect(() => {
|
||||
setTargets(readTargets(code));
|
||||
}, [code]);
|
||||
|
||||
// 종목 페이지 키보드 단축키:
|
||||
// ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap 안 함, 사용자가 끝 인지)
|
||||
// S : 관심종목 토글
|
||||
// 입력 필드(검색창, 메모 등) 포커스 시 무시 — 텍스트 편집 방해 금지.
|
||||
// modifier(Ctrl/Cmd/Alt) 가 눌리면 브라우저/OS 단축키 양보.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const isEditableTarget = (t: EventTarget | null): boolean => {
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
const tag = t.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (t.isContentEditable) return true;
|
||||
return false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (isEditableTarget(e.target)) return;
|
||||
// 모달/대화상자가 열려 있으면 뒤의 페이지 단축키를 가로채지 않음.
|
||||
// ShortcutsHelp 처럼 close 버튼이 포커스 잡혀 있어도 editable 가드는 통과되므로,
|
||||
// 페이지 단축키가 뒤에서 작동하는 것을 막기 위해 aria-modal 으로 추가 확인.
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setPeriod((cur) => periodNeighbor(cur, -1));
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setPeriod((cur) => periodNeighbor(cur, 1));
|
||||
} else if (e.key === "s" || e.key === "S") {
|
||||
e.preventDefault();
|
||||
watchlist.toggle(code);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [code]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
setChart(null);
|
||||
api
|
||||
.getChart(code, days)
|
||||
.then((c) => {
|
||||
if (alive) setChart(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
const load = () => {
|
||||
api
|
||||
.getChart(code, spec.days, spec.interval)
|
||||
.then((c) => {
|
||||
if (alive) setChart(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
};
|
||||
load();
|
||||
|
||||
// 1일(10분봉) 모드만 60s 폴링 — 백엔드가 10분 내면 DB 만 읽음.
|
||||
if (isIntraday) {
|
||||
const h = window.setInterval(load, 60_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(h);
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code, days]);
|
||||
}, [code, spec.days, spec.interval, isIntraday]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -44,53 +154,177 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
if (alive && r.found) setPrediction(r);
|
||||
})
|
||||
.catch(() => {
|
||||
// 예측 이력 없는 경우는 무시.
|
||||
// 예측 이력 없음 — 무시
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 검색으로
|
||||
</Link>
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value={60}>최근 3개월</option>
|
||||
<option value={180}>최근 6개월</option>
|
||||
<option value={365}>최근 1년</option>
|
||||
<option value={1095}>최근 3년</option>
|
||||
</select>
|
||||
</div>
|
||||
// 현재가/전일가 + 헤더 sparkline 시리즈 + 오늘 흐름 OHLV — ohlcv 끝부분에서 산출.
|
||||
//
|
||||
// flow 의 정의 (period 모드별로 "오늘" 의 의미가 다름):
|
||||
// - 1D(10m): 같은 KST 날짜의 모든 봉을 묶어서 today 의 세션 OHLV 로 집계.
|
||||
// open = 첫 봉 open, high = max(high), low = min(low), volume = sum(volume).
|
||||
// - 1d/1w/1mo: 마지막 봉 자체가 한 단위 (일/주/월) — 그 봉의 OHLV 를 그대로 사용.
|
||||
// PriceHero hero 라인 아래에 "시 / 고 / 저 / 거래량" mini KPI 로 표시 — 차트 hover 없이도
|
||||
// 페이지 진입 즉시 그날 흐름이 한 줄로 보임.
|
||||
const { current, prev, asOf, sparkSeries, flow } = useMemo(() => {
|
||||
const empty = {
|
||||
current: null as number | null,
|
||||
prev: null as number | null,
|
||||
asOf: null as string | null,
|
||||
sparkSeries: [] as number[],
|
||||
flow: null as { open: number; high: number; low: number; volume: number } | null,
|
||||
};
|
||||
if (!chart || !chart.ohlcv.length) return empty;
|
||||
const valid = chart.ohlcv.filter((p) => p.close != null);
|
||||
if (!valid.length) return empty;
|
||||
const last = valid[valid.length - 1];
|
||||
const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null;
|
||||
// 최근 30개 종가 (없으면 가용 전체).
|
||||
const recent = valid.slice(-30).map((p) => p.close as number);
|
||||
|
||||
{chart && (
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
// flow 집계.
|
||||
const isIntra = chart.interval === "10m";
|
||||
let bars: typeof valid;
|
||||
if (isIntra) {
|
||||
// 10m 모드는 'YYYY-MM-DDTHH:MM:SS' — 'T' 이전이 KST 날짜. 마지막 봉의 날짜와 같은
|
||||
// 봉만 묶음 (intraday 세션 = 오늘).
|
||||
const lastDay = last.date.split("T")[0];
|
||||
bars = valid.filter((p) => p.date.split("T")[0] === lastDay);
|
||||
} else {
|
||||
bars = [last];
|
||||
}
|
||||
const opens = bars.map((p) => p.open).filter((v): v is number => v != null);
|
||||
const highs = bars.map((p) => p.high).filter((v): v is number => v != null);
|
||||
const lows = bars.map((p) => p.low).filter((v): v is number => v != null);
|
||||
const vols = bars.map((p) => p.volume).filter((v): v is number => v != null);
|
||||
const flow =
|
||||
opens.length && highs.length && lows.length
|
||||
? {
|
||||
open: opens[0],
|
||||
high: Math.max(...highs),
|
||||
low: Math.min(...lows),
|
||||
// volume 누락 봉은 0 로 취급 (sum 안 그러면 NaN). 1d 모드는 단일 봉이라 무관.
|
||||
volume: vols.reduce((a, b) => a + b, 0),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
current: last.close,
|
||||
prev: prevPt?.close ?? null,
|
||||
asOf: last.date,
|
||||
sparkSeries: recent,
|
||||
flow,
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
{/*
|
||||
헤더 액션 바 — 2 행 구조:
|
||||
1행) 좌: 페이지 nav 링크 (← 검색 / 관심종목), 우: 종목 액션 (비교/공유/Star)
|
||||
2행) PeriodTabs (7 탭) 단독 — 항상 우측 정렬
|
||||
한 줄에 nav+액션+7탭 을 모두 넣으면 모바일에서 wrap 되며 PeriodTabs 가 줄 중간에
|
||||
끼어 어색해짐. 2행 분리 + flex-wrap + overflow-x-auto 로 모든 폭에서 일관.
|
||||
*/}
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 검색
|
||||
</Link>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
관심종목
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<Link
|
||||
href={`/compare?codes=${code}`}
|
||||
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
||||
title="이 종목으로 비교 시작"
|
||||
>
|
||||
⇄ 비교
|
||||
</Link>
|
||||
<ShareButton code={code} name={chart?.name} />
|
||||
<StarButton code={code} name={chart?.name} />
|
||||
</div>
|
||||
</div>
|
||||
{/*
|
||||
PeriodTabs 가 좁은 폭에서 컨테이너보다 넓어질 때:
|
||||
부모에 `justify-end` 를 직접 걸면 음수 방향으로 첫 탭이 잘려서 스크롤로도 접근이 어려움.
|
||||
wrapper-of-wrapper 패턴 — 바깥은 overflow-x-auto 스크롤만, 안쪽 inline flex 가 폭에 맞춰
|
||||
넓으면 좌측 정렬(스크롤 노출), 좁으면 우측 정렬(공간 차지 안 함). w-max + min-w-full 이 핵심.
|
||||
*/}
|
||||
<div className="mb-4 overflow-x-auto">
|
||||
<div className="flex w-max min-w-full justify-end">
|
||||
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-4 text-sm text-red-400">차트 로딩 실패: {err}</div>}
|
||||
|
||||
{chart && (
|
||||
<>
|
||||
<StockChart chart={chart} prediction={prediction} />
|
||||
<PriceHero
|
||||
name={chart.name}
|
||||
code={chart.code}
|
||||
market={chart.market}
|
||||
current={current}
|
||||
prev={prev}
|
||||
asOf={asOf}
|
||||
series={sparkSeries}
|
||||
viewsToday={viewsToday}
|
||||
flow={flow}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||
<div>
|
||||
<StockChart chart={chart} prediction={prediction} targets={targets} />
|
||||
{isIntraday && chart.intraday_status && (
|
||||
<div className="mt-2 text-right text-[11px] text-zinc-500">
|
||||
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}]
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SymbolSidebar code={code} />
|
||||
<OrderbookPanel code={code} sticky={isIntraday} />
|
||||
<PriceTargets code={code} current={current} onChange={setTargets} />
|
||||
<AlertsPanel code={code} name={chart?.name} current={current} />
|
||||
<InvestmentNote code={code} />
|
||||
<RelatedStocks code={code} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
|
||||
<CompositeScoreCard chart={chart} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
{/* 1D(10분봉) 모드에선 PeriodReturns 의 거래일 라벨이 거짓이 되므로 단기 카드로 교체 */}
|
||||
{isIntraday ? (
|
||||
<IntradayReturns ohlcv={chart.ohlcv} />
|
||||
) : (
|
||||
<PeriodReturns ohlcv={chart.ohlcv} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<PeerComparePanel code={code} />
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
<TradingValuePanel data={chart.trading_value} />
|
||||
<InvestorCumulative data={chart.trading_value} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<MetricsPanel code={code} />
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
<NewsList code={code} />
|
||||
<DisclosuresPanel code={code} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
212
web/app/alerts/page.tsx
Normal file
212
web/app/alerts/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
// /alerts — 모든 가격 알람을 한 화면에서 관리.
|
||||
// 종목별로 그룹핑, 활성/발사됨 분리, 재무장·삭제·일괄 지우기.
|
||||
// AlertsToaster 가 글로벌로 폴링하므로 이 페이지는 표시/조작만 담당.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { alerts, type Alert } from "../../lib/alerts";
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [items, setItems] = useState<Alert[]>([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHydrated(true);
|
||||
const refresh = () => setItems(alerts.list());
|
||||
refresh();
|
||||
return alerts.subscribe(refresh);
|
||||
}, []);
|
||||
|
||||
// 종목별 그룹. 표시는 최신 등록순(prepend 된 순서) 유지.
|
||||
const grouped = useMemo(() => {
|
||||
const m = new Map<string, { name?: string; rows: Alert[] }>();
|
||||
for (const a of items) {
|
||||
const g = m.get(a.code) ?? { name: a.name, rows: [] };
|
||||
if (!g.name && a.name) g.name = a.name;
|
||||
g.rows.push(a);
|
||||
m.set(a.code, g);
|
||||
}
|
||||
return Array.from(m.entries()).map(([code, g]) => ({
|
||||
code,
|
||||
name: g.name,
|
||||
rows: g.rows,
|
||||
}));
|
||||
}, [items]);
|
||||
|
||||
const activeCount = items.filter((a) => !a.triggered).length;
|
||||
const firedCount = items.length - activeCount;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 홈
|
||||
</Link>
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
활성 {activeCount} · 발사됨 {firedCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-50">가격 알람</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
브라우저에 저장됩니다. <b className="text-zinc-300">웹앱이 열려 있는 동안</b>{" "}
|
||||
활성 알람을 60초마다 체크해 임계값 도달 시 토스트 + (권한 허용 시) 시스템 알림을
|
||||
띄웁니다. 탭이 닫혀 있으면 체크하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm("모든 알람을 삭제하시겠어요?")) alerts.clear();
|
||||
}}
|
||||
className="shrink-0 rounded-md border border-zinc-700 px-2 py-1 text-[11px] text-zinc-400 hover:border-rose-500/40 hover:text-rose-300"
|
||||
>
|
||||
전체 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{!hydrated ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
로딩 중…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
|
||||
아직 설정된 알람이 없습니다.
|
||||
<br />
|
||||
종목 페이지의 <b className="text-amber-300">가격 알람</b> 카드에서 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<NotificationHint />
|
||||
)}
|
||||
|
||||
{hydrated && grouped.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{grouped.map((g) => (
|
||||
<Group key={g.code} code={g.code} name={g.name} rows={g.rows} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 알림 권한이 default 면 안내 + 요청 버튼.
|
||||
function NotificationHint() {
|
||||
const [perm, setPerm] = useState<NotificationPermission | "unsupported">(
|
||||
"unsupported",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("Notification" in window)) {
|
||||
setPerm("unsupported");
|
||||
return;
|
||||
}
|
||||
setPerm(Notification.permission);
|
||||
}, []);
|
||||
|
||||
if (perm === "granted" || perm === "denied" || perm === "unsupported") return null;
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200">
|
||||
<span>시스템 알림 권한이 없습니다. 허용하면 다른 창을 보고 있을 때도 OS 알림이 뜹니다 (탭이 열려 있어야 함).</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const p = await Notification.requestPermission();
|
||||
setPerm(p);
|
||||
} catch {
|
||||
// 무시
|
||||
}
|
||||
}}
|
||||
className="shrink-0 rounded-md border border-amber-400/50 bg-amber-500/10 px-2 py-1 text-[11px] hover:bg-amber-500/20"
|
||||
>
|
||||
권한 허용
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({
|
||||
code,
|
||||
name,
|
||||
rows,
|
||||
}: {
|
||||
code: string;
|
||||
name?: string;
|
||||
rows: Alert[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Link
|
||||
href={`/${code}`}
|
||||
className="flex items-baseline gap-2 text-sm text-zinc-100 hover:text-white"
|
||||
>
|
||||
<span className="font-medium">{name ?? code}</span>
|
||||
<span className="text-[10px] text-zinc-500">{code}</span>
|
||||
</Link>
|
||||
<span className="text-[10px] text-zinc-500">{rows.length}개</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{rows.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between gap-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className={`shrink-0 rounded-sm px-1 py-0.5 text-[10px] font-semibold ${
|
||||
a.op === "gte"
|
||||
? "bg-rose-900/50 text-rose-300"
|
||||
: "bg-sky-900/50 text-sky-300"
|
||||
}`}
|
||||
>
|
||||
{a.op === "gte" ? "≥" : "≤"}
|
||||
</span>
|
||||
<span className="tabular-nums text-zinc-100">
|
||||
{a.target.toLocaleString()}
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] text-zinc-500">
|
||||
{new Date(a.createdAt).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
{a.triggered && (
|
||||
<span className="ml-1 rounded-sm bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-400">
|
||||
발사됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{a.triggered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alerts.reset(a.id)}
|
||||
className="rounded-sm border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
재무장
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alerts.remove(a.id)}
|
||||
className="rounded-sm px-1 text-[10px] text-zinc-500 hover:text-rose-300"
|
||||
aria-label="알람 삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
414
web/app/compare/page.tsx
Normal file
414
web/app/compare/page.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
// /compare?codes=005930,000660,...
|
||||
// 최대 5 종목까지 같은 기간의 누적수익률(%)을 한 차트에 겹쳐서 비교.
|
||||
// 토스의 "종목 비교" 화면 톤. 각 종목 첫 종가를 기준선(0%)으로 정규화.
|
||||
//
|
||||
// 구현:
|
||||
// - URLSearchParams 의 codes (콤마구분) ↔ 내부 state 양방향
|
||||
// - 기간 토글: 1M/3M/6M/1Y (각각 21/63/126/252 거래일)
|
||||
// - 종목별 /api/chart 병렬 fetch → 누적수익률로 매핑 → lightweight-charts line 시리즈
|
||||
// - 종목 추가는 헤더의 작은 검색바 (debounced)
|
||||
// - 색상 팔레트 5색 순환
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createChart,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type LineData,
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import { api, type Symbol as SymbolItem } from "../../lib/api";
|
||||
|
||||
const MAX_CODES = 5;
|
||||
const PALETTE = ["#f43f5e", "#22d3ee", "#a78bfa", "#facc15", "#34d399"];
|
||||
|
||||
type Range = "1M" | "3M" | "6M" | "1Y";
|
||||
const RANGE_DAYS: Record<Range, number> = { "1M": 21, "3M": 63, "6M": 126, "1Y": 252 };
|
||||
|
||||
type Series = {
|
||||
code: string;
|
||||
name: string;
|
||||
points: { time: UTCTimestamp; value: number }[];
|
||||
finalPct: number | null;
|
||||
};
|
||||
|
||||
function isoToUtcTs(s: string): UTCTimestamp {
|
||||
return (Date.UTC(
|
||||
Number(s.slice(0, 4)),
|
||||
Number(s.slice(5, 7)) - 1,
|
||||
Number(s.slice(8, 10)),
|
||||
) / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
export default function ComparePage() {
|
||||
// useSearchParams 는 Suspense 안에서 안전. 내부 컴포넌트로 분리.
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CompareInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const codesParam = search.get("codes") ?? "";
|
||||
const [codes, setCodes] = useState<string[]>(() => parseCodes(codesParam));
|
||||
const [range, setRange] = useState<Range>("3M");
|
||||
const [series, setSeries] = useState<Series[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// URL ↔ state 동기화 — 외부에서 codes= 가 바뀌면 흡수.
|
||||
useEffect(() => {
|
||||
setCodes(parseCodes(codesParam));
|
||||
}, [codesParam]);
|
||||
|
||||
// codes 변경을 URL 에 반영.
|
||||
const writeUrl = (next: string[]) => {
|
||||
const q = next.length ? `?codes=${next.join(",")}` : "";
|
||||
router.replace(`/compare${q}`, { scroll: false });
|
||||
};
|
||||
|
||||
const add = (code: string, _name?: string) => {
|
||||
if (!code) return;
|
||||
if (codes.includes(code)) return;
|
||||
if (codes.length >= MAX_CODES) return;
|
||||
const next = [...codes, code];
|
||||
setCodes(next);
|
||||
writeUrl(next);
|
||||
};
|
||||
const remove = (code: string) => {
|
||||
const next = codes.filter((c) => c !== code);
|
||||
setCodes(next);
|
||||
writeUrl(next);
|
||||
};
|
||||
|
||||
// 시리즈 fetch.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
if (codes.length === 0) {
|
||||
setSeries([]);
|
||||
return;
|
||||
}
|
||||
const days = RANGE_DAYS[range] + 8; // 비거래일 여유.
|
||||
Promise.all(
|
||||
codes.map(async (code) => {
|
||||
const c = await api.getChart(code, days, "1d");
|
||||
const valid = c.ohlcv.filter((p) => p.close != null && p.close > 0);
|
||||
if (!valid.length) {
|
||||
return {
|
||||
code,
|
||||
name: c.name,
|
||||
points: [],
|
||||
finalPct: null,
|
||||
} as Series;
|
||||
}
|
||||
const base = valid[0].close as number;
|
||||
const pts = valid.map((p) => ({
|
||||
time: isoToUtcTs(p.date),
|
||||
value: (((p.close as number) - base) / base) * 100,
|
||||
}));
|
||||
const final = pts[pts.length - 1].value;
|
||||
return { code, name: c.name, points: pts, finalPct: final };
|
||||
}),
|
||||
)
|
||||
.then((r) => {
|
||||
if (alive) setSeries(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [codes, range]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 홈
|
||||
</Link>
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
{codes.length} / {MAX_CODES} 종목
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-zinc-50">종목 비교</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
기준일 종가를 0% 로 정규화한 누적수익률. 최대 {MAX_CODES} 종목까지 겹쳐서 보여줍니다.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<RangeTabs value={range} onChange={setRange} />
|
||||
<SearchAdd onPick={add} disabled={codes.length >= MAX_CODES} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{series.map((s, i) => (
|
||||
<Chip
|
||||
key={s.code}
|
||||
color={PALETTE[i % PALETTE.length]}
|
||||
name={s.name}
|
||||
code={s.code}
|
||||
pct={s.finalPct}
|
||||
onRemove={() => remove(s.code)}
|
||||
/>
|
||||
))}
|
||||
{codes.length === 0 && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
상단의 검색바에서 종목을 골라 추가하세요.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="mt-4 rounded-md border border-rose-500/30 bg-rose-500/5 p-3 text-xs text-rose-200">
|
||||
비교 데이터 로딩 실패: {err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<CompareChart series={series} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function parseCodes(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /^[0-9A-Za-z]{4,12}$/.test(s)),
|
||||
),
|
||||
).slice(0, MAX_CODES);
|
||||
}
|
||||
|
||||
function RangeTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Range;
|
||||
onChange: (r: Range) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
|
||||
{(["1M", "3M", "6M", "1Y"] as Range[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => onChange(r)}
|
||||
className={`px-2.5 py-1 ${
|
||||
value === r
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAdd({
|
||||
onPick,
|
||||
disabled,
|
||||
}: {
|
||||
onPick: (code: string, name: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<SymbolItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
const h = setTimeout(async () => {
|
||||
try {
|
||||
const r = await api.search(term, false, 6, false);
|
||||
setItems(r.items);
|
||||
} catch {
|
||||
setItems([]);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(h);
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current) return;
|
||||
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "최대치 도달" : "종목 추가 검색"}
|
||||
className="w-56 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{open && q.trim() && items.length > 0 && (
|
||||
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-72 overflow-auto rounded-md border border-zinc-700 bg-zinc-950 shadow-xl">
|
||||
{items.map((it) => (
|
||||
<li key={it.code}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(it.code, it.name);
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-zinc-800/60"
|
||||
>
|
||||
<span className="min-w-0 truncate text-zinc-100">{it.name}</span>
|
||||
<span className="shrink-0 text-zinc-500">
|
||||
{it.code} · {it.market}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
color,
|
||||
name,
|
||||
code,
|
||||
pct,
|
||||
onRemove,
|
||||
}: {
|
||||
color: string;
|
||||
name: string;
|
||||
code: string;
|
||||
pct: number | null;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const tone = pct == null ? "text-zinc-400" : pct > 0 ? "text-rose-300" : pct < 0 ? "text-sky-300" : "text-zinc-300";
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/50 px-2.5 py-1 text-xs">
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-zinc-100">{name}</span>
|
||||
<span className="text-[10px] text-zinc-500">{code}</span>
|
||||
{pct != null && (
|
||||
<span className={`tabular-nums ${tone}`}>
|
||||
{pct >= 0 ? "+" : ""}
|
||||
{pct.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-1 text-zinc-500 hover:text-rose-300"
|
||||
aria-label="제거"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareChart({ series }: { series: Series[] }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map());
|
||||
|
||||
// chart 생성/파괴.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const c = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { color: "transparent" },
|
||||
textColor: "#cbd5e1",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "#1f2937" },
|
||||
horzLines: { color: "#1f2937" },
|
||||
},
|
||||
rightPriceScale: { borderColor: "#374151" },
|
||||
timeScale: { borderColor: "#374151", timeVisible: false },
|
||||
autoSize: true,
|
||||
crosshair: { mode: 1 },
|
||||
});
|
||||
chartRef.current = c;
|
||||
const map = seriesRef.current;
|
||||
return () => {
|
||||
c.remove();
|
||||
chartRef.current = null;
|
||||
map.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 시리즈 동기화.
|
||||
useEffect(() => {
|
||||
const c = chartRef.current;
|
||||
if (!c) return;
|
||||
const wanted = new Set(series.map((s) => s.code));
|
||||
// 제거된 종목 unsubscribe.
|
||||
for (const [code, s] of seriesRef.current) {
|
||||
if (!wanted.has(code)) {
|
||||
c.removeSeries(s);
|
||||
seriesRef.current.delete(code);
|
||||
}
|
||||
}
|
||||
// 추가/갱신.
|
||||
series.forEach((s, i) => {
|
||||
let line = seriesRef.current.get(s.code);
|
||||
if (!line) {
|
||||
line = c.addLineSeries({
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
lineWidth: 2,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: s.name,
|
||||
});
|
||||
seriesRef.current.set(s.code, line);
|
||||
} else {
|
||||
line.applyOptions({ color: PALETTE[i % PALETTE.length] });
|
||||
}
|
||||
const data: LineData[] = s.points.map((p) => ({ time: p.time, value: p.value }));
|
||||
line.setData(data);
|
||||
});
|
||||
c.timeScale().fitContent();
|
||||
}, [series]);
|
||||
|
||||
const hasAny = useMemo(() => series.some((s) => s.points.length > 0), [series]);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
||||
<div className="h-[420px] w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
{!hasAny && (
|
||||
<div className="px-2 py-2 text-[11px] text-zinc-500">
|
||||
비교할 종목을 추가하면 차트가 그려집니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,45 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { AlertsToaster } from "../components/AlertsToaster";
|
||||
import { PwaRegister } from "../components/PwaRegister";
|
||||
import { ShortcutsHelp } from "../components/ShortcutsHelp";
|
||||
import { TopNav } from "../components/TopNav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stock Chart Site",
|
||||
description: "개인용 주식 예측 차트",
|
||||
title: {
|
||||
default: "주식 차트 · 예측",
|
||||
template: "%s · 주식 차트",
|
||||
},
|
||||
description: "한국 주식 차트와 단기 예측을 보는 개인용 앱.",
|
||||
manifest: "/manifest.webmanifest",
|
||||
// SVG 아이콘 하나로 다 처리. Chrome/Safari/iOS 모두 SVG favicon + apple-touch 수용.
|
||||
icons: {
|
||||
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
|
||||
apple: [{ url: "/icon.svg", type: "image/svg+xml" }],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "주식 차트",
|
||||
},
|
||||
};
|
||||
|
||||
// Next 14: themeColor / colorScheme 는 viewport 로 분리해야 빌드 경고가 안 뜸.
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#09090b",
|
||||
colorScheme: "dark",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className="min-h-screen">{children}</body>
|
||||
<body className="min-h-screen">
|
||||
<TopNav />
|
||||
{children}
|
||||
<AlertsToaster />
|
||||
<ShortcutsHelp />
|
||||
<PwaRegister />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { IndicesPanel } from "../components/IndicesPanel";
|
||||
import { MarketsOverview } from "../components/MarketsOverview";
|
||||
import { RecentTiles } from "../components/RecentTiles";
|
||||
import { SearchBox } from "../components/SearchBox";
|
||||
import { SeedTiles } from "../components/SeedTiles";
|
||||
import { ThemeTiles } from "../components/ThemeTiles";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-16">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Stock Chart Site</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
종목을 검색해 현재 차트를 보고, <b>예상차트 보기</b> 버튼으로 Chronos + LightGBM
|
||||
앙상블의 단기(1·3·5거래일) 예측을 차트에 이어 붙입니다.
|
||||
</p>
|
||||
<main className="mx-auto max-w-5xl px-6 py-12">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-50">종목 검색</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
종목을 골라 차트를 보고, <b className="text-rose-300">예상차트 보기</b> 버튼으로
|
||||
15일·30일·1년 예측 캔들을 같은 차트에 이어 붙입니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="shrink-0 rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300 hover:bg-amber-500/20"
|
||||
>
|
||||
★ 관심종목
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mt-6">
|
||||
<SearchBox autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-3 text-xs text-zinc-500 sm:grid-cols-2">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<div className="font-medium text-zinc-300">학습 대상 10종목</div>
|
||||
<div className="mt-1">
|
||||
삼성전자, SK하이닉스, 에코프로비엠, 한미반도체, 두산에너빌리티, 한화에어로스페이스,
|
||||
HD현대중공업, NAVER, KT&G, 한국가스공사
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<div className="font-medium text-zinc-300">매칭/재학습</div>
|
||||
<div className="mt-1">
|
||||
평일 16:30 KST 에 예측과 실제 종가 매칭, 일요일 02:00 KST 에 LGBM 재학습.
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<RecentTiles />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<IndicesPanel />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<SeedTiles />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<ThemeTiles />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<MarketsOverview />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
<div className="font-medium text-zinc-300">매칭 · 재학습</div>
|
||||
<div className="mt-1">
|
||||
평일 16:30 KST 예측↔실제 매칭, 일요일 02:00 KST LGBM 재학습.
|
||||
앙상블은 Chronos(zero-shot) + LightGBM.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
97
web/app/themes/[slug]/page.tsx
Normal file
97
web/app/themes/[slug]/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
// 테마 상세 페이지: /themes/{slug}.
|
||||
// 카드 그리드로 해당 테마 종목을 나열. close/pct_change 없으면 자리만 표시.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ThemeDetailResponse } from "../../../lib/api";
|
||||
|
||||
export default function ThemePage({ params }: { params: { slug: string } }) {
|
||||
const { slug } = params;
|
||||
const [data, setData] = useState<ThemeDetailResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
setData(null);
|
||||
api
|
||||
.themeDetail(slug, 60)
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="mb-4">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 홈
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{err && <div className="text-sm text-red-400">테마 로딩 실패: {err}</div>}
|
||||
{!data && !err && <div className="text-sm text-zinc-500">테마 로딩 중…</div>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-zinc-50">{data.name}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">{data.description}</p>
|
||||
<div className="mt-1 text-[11px] text-zinc-600">
|
||||
{data.items.length} 종목
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.items.map((m) => {
|
||||
const pct = m.pct_change;
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<Link
|
||||
key={m.code}
|
||||
href={`/${m.code}`}
|
||||
className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5 transition hover:border-zinc-700 hover:bg-zinc-800/60"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-sm font-medium text-zinc-100">
|
||||
{m.name}
|
||||
</div>
|
||||
<div className="shrink-0 text-[11px] text-zinc-500">
|
||||
{m.market ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline justify-between text-xs tabular-nums">
|
||||
<span className="text-zinc-200">
|
||||
{m.close != null
|
||||
? m.close.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
<span className={tone}>
|
||||
{pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-zinc-600">{m.code}</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
159
web/app/watchlist/page.tsx
Normal file
159
web/app/watchlist/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
// /watchlist — localStorage 의 관심종목 코드들을 카드 그리드로.
|
||||
// SEED 타일과 같은 톤. 각 카드는 /api/chart 2일치를 받아 현재가/전일대비를 채움.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import { watchlist } from "../../lib/watchlist";
|
||||
|
||||
type Tile = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
current: number | null;
|
||||
prev: number | null;
|
||||
};
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const [tiles, setTiles] = useState<Tile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
// localStorage 구독 — 다른 탭/페이지에서 추가/제거해도 즉시 반영.
|
||||
useEffect(() => {
|
||||
setHydrated(true);
|
||||
const refresh = () => setCodes(watchlist.get());
|
||||
refresh();
|
||||
return watchlist.subscribe(refresh);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
if (codes.length === 0) {
|
||||
setTiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.allSettled(
|
||||
codes.map(async (code) => {
|
||||
const c = await api.getChart(code, 5, "1d");
|
||||
const valid = c.ohlcv.filter((p) => p.close != null);
|
||||
const last = valid[valid.length - 1] ?? null;
|
||||
const prev = valid.length >= 2 ? valid[valid.length - 2] : null;
|
||||
return {
|
||||
code,
|
||||
name: c.name,
|
||||
market: c.market,
|
||||
current: last?.close ?? null,
|
||||
prev: prev?.close ?? null,
|
||||
};
|
||||
}),
|
||||
).then((settled) => {
|
||||
if (!alive) return;
|
||||
const next: Tile[] = settled.map((r, i) => {
|
||||
if (r.status === "fulfilled") return r.value;
|
||||
return {
|
||||
code: codes[i],
|
||||
name: codes[i],
|
||||
market: "—",
|
||||
current: null,
|
||||
prev: null,
|
||||
};
|
||||
});
|
||||
setTiles(next);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [codes, hydrated]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 홈
|
||||
</Link>
|
||||
<span className="text-[11px] text-zinc-500">{codes.length} 종목</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-zinc-50">관심종목</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
브라우저 저장소에 보관됩니다. 로그인 없이 같은 브라우저에서 유지.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
{!hydrated || loading ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
관심종목 로딩 중…
|
||||
</div>
|
||||
) : codes.length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
|
||||
아직 관심종목이 없습니다.
|
||||
<br />
|
||||
종목 페이지에서 <b className="text-amber-300">☆ 관심</b> 버튼을 눌러 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{tiles.map((t) => (
|
||||
<TileCard key={t.code} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TileCard({ t }: { t: Tile }) {
|
||||
const change =
|
||||
t.current != null && t.prev != null ? t.current - t.prev : null;
|
||||
const pct =
|
||||
change != null && t.prev != null && t.prev !== 0
|
||||
? (change / t.prev) * 100
|
||||
: null;
|
||||
const tone =
|
||||
change == null || change === 0
|
||||
? "text-zinc-400"
|
||||
: change > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={`/${t.code}`}
|
||||
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-3 transition hover:border-zinc-600"
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-zinc-100">{t.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-zinc-500">
|
||||
{t.code} · {t.market}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tabular-nums text-zinc-100">
|
||||
{t.current != null
|
||||
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—"}
|
||||
</div>
|
||||
<div className={`text-[11px] tabular-nums ${tone}`}>
|
||||
{change == null
|
||||
? " "
|
||||
: `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`}
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchlist.remove(t.code)}
|
||||
title="관심종목에서 제거"
|
||||
className="absolute right-1.5 top-1.5 rounded-full bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
web/components/AlertsPanel.tsx
Normal file
142
web/components/AlertsPanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
// 종목 페이지의 가격 알람 패널.
|
||||
// 현재 종목의 활성 알람 리스트 + 새 알람 추가 폼 (≥ / ≤ + 가격).
|
||||
// 발사 자체는 AlertsToaster 가 글로벌로 처리. 여기는 입력/표시만.
|
||||
// Collapsible 본문은 hidden 으로만 가리므로 접어도 op/target partial 입력 유지.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { alerts, type Alert, type AlertOp } from "../lib/alerts";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
// 현재가 — 폼 placeholder 와 기본값으로 사용.
|
||||
current?: number | null;
|
||||
};
|
||||
|
||||
export function AlertsPanel({ code, name, current }: Props) {
|
||||
const [items, setItems] = useState<Alert[]>([]);
|
||||
const [op, setOp] = useState<AlertOp>("gte");
|
||||
const [target, setTarget] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setItems(alerts.listForCode(code));
|
||||
refresh();
|
||||
return alerts.subscribe(refresh);
|
||||
}, [code]);
|
||||
|
||||
const onAdd = () => {
|
||||
const n = Number(target.replace(/[, ]/g, ""));
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
alerts.add({ code, name: name ?? undefined, op, target: n });
|
||||
setTarget("");
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
current != null ? `현재가 ${current.toLocaleString()}원` : undefined;
|
||||
|
||||
return (
|
||||
<Collapsible title="가격 알람" subtitle={subtitle} storageKey="alerts">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOp("gte")}
|
||||
className={`px-2 py-1 ${
|
||||
op === "gte"
|
||||
? "bg-zinc-800 text-rose-300"
|
||||
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
≥ 도달 시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOp("lte")}
|
||||
className={`px-2 py-1 ${
|
||||
op === "lte"
|
||||
? "bg-zinc-800 text-sky-300"
|
||||
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
≤ 하락 시
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onAdd();
|
||||
}}
|
||||
placeholder={current != null ? String(Math.round(current)) : "목표가"}
|
||||
className="w-28 rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={!target.trim()}
|
||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-3 text-[11px] text-zinc-500">
|
||||
설정된 알람이 없습니다. 임계값을 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-zinc-800">
|
||||
{items.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={`shrink-0 rounded-sm px-1 py-0.5 text-[10px] font-semibold ${
|
||||
a.op === "gte"
|
||||
? "bg-rose-900/50 text-rose-300"
|
||||
: "bg-sky-900/50 text-sky-300"
|
||||
}`}
|
||||
>
|
||||
{a.op === "gte" ? "≥" : "≤"}
|
||||
</span>
|
||||
<span className="tabular-nums text-zinc-100">
|
||||
{a.target.toLocaleString()}
|
||||
</span>
|
||||
{a.triggered && (
|
||||
<span className="ml-1 rounded-sm bg-zinc-800 px-1 py-0.5 text-[9px] text-zinc-400">
|
||||
발사됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{a.triggered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alerts.reset(a.id)}
|
||||
className="rounded-sm border border-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
재무장
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alerts.remove(a.id)}
|
||||
className="rounded-sm px-1 text-[10px] text-zinc-500 hover:text-rose-300"
|
||||
aria-label="알람 삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
146
web/components/AlertsToaster.tsx
Normal file
146
web/components/AlertsToaster.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
// 글로벌 가격 알람 폴러 + 인페이지 토스트.
|
||||
//
|
||||
// 동작:
|
||||
// 1) alerts 구독 + 60s 인터벌마다 미발사(triggered=false) 알람 코드 set 수집
|
||||
// 2) 각 코드별 /api/chart?days=2&interval=1d → 마지막 종가만 사용
|
||||
// 3) 조건 (gte: close >= target, lte: close <= target) 만족 시 markTriggered + 토스트
|
||||
//
|
||||
// 알람이 0건이면 폴링 자체를 하지 않음 (네트워크 정숙).
|
||||
// 토스트는 우측 하단에 쌓이며 6초 후 자동 dismiss.
|
||||
//
|
||||
// 브라우저 Notification 은 사용자가 허용한 경우에만 발송.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "../lib/api";
|
||||
import { alerts, type Alert } from "../lib/alerts";
|
||||
|
||||
type Toast = {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string;
|
||||
op: Alert["op"];
|
||||
target: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
const POLL_MS = 60_000;
|
||||
const TOAST_TTL_MS = 6_000;
|
||||
|
||||
export function AlertsToaster() {
|
||||
const [active, setActive] = useState<Alert[]>([]);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const tickingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setActive(alerts.list().filter((a) => !a.triggered));
|
||||
refresh();
|
||||
return alerts.subscribe(refresh);
|
||||
}, []);
|
||||
|
||||
// 폴링.
|
||||
useEffect(() => {
|
||||
if (active.length === 0) return;
|
||||
let alive = true;
|
||||
|
||||
const codes = Array.from(new Set(active.map((a) => a.code)));
|
||||
|
||||
const tick = async () => {
|
||||
if (tickingRef.current) return;
|
||||
tickingRef.current = true;
|
||||
try {
|
||||
// 종목별 마지막 종가 (직전 2 거래일만 받음 — 가볍게).
|
||||
const prices = new Map<string, number | null>();
|
||||
await Promise.allSettled(
|
||||
codes.map(async (code) => {
|
||||
const c = await api.getChart(code, 2, "1d");
|
||||
const valid = c.ohlcv.filter((p) => p.close != null);
|
||||
const last = valid[valid.length - 1] ?? null;
|
||||
prices.set(code, last?.close ?? null);
|
||||
}),
|
||||
);
|
||||
if (!alive) return;
|
||||
|
||||
// 알람 평가 — 최신 스냅샷(read)으로 재조회 (구독 사이 추가/삭제 반영).
|
||||
const fresh = alerts.list().filter((a) => !a.triggered);
|
||||
const newToasts: Toast[] = [];
|
||||
for (const a of fresh) {
|
||||
const p = prices.get(a.code);
|
||||
if (p == null) continue;
|
||||
const hit =
|
||||
(a.op === "gte" && p >= a.target) ||
|
||||
(a.op === "lte" && p <= a.target);
|
||||
if (!hit) continue;
|
||||
alerts.markTriggered(a.id);
|
||||
newToasts.push({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
op: a.op,
|
||||
target: a.target,
|
||||
price: p,
|
||||
});
|
||||
// 브라우저 알림 (권한 허용 시).
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
"Notification" in window &&
|
||||
Notification.permission === "granted"
|
||||
) {
|
||||
try {
|
||||
new Notification(`${a.name ?? a.code} 알람`, {
|
||||
body: `${a.op === "gte" ? "≥" : "≤"} ${a.target.toLocaleString()} · 현재 ${p.toLocaleString()}`,
|
||||
});
|
||||
} catch {
|
||||
// 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newToasts.length) {
|
||||
setToasts((cur) => [...cur, ...newToasts]);
|
||||
for (const t of newToasts) {
|
||||
window.setTimeout(() => {
|
||||
setToasts((cur) => cur.filter((x) => x.id !== t.id));
|
||||
}, TOAST_TTL_MS);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
tickingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const h = window.setInterval(tick, POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(h);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
|
||||
{toasts.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={`/${t.code}`}
|
||||
className="pointer-events-auto block w-72 rounded-md border border-amber-500/40 bg-zinc-950/95 p-3 shadow-xl backdrop-blur"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-amber-300">🔔 가격 알람</span>
|
||||
<span className="text-[10px] text-zinc-500">{t.code}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-zinc-100">
|
||||
{t.name ?? t.code}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
{t.op === "gte" ? "≥" : "≤"} {t.target.toLocaleString()} 도달 ·
|
||||
현재 <span className="tabular-nums text-zinc-100">{t.price.toLocaleString()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
web/components/CardHeader.tsx
Normal file
40
web/components/CardHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// 상세 페이지 본문 카드의 표준 헤더 — title 좌, 옵셔널 우측 슬롯 우.
|
||||
// 작은 wrapper 지만 본문 카드 9개(Metrics / IntradayReturns / PeriodReturns /
|
||||
// CompositeScoreCard / PeerComparePanel / TradingValuePanel / DisclosuresPanel /
|
||||
// NewsList / InvestorCumulative) 가 같은 mb-3 flex items-baseline justify-between
|
||||
// 패턴을 character-by-character 로 중복 정의하고 있어, ReturnBucket 분리와 같은
|
||||
// 이유로 단일 source of truth 로 묶는다 — 한쪽만 마진/폰트 바꾸면 본문 카드들
|
||||
// 사이에 톤 점프가 생기는 future drift 리스크 제거.
|
||||
//
|
||||
// 우측 슬롯은 두 형태:
|
||||
// - subtitle: 단순 보조 텍스트 (text-[11px] text-zinc-500 자동 적용). 8개 카드 사용.
|
||||
// - right : 임의 ReactNode (버튼 그룹 등 인터랙티브). InvestorCumulative 가 사용.
|
||||
// 둘 중 하나만 지정. 동시 지정 시 right 우선 (호출자 의도 명확화는 호출 시점에서).
|
||||
//
|
||||
// 제외 카드 — PredictionPanel: title 자체가 제목 + 설명 2줄 stack 이고 우측 큰
|
||||
// button 이라 flex 정렬이 items-center 필요. 별도 패턴이 한 곳뿐이라 wrapper 의
|
||||
// API 를 확장하기보다는 자체 헤더 유지하는 게 surgical.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: ReactNode;
|
||||
// 우측 보조 텍스트. 없으면 헤더에 title 만 남음 (이 경우에도 mb-3 flex 컨테이너
|
||||
// 는 유지 — MetricsPanel 같은 단일-제목 카드도 같은 라인 높이로 정렬).
|
||||
subtitle?: ReactNode;
|
||||
// 우측 임의 노드 (버튼 그룹 등). 제공되면 subtitle 대신 렌더.
|
||||
right?: ReactNode;
|
||||
};
|
||||
|
||||
export function CardHeader({ title, subtitle, right }: Props) {
|
||||
return (
|
||||
<div className="mb-3 flex items-baseline justify-between gap-2">
|
||||
<div className="text-sm font-medium text-zinc-200">{title}</div>
|
||||
{right != null ? (
|
||||
right
|
||||
) : subtitle != null ? (
|
||||
<div className="text-[11px] text-zinc-500">{subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
web/components/Collapsible.tsx
Normal file
97
web/components/Collapsible.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { readOpen, writeOpen } from "../lib/collapsible";
|
||||
|
||||
// 사이드바 패널의 공용 collapsible 래퍼.
|
||||
// - 헤더 row 가 button 으로 동작 → 클릭 시 본문 토글, aria-expanded 로 보조기술 알림.
|
||||
// - 사용자 선택은 localStorage 에 panel-open:<storageKey> 로 영속.
|
||||
// - 본문은 `hidden` 으로 숨겨 unmount 시키지 않음 → InvestmentNote 입력 텍스트,
|
||||
// AlertsPanel 폼 partial 입력, RelatedStocks fetch 결과 등이 토글 사이클 동안
|
||||
// 보존. (조건부 렌더로 children 을 끄면 re-mount → 데이터 재요청/입력 손실.)
|
||||
//
|
||||
// SSR 안전:
|
||||
// 초기 state 를 readOpen() 으로 lazy 초기화하면 서버는 window 가 없어 defaultOpen 을
|
||||
// 반환하고, 클라이언트 hydration 첫 렌더에서 localStorage 값과 다르면 React 가
|
||||
// hydration mismatch 를 경고함. useState(defaultOpen) → useEffect 에서 sync 패턴으로
|
||||
// 해결 — 1프레임 미세 jank 는 감수 (localStorage 값이 default 와 다를 때만, 사이드바
|
||||
// 패널이라 시선 끄는 위치도 아님).
|
||||
|
||||
type Props = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
storageKey: string;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Collapsible({
|
||||
title,
|
||||
subtitle,
|
||||
storageKey,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
className = "rounded-md border border-zinc-800 bg-zinc-900/40",
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(readOpen(storageKey, defaultOpen));
|
||||
}, [storageKey, defaultOpen]);
|
||||
|
||||
const toggle = () => {
|
||||
setOpen((cur) => {
|
||||
const next = !cur;
|
||||
writeOpen(storageKey, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
// hover 톤은 zinc-800/50 — zinc-900 보다 한 단계 밝은 색으로 잡아 부모의
|
||||
// bg 불투명도와 무관하게 항상 highlight 방향으로 시각 신호. zinc-900/60
|
||||
// 였을 땐 비-sticky 부모(/40) 에선 더 어두워져 highlight 가 됐지만,
|
||||
// OrderbookPanel sticky 부모(/80) 에선 오히려 더 옅어져 hover 가 "뒤로
|
||||
// 가는" 인상이 되는 톤 역전 발생. zinc-800 으로 한 단계 색을 올리면
|
||||
// /50 opacity 라도 색 자체가 밝아 두 부모 모두에서 highlight 로 읽힘.
|
||||
// 사이드바 list-row hover 톤(RelatedStocks/PeerList)과도 통일.
|
||||
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-800/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200">
|
||||
<span
|
||||
className={`inline-block text-[10px] text-zinc-500 transition-transform ${
|
||||
open ? "rotate-90" : ""
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle != null && (
|
||||
// min-w-0 + truncate: subtitle 이 길어지면(예: PriceTargets 의 "목표 1,234,567 ·
|
||||
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
|
||||
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
|
||||
// shrink 계산이 올바르게 되도록.
|
||||
//
|
||||
// 크기 토큰: text-[11px] — CardHeader 의 subtitle 과 동일 토큰. 사이드바 패널
|
||||
// 헤더(Collapsible) 와 본문 카드 헤더(CardHeader) 가 같은 페이지 좌우에 나란히
|
||||
// 놓이므로 헤더 우측 보조 텍스트 크기를 통일. 280px 좁은 사이드바에서도 truncate
|
||||
// 가 overflow 를 흡수해 1px 키워도 wrap 위험 없음.
|
||||
<span className="min-w-0 truncate text-[11px] text-zinc-500">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className={open ? "border-t border-zinc-800 px-4 pb-4 pt-3" : "hidden"}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
web/components/CompositeScoreCard.tsx
Normal file
215
web/components/CompositeScoreCard.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
// AI 종합 점수 카드 — 토스 "투자자 분석" 비슷한 톤.
|
||||
// chart payload (ohlcv + sentiment + trading_value) 에서 클라이언트가 직접 합성.
|
||||
// 백엔드 호출 없음.
|
||||
//
|
||||
// 4개 서브 점수 (각 0~100, 50 = 중립):
|
||||
// - 모멘텀 : SMA5 vs SMA20 격차
|
||||
// - 수급 : 외국인+기관 5일 누적 net (vs |대각| 정규화)
|
||||
// - 감성 : 뉴스 weighted_score 7일 평균 (-1~+1 → 0~100)
|
||||
// - 추세 : 종가 20일 선형회귀 기울기 부호+강도
|
||||
// 종합 = 평균. 50 미만 = 약세(파랑), 50 초과 = 강세(빨강).
|
||||
|
||||
import type { ChartPayload } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
type Scores = {
|
||||
momentum: number | null;
|
||||
flow: number | null;
|
||||
sentiment: number | null;
|
||||
trend: number | null;
|
||||
composite: number | null;
|
||||
};
|
||||
|
||||
function computeScores(chart: ChartPayload): Scores {
|
||||
// 모멘텀: SMA5 vs SMA20
|
||||
const closes = chart.ohlcv
|
||||
.map((p) => p.close)
|
||||
.filter((c): c is number => c != null);
|
||||
const sma = (arr: number[], n: number) => {
|
||||
if (arr.length < n) return null;
|
||||
const slice = arr.slice(-n);
|
||||
return slice.reduce((a, b) => a + b, 0) / n;
|
||||
};
|
||||
const sma5 = sma(closes, 5);
|
||||
const sma20 = sma(closes, 20);
|
||||
const momentum =
|
||||
sma5 != null && sma20 != null && sma20 > 0
|
||||
? clamp01(((sma5 - sma20) / sma20) * 10) // ±10% → 0~1
|
||||
: null;
|
||||
|
||||
// 수급: 외국인+기관 5일 net / 평균거래대금 (조잡한 정규화)
|
||||
const tv = chart.trading_value.slice(-5);
|
||||
const flowSum = tv.reduce(
|
||||
(a, r) => a + (r.foreign_net ?? 0) + (r.institution_net ?? 0),
|
||||
0,
|
||||
);
|
||||
const flowAbsMean =
|
||||
tv.reduce(
|
||||
(a, r) => a + Math.abs(r.foreign_net ?? 0) + Math.abs(r.institution_net ?? 0),
|
||||
0,
|
||||
) / Math.max(1, tv.length);
|
||||
const flow =
|
||||
tv.length >= 3 && flowAbsMean > 0
|
||||
? clamp01(flowSum / (flowAbsMean * 5))
|
||||
: null;
|
||||
|
||||
// 감성: weighted_score 평균 (최근 7개 점 기준)
|
||||
const sents = chart.sentiment
|
||||
.slice(-7)
|
||||
.map((p) => p.weighted_score)
|
||||
.filter((v): v is number => v != null);
|
||||
const sentMean =
|
||||
sents.length > 0 ? sents.reduce((a, b) => a + b, 0) / sents.length : null;
|
||||
const sentiment = sentMean != null ? clamp01(sentMean) : null;
|
||||
|
||||
// 추세: 최근 20 종가 선형회귀 기울기 / 평균값 (정규화)
|
||||
const last20 = closes.slice(-20);
|
||||
let trend: number | null = null;
|
||||
if (last20.length >= 10) {
|
||||
const n = last20.length;
|
||||
const xs = Array.from({ length: n }, (_, i) => i);
|
||||
const xMean = (n - 1) / 2;
|
||||
const yMean = last20.reduce((a, b) => a + b, 0) / n;
|
||||
let num = 0;
|
||||
let den = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
num += (xs[i] - xMean) * (last20[i] - yMean);
|
||||
den += (xs[i] - xMean) ** 2;
|
||||
}
|
||||
const slope = den > 0 ? num / den : 0;
|
||||
// 하루당 yMean 의 0.5% 변동 → 점수 만점/최저
|
||||
const norm = yMean > 0 ? slope / (yMean * 0.005) : 0;
|
||||
trend = clamp01(norm);
|
||||
}
|
||||
|
||||
const parts = [momentum, flow, sentiment, trend].filter(
|
||||
(v): v is number => v != null,
|
||||
);
|
||||
const composite =
|
||||
parts.length > 0 ? parts.reduce((a, b) => a + b, 0) / parts.length : null;
|
||||
|
||||
return {
|
||||
momentum: pct(momentum),
|
||||
flow: pct(flow),
|
||||
sentiment: pct(sentiment),
|
||||
trend: pct(trend),
|
||||
composite: pct(composite),
|
||||
};
|
||||
}
|
||||
|
||||
// -1~+1 입력 → 0~1 출력 (중립 0.5).
|
||||
function clamp01(x: number): number {
|
||||
return Math.max(0, Math.min(1, 0.5 + x / 2));
|
||||
}
|
||||
|
||||
function pct(v: number | null): number | null {
|
||||
return v == null ? null : Math.round(v * 100);
|
||||
}
|
||||
|
||||
export function CompositeScoreCard({ chart }: { chart: ChartPayload }) {
|
||||
const s = computeScores(chart);
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader title="종합 점수 (AI)" subtitle="모멘텀·수급·감성·추세 평균" />
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-5">
|
||||
<ScoreDial value={s.composite} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SubScore label="모멘텀" value={s.momentum} hint="SMA5 vs SMA20" />
|
||||
<SubScore label="수급" value={s.flow} hint="외국인+기관 5일" />
|
||||
<SubScore label="감성" value={s.sentiment} hint="뉴스 7일 가중" />
|
||||
<SubScore label="추세" value={s.trend} hint="20일 회귀 기울기" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-zinc-500">
|
||||
점수는 보조지표일 뿐 매매 추천이 아닙니다. 50 = 중립, 100 = 매우 강세.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreDial({ value }: { value: number | null }) {
|
||||
// CSS conic-gradient 반원 게이지. 0~100 → 0~180°.
|
||||
const v = value ?? 50;
|
||||
const angle = (v / 100) * 180;
|
||||
const tone =
|
||||
value == null
|
||||
? "text-zinc-400"
|
||||
: value >= 65
|
||||
? "text-rose-400"
|
||||
: value <= 35
|
||||
? "text-sky-400"
|
||||
: "text-amber-300";
|
||||
const ringColor =
|
||||
value == null
|
||||
? "#52525b"
|
||||
: value >= 65
|
||||
? "#ef4444"
|
||||
: value <= 35
|
||||
? "#3b82f6"
|
||||
: "#f59e0b";
|
||||
return (
|
||||
<div className="relative h-24 w-24">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from 270deg, ${ringColor} 0deg, ${ringColor} ${angle}deg, #27272a ${angle}deg, #27272a 180deg, transparent 180deg)`,
|
||||
mask: "radial-gradient(circle, transparent 38px, #000 39px)",
|
||||
WebkitMask:
|
||||
"radial-gradient(circle, transparent 38px, #000 39px)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pt-2">
|
||||
<div className={`text-xl font-bold tabular-nums ${tone}`}>
|
||||
{value == null ? "—" : value}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500">/ 100</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubScore({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
hint: string;
|
||||
}) {
|
||||
const tone =
|
||||
value == null
|
||||
? "text-zinc-400"
|
||||
: value >= 65
|
||||
? "text-rose-400"
|
||||
: value <= 35
|
||||
? "text-sky-400"
|
||||
: "text-zinc-200";
|
||||
const barColor =
|
||||
value == null
|
||||
? "bg-zinc-700"
|
||||
: value >= 65
|
||||
? "bg-rose-500/70"
|
||||
: value <= 35
|
||||
? "bg-sky-500/70"
|
||||
: "bg-amber-500/60";
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-1.5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
<span className={`text-sm font-medium tabular-nums ${tone}`}>
|
||||
{value == null ? "—" : value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className={`h-full ${barColor}`}
|
||||
style={{ width: `${value ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-zinc-600">{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
web/components/DisclosuresPanel.tsx
Normal file
91
web/components/DisclosuresPanel.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
// 공시(DART) 패널 — news 테이블의 source='dart' 만 골라서 카드 리스트로.
|
||||
// 별도 백엔드 엔드포인트는 안 만들고 기존 /api/news/{code}?source=dart 재사용.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type NewsResponse } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
export function DisclosuresPanel({ code }: { code: string }) {
|
||||
const [data, setData] = useState<NewsResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.news(code, 10, "dart")
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader
|
||||
title="공시 (DART)"
|
||||
subtitle={data ? `최근 ${data.items.length}건` : ""}
|
||||
/>
|
||||
|
||||
{err && <div className="text-xs text-red-400">공시 로딩 실패: {err}</div>}
|
||||
{!err && !data && <div className="text-xs text-zinc-500">공시 로딩 중…</div>}
|
||||
{data && data.items.length === 0 && (
|
||||
<div className="text-xs text-zinc-500">최근 공시 없음</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{data.items.map((n, i) => (
|
||||
<li key={i}>
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-md border border-transparent p-2 transition hover:border-zinc-700 hover:bg-zinc-800/40"
|
||||
>
|
||||
<div className="text-sm leading-snug text-zinc-100 line-clamp-2">
|
||||
{n.title}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="rounded-sm bg-emerald-900/50 px-1.5 py-0.5 font-semibold text-emerald-300">
|
||||
DART
|
||||
</span>
|
||||
{n.published_at && (
|
||||
<span className="text-zinc-500">
|
||||
{formatRelative(n.published_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMin = Math.round((now - d.getTime()) / 60000);
|
||||
if (diffMin < 1) return "방금";
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffMin < 60 * 24) return `${Math.round(diffMin / 60)}시간 전`;
|
||||
if (diffMin < 60 * 24 * 7) return `${Math.round(diffMin / (60 * 24))}일 전`;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
90
web/components/IndicesPanel.tsx
Normal file
90
web/components/IndicesPanel.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
// 홈 상단에 KOSPI / KOSDAQ 인덱스 두 칸. 우측엔 mini sparkline.
|
||||
// macro_daily 가 아직 비어 있으면 안내 카드 한 줄.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type IndicesResponse, type IndexSeries } from "../lib/api";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
|
||||
export function IndicesPanel() {
|
||||
const [data, setData] = useState<IndicesResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.indices(60)
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (err)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
지수 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
지수 로딩 중…
|
||||
</div>
|
||||
);
|
||||
|
||||
const allEmpty = data.indices.every((s) => s.points.length === 0);
|
||||
if (allEmpty)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
지수 데이터 준비 중. macro_daily 가 채워지면 자동으로 표시됩니다.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{data.indices.map((s) => (
|
||||
<IndexCard key={s.key} s={s} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndexCard({ s }: { s: IndexSeries }) {
|
||||
const pct = s.pct_change;
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
const stroke = pct != null && pct < 0 ? "#3b82f6" : "#ef4444";
|
||||
const values = s.points.map((p) => p.value);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-zinc-500">{s.name}</div>
|
||||
<div className="mt-0.5 text-xl font-semibold tabular-nums text-zinc-50">
|
||||
{s.latest != null
|
||||
? s.latest.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "—"}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-xs tabular-nums ${tone}`}>
|
||||
{pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
{values.length >= 2 && (
|
||||
<Sparkline values={values} stroke={stroke} width={120} height={40} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
web/components/IntradayReturns.tsx
Normal file
63
web/components/IntradayReturns.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
// 1D (10분봉) 모드 전용 단기 수익률 미니 카드.
|
||||
// PeriodReturns 는 거래일 단위 (1주/1개월/...) 계산이라 10m 데이터에선 라벨이 거짓이 됨
|
||||
// (1주=5봉=50분). 그래서 intraday 일 때는 이 컴포넌트로 갈아끼움.
|
||||
//
|
||||
// 정의:
|
||||
// - "현재가" = 마지막 유효 close (null 제외 후 가장 뒤). 폴링되면 자동 갱신.
|
||||
// - 10분/30분/1시간/3시간 = 마지막에서 N봉 전 close 와 비교.
|
||||
// - 시가대비 = 가장 첫 봉의 open(없으면 close) 과 비교 — 당일 손익률.
|
||||
// - 데이터 부족하면 "—" 표시.
|
||||
//
|
||||
// 추가 백엔드 호출 없음 — chart.ohlcv 슬라이싱만.
|
||||
|
||||
import type { OhlcvPoint } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
import { ReturnBucket } from "./ReturnBucket";
|
||||
|
||||
const BUCKETS: { label: string; bars: number }[] = [
|
||||
{ label: "10분", bars: 1 },
|
||||
{ label: "30분", bars: 3 },
|
||||
{ label: "1시간", bars: 6 },
|
||||
{ label: "3시간", bars: 18 },
|
||||
];
|
||||
|
||||
export function IntradayReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
|
||||
// close == null 인 휴장/결측 봉은 비교에서 제외.
|
||||
const valid = ohlcv.filter((p): p is OhlcvPoint & { close: number } => p.close != null);
|
||||
if (valid.length < 2) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
단기 수익률 데이터 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const last = valid[valid.length - 1].close;
|
||||
const buckets = BUCKETS.map((b) => {
|
||||
const idx = valid.length - 1 - b.bars;
|
||||
if (idx < 0) return { label: b.label, pct: null as number | null };
|
||||
const base = valid[idx].close;
|
||||
return { label: b.label, pct: base ? ((last - base) / base) * 100 : null };
|
||||
});
|
||||
|
||||
// 시가대비 — 당일 첫 봉의 open 우선, 없으면 close.
|
||||
const first = valid[0];
|
||||
const openBase =
|
||||
first.open != null && first.open > 0 ? first.open : first.close;
|
||||
const todayPct =
|
||||
openBase != null && openBase > 0 ? ((last - openBase) / openBase) * 100 : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader title="단기 수익률" subtitle="10분봉 기준 · 60초 갱신" />
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
{buckets.map((b) => (
|
||||
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
|
||||
))}
|
||||
<ReturnBucket label="시가대비" pct={todayPct} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
web/components/InvestmentNote.tsx
Normal file
117
web/components/InvestmentNote.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
// 종목 페이지의 사이드 메모 패널.
|
||||
// Toss "투자 메모" 패턴 — 진입 이유, 관전 포인트를 짧게 적는 자리.
|
||||
// localStorage 만 사용, 서버 전송 없음.
|
||||
// Collapsible 본문은 hidden 으로만 가리므로 접어도 textarea dirty 텍스트 유지.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { NOTE_MAX_LEN, readNote, writeNote, type Note } from "../lib/notes";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
const FEEDBACK_TEXT: Record<"saved" | "deleted" | "failed", string> = {
|
||||
saved: "저장됨",
|
||||
deleted: "삭제됨",
|
||||
failed: "저장 안됨",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
// '방금 전', '5분 전', '오늘 14:30', 'YYYY-MM-DD' 단계로 압축.
|
||||
function fmtUpdatedAt(iso: string): string {
|
||||
const t = Date.parse(iso);
|
||||
if (!Number.isFinite(t)) return "";
|
||||
const now = Date.now();
|
||||
const diffSec = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (diffSec < 60) return "방금 전";
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
const d = new Date(t);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
// 같은 날짜면 '오늘 HH:MM', 아니면 'YYYY-MM-DD'
|
||||
const today = new Date();
|
||||
if (
|
||||
today.getFullYear() === d.getFullYear() &&
|
||||
today.getMonth() === d.getMonth() &&
|
||||
today.getDate() === d.getDate()
|
||||
) {
|
||||
return `오늘 ${hm}`;
|
||||
}
|
||||
return ymd;
|
||||
}
|
||||
|
||||
export function InvestmentNote({ code }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [saved, setSaved] = useState<Note | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const n = readNote(code);
|
||||
setSaved(n);
|
||||
setText(n?.text ?? "");
|
||||
setFeedback(null);
|
||||
}, [code]);
|
||||
|
||||
const dirty = (saved?.text ?? "") !== text;
|
||||
|
||||
// 저장 직후 잠깐 띄우는 toast 같은 안내. dirty 또는 종목 전환 시 자동 해제.
|
||||
useEffect(() => {
|
||||
if (!feedback) return;
|
||||
const h = window.setTimeout(() => setFeedback(null), 2500);
|
||||
return () => window.clearTimeout(h);
|
||||
}, [feedback]);
|
||||
|
||||
const remaining = useMemo(() => NOTE_MAX_LEN - text.length, [text]);
|
||||
|
||||
const save = () => {
|
||||
const res = writeNote(code, text);
|
||||
if (res.status === "saved") {
|
||||
setSaved(res.note);
|
||||
setText(res.note.text);
|
||||
} else if (res.status === "deleted") {
|
||||
setSaved(null);
|
||||
setText("");
|
||||
}
|
||||
// failed: 입력값 유지 — 사용자가 재시도하거나 일부를 줄일 수 있도록.
|
||||
setFeedback(FEEDBACK_TEXT[res.status]);
|
||||
};
|
||||
|
||||
const subtitle = saved ? fmtUpdatedAt(saved.updatedAt) : "비공개 · 브라우저 저장";
|
||||
|
||||
return (
|
||||
<Collapsible title="투자 메모" subtitle={subtitle} storageKey="note">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value.slice(0, NOTE_MAX_LEN))}
|
||||
placeholder="이 종목에 대한 관전 포인트, 진입/청산 근거 등을 적어두세요."
|
||||
rows={4}
|
||||
className="w-full resize-y rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1.5 text-[12px] leading-relaxed text-zinc-100 outline-none focus:border-zinc-600"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] tabular-nums text-zinc-500">
|
||||
{remaining.toLocaleString()}자 남음
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{feedback && (
|
||||
<span className="text-[10px] text-zinc-400" aria-live="polite">
|
||||
{feedback}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-800/80 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
178
web/components/InvestorCumulative.tsx
Normal file
178
web/components/InvestorCumulative.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
// 투자자별 누적 순매수 미니 차트.
|
||||
//
|
||||
// 토스 종목 상세의 "투자자별 매매" 탭에서 인상이 강한 위젯 — 외국인/기관/개인
|
||||
// 각각의 N일 누적 순매수를 작은 라인으로 보여줘서 "누가 모으고 누가 분배하고 있는가"
|
||||
// 를 한 눈에 잡게 한다.
|
||||
//
|
||||
// 데이터 소스는 ChartPayload.trading_value (이미 페이지에 있음) 그대로. 백엔드 호출 0.
|
||||
// 단위: 원 → 억원(1e8) 환산.
|
||||
//
|
||||
// 색은 한국 컬러 — 누적 +면 빨강(매수 우위), -면 파랑(매도 우위). 라인 자체 색은
|
||||
// 주체별로 고정해서 비교 가능하게 (외국인=amber, 기관=cyan, 개인=violet).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { TradingValuePoint } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
type Window = 20 | 60 | 120;
|
||||
|
||||
const WINDOW_OPTIONS: { v: Window; label: string }[] = [
|
||||
{ v: 20, label: "20일" },
|
||||
{ v: 60, label: "60일" },
|
||||
{ v: 120, label: "120일" },
|
||||
];
|
||||
|
||||
type Subject = "foreign" | "institution" | "individual";
|
||||
|
||||
const SUBJECTS: { key: Subject; label: string; color: string; field: keyof TradingValuePoint }[] = [
|
||||
{ key: "foreign", label: "외국인", color: "#f59e0b", field: "foreign_net" },
|
||||
{ key: "institution", label: "기관", color: "#22d3ee", field: "institution_net" },
|
||||
{ key: "individual", label: "개인", color: "#a78bfa", field: "individual_net" },
|
||||
];
|
||||
|
||||
export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) {
|
||||
const [win, setWin] = useState<Window>(20);
|
||||
|
||||
// 데이터가 충분치 않으면 window 를 줄여서라도 그릴 수 있게.
|
||||
const effectiveWin = Math.min(win, data?.length ?? 0);
|
||||
|
||||
const series = useMemo(() => {
|
||||
if (!data || data.length === 0) return null;
|
||||
const slice = data.slice(-effectiveWin);
|
||||
// 각 주체별 누적 순매수 (원 단위), 0 부터 시작.
|
||||
const out: Record<Subject, number[]> = {
|
||||
foreign: [0],
|
||||
institution: [0],
|
||||
individual: [0],
|
||||
};
|
||||
for (const p of slice) {
|
||||
for (const s of SUBJECTS) {
|
||||
const v = (p[s.field] as number | null) ?? 0;
|
||||
const prev = out[s.key][out[s.key].length - 1];
|
||||
out[s.key].push(prev + v);
|
||||
}
|
||||
}
|
||||
return { slice, out };
|
||||
}, [data, effectiveWin]);
|
||||
|
||||
if (!series) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
투자자별 누적 데이터 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader
|
||||
title="투자자별 누적 순매수"
|
||||
right={
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
{WINDOW_OPTIONS.map((w) => {
|
||||
const on = win === w.v;
|
||||
return (
|
||||
<button
|
||||
key={w.v}
|
||||
type="button"
|
||||
onClick={() => setWin(w.v)}
|
||||
className={`rounded-full border px-2 py-0.5 transition ${
|
||||
on
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={on}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2.5">
|
||||
{SUBJECTS.map((s) => (
|
||||
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-[10px] text-zinc-500">
|
||||
단위: 억원 · 마지막 {effectiveWin}일 누적 (시작 0)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 한 주체에 대한 라벨 + 누적 라인 + 끝 값.
|
||||
// values: 길이 N+1 (시작 0 포함). viewBox 좌표는 0..100 / 0..30.
|
||||
function SubjectRow({
|
||||
label,
|
||||
color,
|
||||
values,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
values: number[];
|
||||
}) {
|
||||
if (values.length < 2) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
|
||||
<span className="w-10 text-zinc-400">{label}</span>
|
||||
<span>—</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const span = hi - lo || 1;
|
||||
const w = 100;
|
||||
const h = 30;
|
||||
const n = values.length;
|
||||
const path = values
|
||||
.map((v, i) => {
|
||||
const x = (i / (n - 1)) * w;
|
||||
// 0 이 위로 가도록 y 뒤집기. 패딩 1px.
|
||||
const y = h - 1 - ((v - lo) / span) * (h - 2);
|
||||
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
// 기준선 (시작 0) 위치.
|
||||
const baseY = h - 1 - ((0 - lo) / span) * (h - 2);
|
||||
const end = values[values.length - 1];
|
||||
const endEok = end / 1e8;
|
||||
const tone =
|
||||
endEok === 0
|
||||
? "text-zinc-400"
|
||||
: endEok > 0
|
||||
? "text-rose-300"
|
||||
: "text-sky-300";
|
||||
const sign = endEok > 0 ? "+" : "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-10 shrink-0 text-[11px] text-zinc-400">{label}</span>
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-7 flex-1"
|
||||
aria-hidden
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
y1={baseY}
|
||||
x2={w}
|
||||
y2={baseY}
|
||||
stroke="#3f3f46"
|
||||
strokeDasharray="2 2"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<path d={path} fill="none" stroke={color} strokeWidth={1.2} strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className={`w-16 shrink-0 text-right text-[11px] tabular-nums ${tone}`}>
|
||||
{sign}
|
||||
{endEok.toLocaleString(undefined, { maximumFractionDigits: 0 })}억
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
web/components/MarketsOverview.tsx
Normal file
161
web/components/MarketsOverview.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
// 시장 overview: 등락 상위 / 등락 하위 / 거래대금 상위 / 거래량 상위 4 컬럼.
|
||||
// /api/markets/movers 응답을 그대로 표 형태로 렌더.
|
||||
// 거래대금 = close × volume, 단위 억원. 토스 톤의 핵심 랭킹.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Mover, type MoversResponse } from "../lib/api";
|
||||
|
||||
export function MarketsOverview() {
|
||||
const [data, setData] = useState<MoversResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.movers("ALL", 10)
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (err)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
시장 overview 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
시장 overview 로딩 중…
|
||||
</div>
|
||||
);
|
||||
|
||||
// 아직 ohlcv_daily 가 비어 있으면 모든 리스트가 빈 배열로 옴 — 안내만.
|
||||
const empty =
|
||||
!data.gainers.length &&
|
||||
!data.losers.length &&
|
||||
!data.by_volume.length &&
|
||||
!data.by_trading_value.length;
|
||||
if (empty)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
시장 데이터 준비 중. ohlcv_daily 가 채워지면 자동으로 표시됩니다.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MoverList title="등락 상위" items={data.gainers} />
|
||||
<MoverList title="등락 하위" items={data.losers} />
|
||||
<MoverList
|
||||
title="거래대금 상위"
|
||||
items={data.by_trading_value}
|
||||
metric="trading_value"
|
||||
/>
|
||||
<MoverList title="거래량 상위" items={data.by_volume} metric="volume" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Metric = "price" | "volume" | "trading_value";
|
||||
|
||||
function MoverList({
|
||||
title,
|
||||
items,
|
||||
metric = "price",
|
||||
}: {
|
||||
title: string;
|
||||
items: Mover[];
|
||||
metric?: Metric;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-zinc-300">{title}</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-[11px] text-zinc-500">데이터 없음</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((m, i) => (
|
||||
<MoverRow key={m.code} rank={i + 1} m={m} metric={metric} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoverRow({
|
||||
rank,
|
||||
m,
|
||||
metric,
|
||||
}: {
|
||||
rank: number;
|
||||
m: Mover;
|
||||
metric: Metric;
|
||||
}) {
|
||||
const pct = m.pct_change;
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
|
||||
let primary: string;
|
||||
if (metric === "volume") {
|
||||
primary = fmtVol(m.volume);
|
||||
} else if (metric === "trading_value") {
|
||||
primary = fmtKrw(m.trading_value ?? null);
|
||||
} else {
|
||||
primary =
|
||||
m.close != null
|
||||
? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—";
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={`/${m.code}`}
|
||||
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-zinc-500">{rank}</span>
|
||||
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right tabular-nums">
|
||||
<div className="text-zinc-100">{primary}</div>
|
||||
<div className={`text-[10px] ${tone}`}>
|
||||
{pct != null ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtVol(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// 거래대금 원 → 억/조 표기. 토스에서 가장 흔한 단위.
|
||||
function fmtKrw(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}조`;
|
||||
if (n >= 1e8) return `${Math.round(n / 1e8).toLocaleString()}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type MetricsResponse } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
export function MetricsPanel({ code }: { code: string }) {
|
||||
const [m, setM] = useState<MetricsResponse | null>(null);
|
||||
@@ -36,7 +37,7 @@ export function MetricsPanel({ code }: { code: string }) {
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">최근 30일 모델 성능</div>
|
||||
<CardHeader title="최근 30일 모델 성능" />
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-zinc-500">
|
||||
<tr>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
// 토스증권 뉴스 카드 스타일을 흉내낸 리스트.
|
||||
//
|
||||
// 백엔드가 OG 썸네일은 아직 안 주므로 텍스트 카드 + 감성 칩 + 출처 배지만.
|
||||
// 썸네일은 백엔드에 og_image 컬럼이 들어오면 추가.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type NewsResponse } from "../lib/api";
|
||||
import { api, type NewsItem, type NewsResponse } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
export function NewsList({ code }: { code: string }) {
|
||||
const [data, setData] = useState<NewsResponse | null>(null);
|
||||
@@ -29,44 +35,80 @@ export function NewsList({ code }: { code: string }) {
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">최근 뉴스/공시</div>
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
<CardHeader title="뉴스 · 공시" subtitle={`최근 ${data.items.length}건`} />
|
||||
<ul className="space-y-2">
|
||||
{data.items.map((n, i) => (
|
||||
<li key={i} className="py-2">
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block hover:bg-zinc-800/40"
|
||||
>
|
||||
<div className="text-sm text-zinc-100 line-clamp-2">{n.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{n.source}</span>
|
||||
{n.published_at && <span>· {formatDate(n.published_at)}</span>}
|
||||
{n.sentiment_label && (
|
||||
<span className={sentimentColor(n.sentiment_label)}>
|
||||
· {n.sentiment_label} {n.sentiment_score != null ? `(${n.sentiment_score.toFixed(2)})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<NewsCard key={i} n={n} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sentimentColor(l: string): string {
|
||||
if (l === "positive") return "text-emerald-400";
|
||||
if (l === "negative") return "text-red-400";
|
||||
return "text-zinc-400";
|
||||
function NewsCard({ n }: { n: NewsItem }) {
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-md border border-transparent p-2 transition hover:border-zinc-700 hover:bg-zinc-800/40"
|
||||
>
|
||||
<div className="text-sm leading-snug text-zinc-100 line-clamp-2">{n.title}</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<SourceBadge source={n.source} />
|
||||
{n.published_at && (
|
||||
<span className="text-zinc-500">{formatRelative(n.published_at)}</span>
|
||||
)}
|
||||
{n.sentiment_label && (
|
||||
<SentimentChip label={n.sentiment_label} score={n.sentiment_score} />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
function SourceBadge({ source }: { source: string }) {
|
||||
return (
|
||||
<span className="rounded-sm bg-zinc-800 px-1.5 py-0.5 text-zinc-300">{source}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SentimentChip({
|
||||
label,
|
||||
score,
|
||||
}: {
|
||||
label: string;
|
||||
score: number | null;
|
||||
}) {
|
||||
// KR 관행: 긍정=빨강, 부정=파랑 (StockChart 와 톤 일치)
|
||||
const tone =
|
||||
label === "positive"
|
||||
? "bg-rose-500/15 text-rose-300"
|
||||
: label === "negative"
|
||||
? "bg-sky-500/15 text-sky-300"
|
||||
: "bg-zinc-700/40 text-zinc-400";
|
||||
const text =
|
||||
label === "positive" ? "긍정" : label === "negative" ? "부정" : "중립";
|
||||
return (
|
||||
<span className={`rounded-sm px-1.5 py-0.5 ${tone}`}>
|
||||
{text}
|
||||
{score != null && ` ${score >= 0 ? "+" : ""}${score.toFixed(2)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const now = Date.now();
|
||||
const diffMin = Math.round((now - d.getTime()) / 60000);
|
||||
if (diffMin < 1) return "방금";
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffMin < 60 * 24) return `${Math.round(diffMin / 60)}시간 전`;
|
||||
if (diffMin < 60 * 24 * 7) return `${Math.round(diffMin / (60 * 24))}일 전`;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
|
||||
200
web/components/OrderbookPanel.tsx
Normal file
200
web/components/OrderbookPanel.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
// 호가창 — 10단계 매도/매수 호가 + 잔량. Toss 톤의 한 줄 한 줄 막대 그래프.
|
||||
// /api/orderbook/{code} 폴링 (10s). 키 없으면 안내 카드.
|
||||
//
|
||||
// 표시 규칙:
|
||||
// - 위쪽: 매도호가 10개 (가장 낮은 askp1 이 맨 아래, 즉 현재가에 인접)
|
||||
// → 그래서 화면상 askp10 → askp1 (위에서 아래로 가격 내림차순) 으로 렌더
|
||||
// - 가운데 굵게: 현재가 + 전일대비 + %
|
||||
// - 아래쪽: 매수호가 10개 (가장 높은 bidp1 이 맨 위, bidp10 이 맨 아래)
|
||||
// - 잔량 막대: 매도/매수 각각의 단계별 최대잔량 기준 비율로 색상 막대.
|
||||
// - 색상: 매도=파랑(하락 톤), 매수=빨강(상승 톤) — 한국 거래소 관행.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type OrderbookLevel, type OrderbookResponse } from "../lib/api";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
// 1D(10분봉) 모드일 때만 sticky 로 띄움 — 종목 페이지가 길어 스크롤해도 매수/매도 호가가
|
||||
// 시야에 남도록. 일봉 이상에선 호가의 실시간 가치가 낮아 일반 흐름으로 두는 게 사이드바
|
||||
// 다른 패널(PriceTargets/AlertsPanel/InvestmentNote/RelatedStocks) 의 위치 안정성을 해치지
|
||||
// 않음.
|
||||
//
|
||||
// sticky + collapsible 조합: Collapsible 의 outer 래퍼 자체에 sticky 를 걸어두면 접혔을
|
||||
// 때는 헤더 한 줄(호가 + ts) 만 상단에 떠 있고, 펼치면 본문 포함 전체가 시야에 남는다.
|
||||
// — 사용자가 차트/지표 영역을 길게 스크롤하다 호가 보고 싶을 때 늘 접근 가능.
|
||||
sticky?: boolean;
|
||||
};
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
export function OrderbookPanel({ code, sticky = false }: Props) {
|
||||
const [data, setData] = useState<OrderbookResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
api
|
||||
.orderbook(code)
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setData(r);
|
||||
setErr(null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
load();
|
||||
const h = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(h);
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
// 호가는 사이드바에서 정보 밀도/시의성 모두 가장 높음 → 기본 펼침 유지.
|
||||
// 사용자가 접고 싶다면 헤더 클릭 한 번 + localStorage 영속.
|
||||
return (
|
||||
<Collapsible
|
||||
title="호가"
|
||||
subtitle={data?.ts ? `${data.ts} · 10초 갱신` : "10초 갱신"}
|
||||
storageKey="orderbook"
|
||||
className={
|
||||
sticky
|
||||
? "sticky top-4 rounded-md border border-zinc-800 bg-zinc-900/80 backdrop-blur"
|
||||
: "rounded-md border border-zinc-800 bg-zinc-900/40"
|
||||
}
|
||||
>
|
||||
{loading && !data && (
|
||||
<div className="py-6 text-center text-[11px] text-zinc-500">불러오는 중…</div>
|
||||
)}
|
||||
|
||||
{err && !data && (
|
||||
<div className="rounded border border-rose-800/40 bg-rose-900/10 p-2 text-[11px] text-rose-300">
|
||||
호가 로딩 실패: {err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.status === "skipped_missing_key" && (
|
||||
<div className="rounded border border-amber-800/40 bg-amber-900/10 p-2 text-[11px] text-amber-200">
|
||||
KIS API 키가 설정되지 않아 호가를 표시할 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.status === "ok" && <OrderbookTable data={data} />}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderbookTable({ data }: { data: OrderbookResponse }) {
|
||||
// KIS 가 항상 10단계를 다 주진 않을 수 있어 정렬 강제.
|
||||
// 매도: level 오름차순 (1=가장 낮은 매도) → 화면은 level 내림차순으로 표시.
|
||||
// 매수: level 오름차순 (1=가장 높은 매수) → 화면은 level 오름차순.
|
||||
const asks = [...data.asks].sort((a, b) => a.level - b.level);
|
||||
const bids = [...data.bids].sort((a, b) => a.level - b.level);
|
||||
const asksDesc = [...asks].reverse();
|
||||
|
||||
// 잔량 막대 정규화 — 매도/매수 통틀어 max 로 정규화하면 한쪽이 항상 미니 막대로 나옴.
|
||||
// 사이드별 max 로 각자 정규화.
|
||||
const askMax = Math.max(1, ...asks.map((x) => x.qty));
|
||||
const bidMax = Math.max(1, ...bids.map((x) => x.qty));
|
||||
|
||||
const change = data.prev_close_diff ?? 0;
|
||||
const tone =
|
||||
change > 0 ? "text-rose-400" : change < 0 ? "text-sky-400" : "text-zinc-400";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<ul className="space-y-0.5">
|
||||
{asksDesc.map((lv) => (
|
||||
<Row key={`a${lv.level}`} lv={lv} max={askMax} side="ask" />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="my-1 flex items-center justify-between rounded bg-zinc-800/60 px-2 py-1.5">
|
||||
<span className="text-[10px] text-zinc-400">현재가</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold tabular-nums text-zinc-50">
|
||||
{data.current != null ? data.current.toLocaleString() : "—"}
|
||||
</span>
|
||||
{data.prev_close_diff != null && (
|
||||
<span className={`text-[11px] tabular-nums ${tone}`}>
|
||||
{change > 0 ? "+" : ""}
|
||||
{change.toLocaleString()}
|
||||
{data.prev_close_pct != null && (
|
||||
<span className="ml-0.5">
|
||||
({data.prev_close_pct > 0 ? "+" : ""}
|
||||
{data.prev_close_pct.toFixed(2)}%)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{bids.map((lv) => (
|
||||
<Row key={`b${lv.level}`} lv={lv} max={bidMax} side="bid" />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-1.5 flex items-center justify-between border-t border-zinc-800 pt-1.5 text-[10px] text-zinc-500">
|
||||
<span>매도 합 {data.ask_total.toLocaleString()}</span>
|
||||
<span>매수 합 {data.bid_total.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
lv,
|
||||
max,
|
||||
side,
|
||||
}: {
|
||||
lv: OrderbookLevel;
|
||||
max: number;
|
||||
side: "ask" | "bid";
|
||||
}) {
|
||||
const pct = Math.min(100, (lv.qty / max) * 100);
|
||||
// ask=파랑(하락 톤), bid=빨강(상승 톤). 잔량 막대는 잔량 셀 안에서 자라남:
|
||||
// - ask 행: 가격(좌) · 잔량(우) — 막대는 잔량 셀 오른쪽 → 왼쪽 (수치쪽으로) 자라남
|
||||
// - bid 행: 잔량(좌) · 가격(우) — 막대는 잔량 셀 왼쪽 → 오른쪽 자라남
|
||||
const barColor = side === "ask" ? "bg-sky-500/20" : "bg-rose-500/20";
|
||||
const priceColor = side === "ask" ? "text-sky-300" : "text-rose-300";
|
||||
|
||||
const priceCell = (
|
||||
<span className={`tabular-nums ${priceColor}`}>{lv.price.toLocaleString()}</span>
|
||||
);
|
||||
const qtyCell = (
|
||||
<span className="relative inline-block w-full overflow-hidden rounded-sm py-0.5">
|
||||
<span
|
||||
className={`absolute inset-y-0 ${side === "ask" ? "right-0" : "left-0"} ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className={`relative z-10 block tabular-nums text-zinc-300 ${
|
||||
side === "ask" ? "text-right" : "text-left"
|
||||
} px-1.5`}
|
||||
>
|
||||
{lv.qty.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="grid grid-cols-[12px_64px_1fr] items-center gap-1.5 text-[11px]">
|
||||
<span className="text-[9px] text-zinc-600">{lv.level}</span>
|
||||
<span className="text-right">{priceCell}</span>
|
||||
{qtyCell}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
162
web/components/PeerComparePanel.tsx
Normal file
162
web/components/PeerComparePanel.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
// 동종업종(테마) 비교 패널 — 토스의 "동일업종 비교" 톤.
|
||||
// /api/themes/peer/{code} 한 번 호출로 (테마 라벨, 본인 수익률, 평균, 상/하위 N) 모두 받음.
|
||||
// 테마 무소속 종목은 패널을 통째로 숨김.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
export function PeerComparePanel({ code }: { code: string }) {
|
||||
const [data, setData] = useState<PeerCompareResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.peerCompare(code, 21, 5)
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
|
||||
동종업종 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
동종업종 로딩 중…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 테마 무소속 → 패널 숨김
|
||||
if (data.themes.length === 0) return null;
|
||||
|
||||
const self = data.self_pct;
|
||||
const avg = data.peer_avg_pct;
|
||||
const diff = self != null && avg != null ? self - avg : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader
|
||||
title="동종업종 비교"
|
||||
subtitle={`최근 ${data.window}거래일 · peer ${data.peer_count ?? 0}종목`}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{data.themes.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`/themes/${t.slug}`}
|
||||
className="rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[11px] text-zinc-300 hover:border-zinc-500"
|
||||
>
|
||||
#{t.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-3 gap-2 text-center">
|
||||
<CompareTile label="이 종목" pct={self} bold />
|
||||
<CompareTile label="업종 평균" pct={avg} />
|
||||
<CompareTile label="격차" pct={diff} sign />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<PeerList title="상위 5종목" items={data.top} />
|
||||
<PeerList title="하위 5종목" items={data.bottom} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareTile({
|
||||
label,
|
||||
pct,
|
||||
bold = false,
|
||||
sign = false,
|
||||
}: {
|
||||
label: string;
|
||||
pct: number | null;
|
||||
bold?: boolean;
|
||||
sign?: boolean;
|
||||
}) {
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
const text =
|
||||
pct == null
|
||||
? "—"
|
||||
: sign
|
||||
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%p`
|
||||
: `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2">
|
||||
<div className="text-[11px] text-zinc-500">{label}</div>
|
||||
<div
|
||||
className={`mt-1 tabular-nums ${tone} ${
|
||||
bold ? "text-base font-semibold" : "text-sm font-medium"
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeerList({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: PeerCompareItem[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] text-zinc-500">{title}</div>
|
||||
<ul className="divide-y divide-zinc-800 rounded-md border border-zinc-800">
|
||||
{items.length === 0 && (
|
||||
<li className="px-2 py-2 text-xs text-zinc-500">데이터 없음</li>
|
||||
)}
|
||||
{items.map((it) => {
|
||||
const tone =
|
||||
it.pct_change == null || it.pct_change === 0
|
||||
? "text-zinc-400"
|
||||
: it.pct_change > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<li key={it.code}>
|
||||
<Link
|
||||
href={`/${it.code}`}
|
||||
className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs hover:bg-zinc-800/50"
|
||||
>
|
||||
<span className="truncate text-zinc-200">{it.name}</span>
|
||||
<span className={`shrink-0 tabular-nums ${tone}`}>
|
||||
{it.pct_change == null
|
||||
? "—"
|
||||
: `${it.pct_change >= 0 ? "+" : ""}${it.pct_change.toFixed(2)}%`}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
web/components/PeriodReturns.tsx
Normal file
53
web/components/PeriodReturns.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
// 기간별 수익률 패널 — chart.ohlcv 에서 직접 계산.
|
||||
// 토스 종목 페이지의 "기간 수익률" 박스 모사. 1주/1개월/3개월/6개월/1년/전체.
|
||||
// 거래일 기준 근사: 1주≈5, 1개월≈21, 3개월≈63, 6개월≈126, 1년≈252.
|
||||
|
||||
import type { OhlcvPoint } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
import { ReturnBucket } from "./ReturnBucket";
|
||||
|
||||
const BUCKETS: { label: string; days: number }[] = [
|
||||
{ label: "1주", days: 5 },
|
||||
{ label: "1개월", days: 21 },
|
||||
{ label: "3개월", days: 63 },
|
||||
{ label: "6개월", days: 126 },
|
||||
{ label: "1년", days: 252 },
|
||||
];
|
||||
|
||||
export function PeriodReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
|
||||
const closes = ohlcv
|
||||
.map((p) => p.close)
|
||||
.filter((c): c is number => c != null);
|
||||
if (closes.length < 2) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
기간 수익률 데이터 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const last = closes[closes.length - 1];
|
||||
const buckets = BUCKETS.map((b) => {
|
||||
const idx = closes.length - 1 - b.days;
|
||||
if (idx < 0) return { ...b, pct: null as number | null };
|
||||
const base = closes[idx];
|
||||
return { ...b, pct: base ? ((last - base) / base) * 100 : null };
|
||||
});
|
||||
const all =
|
||||
closes[0] != null && closes[0] !== 0
|
||||
? ((last - closes[0]) / closes[0]) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader title="기간 수익률" subtitle="차트 구간 기준" />
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||
{buckets.map((b) => (
|
||||
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
|
||||
))}
|
||||
<ReturnBucket label="전체" pct={all} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
web/components/PeriodTabs.tsx
Normal file
75
web/components/PeriodTabs.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartInterval } from "../lib/api";
|
||||
|
||||
// 7-탭 기간 셀렉터. 각 탭이 (interval, days) 쌍으로 풀린다.
|
||||
// 1D 만 10분봉, 5Y/MAX 는 자동으로 주봉/월봉 사용 (포인트 수 폭주 방지).
|
||||
|
||||
export type Period = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y" | "ALL";
|
||||
|
||||
type PeriodSpec = { id: Period; label: string; interval: ChartInterval; days: number };
|
||||
|
||||
const PERIODS: PeriodSpec[] = [
|
||||
{ id: "1D", label: "1일", interval: "10m", days: 1 },
|
||||
{ id: "1W", label: "1주", interval: "1d", days: 7 },
|
||||
{ id: "1M", label: "1달", interval: "1d", days: 30 },
|
||||
{ id: "3M", label: "3달", interval: "1d", days: 90 },
|
||||
{ id: "1Y", label: "1년", interval: "1d", days: 365 },
|
||||
{ id: "5Y", label: "5년", interval: "1w", days: 365 * 5 },
|
||||
{ id: "ALL", label: "최대", interval: "1mo", days: 365 * 20 },
|
||||
];
|
||||
|
||||
export function periodSpec(id: Period): PeriodSpec {
|
||||
return PERIODS.find((p) => p.id === id) ?? PERIODS[2];
|
||||
}
|
||||
|
||||
// ←/→ 키 네비게이션용 — 양끝 clamp (wrap-around 안 함, 사용자가 끝에 갇혔는지 인지 가능).
|
||||
export function periodNeighbor(id: Period, dir: -1 | 1): Period {
|
||||
const idx = PERIODS.findIndex((p) => p.id === id);
|
||||
if (idx < 0) return id;
|
||||
const next = idx + dir;
|
||||
if (next < 0 || next >= PERIODS.length) return id;
|
||||
return PERIODS[next].id;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: Period;
|
||||
onChange: (id: Period, interval: ChartInterval, days: number) => void;
|
||||
};
|
||||
|
||||
export function PeriodTabs({ value, onChange }: Props) {
|
||||
// 활성 탭 스타일:
|
||||
// 이전엔 `bg-zinc-700` 만 사용해 헤더의 다른 zinc-톤 버튼(비교/공유/Star) 과 색이 거의 동일,
|
||||
// 페이지에서 "지금 어느 기간이 선택돼 있는지" 한눈에 안 들어왔음.
|
||||
// 한국 컬러 상승 톤(rose-500) 으로 칠해서 zinc 버튼군 사이에서 즉시 식별. 다른 모듈의 활성/positive
|
||||
// 상태(목표가 라인, 등락 양수 등) 도 같은 rose 계열이라 톤 일관성 유지.
|
||||
// ARIA: 이건 tabpanel 을 가진 진짜 탭이 아니라 차트 기간 segmented control 임.
|
||||
// role=tablist/tab 을 붙이면 스크린리더가 aria-controls·roving tabIndex·Arrow focus 이동 같은
|
||||
// ARIA 탭 패턴을 기대하는데 여기엔 그 인프라가 없음 (←/→ 는 페이지 전역 단축키로 처리). 단순
|
||||
// 버튼 그룹 + aria-pressed 가 정확한 표현.
|
||||
return (
|
||||
<div
|
||||
aria-label="기간 선택"
|
||||
className="inline-flex rounded-full border border-zinc-800 bg-zinc-900 p-1 text-xs"
|
||||
>
|
||||
{PERIODS.map((p) => {
|
||||
const active = value === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(p.id, p.interval, p.days)}
|
||||
className={
|
||||
active
|
||||
? "rounded-full bg-rose-500 px-3 py-1 font-semibold text-white shadow-sm shadow-rose-500/40"
|
||||
: "rounded-full px-3 py-1 text-zinc-400 hover:text-zinc-100"
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,16 +42,26 @@ function normalizeFromPredictResponse(
|
||||
};
|
||||
}
|
||||
|
||||
// 15일/30일/1년 세 프리셋. 각각 그 구간 안에 6~7 step 을 깔아서 차트 캔들이
|
||||
// 너무 듬성하지 않게 한다. 백엔드는 1..252 거래일 horizon 을 허용 (predict.py 참조).
|
||||
const PREDICT_PRESETS: { id: string; label: string; horizons: number[] }[] = [
|
||||
{ id: "15d", label: "15일", horizons: [1, 3, 5, 7, 10, 15] },
|
||||
{ id: "30d", label: "30일", horizons: [1, 5, 10, 15, 20, 25, 30] },
|
||||
{ id: "1y", label: "1년", horizons: [5, 15, 30, 60, 120, 180, 240] },
|
||||
];
|
||||
|
||||
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(1); // 30일 기본
|
||||
|
||||
async function runPredict() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await api.predict(code);
|
||||
const horizons = PREDICT_PRESETS[presetIdx].horizons;
|
||||
const r = await api.predict(code, horizons);
|
||||
const normalized = normalizeFromPredictResponse(code, r);
|
||||
setPred(normalized);
|
||||
onResult(normalized);
|
||||
@@ -66,22 +76,55 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-200">예측 (Chronos + LightGBM 앙상블)</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
클릭한 종목은 자동 저장 후 다음 거래일 장 종료 시 실제 가격과 비교됩니다.
|
||||
결과는 차트에 옅은 색 캔들로 이어 붙고, 꼬리 길이가 신뢰구간 폭입니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={runPredict}
|
||||
disabled={loading}
|
||||
className="rounded-md bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
|
||||
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "예측 중…" : pred?.found ? "다시 예측" : "예상차트 보기"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-zinc-500">예측 기간:</span>
|
||||
{PREDICT_PRESETS.map((p, i) => {
|
||||
const on = presetIdx === i;
|
||||
return (
|
||||
// segmented control — PeriodTabs / InvestorCumulative window / SMA·RSI·MACD 토글과
|
||||
// 같은 패턴. type="button" 으로 폼 submit 기본값 사고 차단, aria-pressed 로 스크린
|
||||
// 리더에 현재 선택 노출. 시각 토큰은 PredictionPanel 자체 톤(bg-zinc-700 solid)
|
||||
// 유지 — 본문 카드 영역 안 primary 보조 컨트롤이라 툴바용 zinc-800/80 보다 한
|
||||
// 단계 도드라지는 게 의도. 패딩(px-3 py-1) 도 옆 rose-600 실행 버튼과 시각
|
||||
// 무게 균형을 위해 툴바 토글(px-2 py-0.5) 보다 큼.
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setPresetIdx(i)}
|
||||
aria-pressed={on}
|
||||
className={
|
||||
on
|
||||
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
|
||||
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{presetIdx === 2 && (
|
||||
<span className="ml-1 text-[11px] text-amber-400">
|
||||
※ 30거래일 너머는 모델 학습 범위 밖 — 참고용
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-3 text-xs text-red-400">에러: {err}</div>}
|
||||
|
||||
{pred?.found ? (
|
||||
@@ -90,54 +133,56 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
|
||||
기준일 {pred.base_date} · 기준종가{" "}
|
||||
{pred.base_close != null ? pred.base_close.toLocaleString() : "-"}
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th className="py-1">+거래일</th>
|
||||
<th>매칭일</th>
|
||||
<th>방향</th>
|
||||
<th>P(up/flat/down)</th>
|
||||
<th>기대수익</th>
|
||||
<th>예측 종가</th>
|
||||
<th>q10~q90</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{steps.map((s) => (
|
||||
<tr key={s.horizon}>
|
||||
<td className="py-2">+{s.horizon}</td>
|
||||
<td className="text-xs text-zinc-400">{s.target_date}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
s.direction === "up"
|
||||
? "text-emerald-400"
|
||||
: s.direction === "down"
|
||||
? "text-red-400"
|
||||
: "text-zinc-300"
|
||||
}
|
||||
>
|
||||
{s.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-xs text-zinc-300">
|
||||
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
|
||||
</td>
|
||||
<td>{fmtSignedPct(s.expected_return)}</td>
|
||||
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
|
||||
<td className="text-xs text-zinc-400">
|
||||
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
|
||||
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
|
||||
</td>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-zinc-900/95 text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th className="py-1">+거래일</th>
|
||||
<th>매칭일</th>
|
||||
<th>방향</th>
|
||||
<th>P(up/flat/down)</th>
|
||||
<th>기대수익</th>
|
||||
<th>예측 종가</th>
|
||||
<th>q10~q90</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{steps.map((s) => (
|
||||
<tr key={s.horizon}>
|
||||
<td className="py-2">+{s.horizon}</td>
|
||||
<td className="text-xs text-zinc-400">{s.target_date}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
s.direction === "up"
|
||||
? "text-rose-400"
|
||||
: s.direction === "down"
|
||||
? "text-sky-400"
|
||||
: "text-zinc-300"
|
||||
}
|
||||
>
|
||||
{s.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-xs text-zinc-300">
|
||||
{fmtPct(s.prob_up)} / {fmtPct(s.prob_flat)} / {fmtPct(s.prob_down)}
|
||||
</td>
|
||||
<td>{fmtSignedPct(s.expected_return)}</td>
|
||||
<td>{s.point_close != null ? s.point_close.toLocaleString() : "-"}</td>
|
||||
<td className="text-xs text-zinc-400">
|
||||
{s.ci_low != null ? s.ci_low.toLocaleString() : "-"} ~{" "}
|
||||
{s.ci_high != null ? s.ci_high.toLocaleString() : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">
|
||||
아직 예측이 없습니다. <b>예상차트 보기</b> 버튼을 누르면 1·3·5거래일 후 예측을 생성하고
|
||||
차트에 점선으로 이어 붙입니다.
|
||||
아직 예측이 없습니다. <b>예상차트 보기</b> 를 누르면 선택한 기간의 예측 캔들이
|
||||
현재 차트에 옅은 색으로 이어 붙습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
128
web/components/PriceHero.tsx
Normal file
128
web/components/PriceHero.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
// 종목 페이지 상단 가격 헤더.
|
||||
// 표시: 종목명 / (코드·시장) / 현재가 / 전일대비 + 우측에 sparkline.
|
||||
// 한국 거래소 관행대로 상승=빨강, 하락=파랑.
|
||||
|
||||
import { Sparkline } from "./Sparkline";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
code: string;
|
||||
market: string;
|
||||
current: number | null;
|
||||
prev: number | null;
|
||||
asOf?: string | null;
|
||||
// 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시.
|
||||
series?: number[];
|
||||
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
|
||||
viewsToday?: number | null;
|
||||
// 오늘(또는 마지막 봉) 흐름 요약. page.tsx 에서 ohlcv 끝부분으로 계산해 전달.
|
||||
// null 이면 strip 자체를 숨김 (데이터 없음 + 레이아웃 jitter 방지).
|
||||
flow?: { open: number; high: number; low: number; volume: number } | null;
|
||||
};
|
||||
|
||||
// 거래량 한국식 단위 (억/만) — StockChart 의 fmtKVolume 과 같은 규칙. 둘 다 작아서
|
||||
// 별도 모듈로 빼지 않고 사용처에 인라인 유지.
|
||||
function fmtVolume(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "-";
|
||||
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function PriceHero({
|
||||
name,
|
||||
code,
|
||||
market,
|
||||
current,
|
||||
prev,
|
||||
asOf,
|
||||
series,
|
||||
viewsToday,
|
||||
flow,
|
||||
}: Props) {
|
||||
const change = current != null && prev != null ? current - prev : null;
|
||||
const pct = change != null && prev ? (change / prev) * 100 : null;
|
||||
const tone =
|
||||
change == null || change === 0
|
||||
? "text-zinc-400"
|
||||
: change > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
const arrow = change == null || change === 0 ? "—" : change > 0 ? "▲" : "▼";
|
||||
|
||||
return (
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{code} · {market}
|
||||
</div>
|
||||
<h1 className="mt-0.5 text-2xl font-bold tracking-tight text-zinc-50">
|
||||
{name}
|
||||
</h1>
|
||||
<div className="mt-3 flex items-baseline gap-3">
|
||||
<span className="text-4xl font-semibold tabular-nums text-zinc-50">
|
||||
{current != null ? current.toLocaleString() : "-"}
|
||||
</span>
|
||||
{change != null && pct != null && (
|
||||
<span className={`text-base font-medium tabular-nums ${tone}`}>
|
||||
{arrow} {Math.abs(change).toLocaleString()}{" "}
|
||||
({pct >= 0 ? "+" : ""}
|
||||
{pct.toFixed(2)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-zinc-500">
|
||||
{asOf && <span>기준 {asOf}</span>}
|
||||
{viewsToday != null && viewsToday > 0 && (
|
||||
<span className="rounded-full border border-zinc-700/70 bg-zinc-900/60 px-2 py-0.5 text-[11px] text-zinc-300">
|
||||
오늘 <span className="tabular-nums text-zinc-100">
|
||||
{viewsToday.toLocaleString()}
|
||||
</span>
|
||||
명이 봤어요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{flow && (
|
||||
// 오늘의 흐름 요약 — 시/고/저/거래량 4-KPI mini strip. 차트 hover 없이도 진입 즉시 보임.
|
||||
// 고/저 가격 자체에는 색을 칠하지 않음 (양수/음수 의미가 없으므로 톤이 거짓 신호가 됨).
|
||||
// 대신 라벨만 sky-300(저) / rose-300(고) 으로 살짝 칠해 직관 보조.
|
||||
<div className="mt-3 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[11px] text-zinc-400">
|
||||
<span>
|
||||
시{" "}
|
||||
<span className="tabular-nums text-zinc-200">
|
||||
{flow.open.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-rose-300">고</span>{" "}
|
||||
<span className="tabular-nums text-zinc-200">
|
||||
{flow.high.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-sky-300">저</span>{" "}
|
||||
<span className="tabular-nums text-zinc-200">
|
||||
{flow.low.toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
거래량{" "}
|
||||
<span className="tabular-nums text-zinc-200">
|
||||
{fmtVolume(flow.volume)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{series && series.length >= 2 && (
|
||||
<Sparkline
|
||||
values={series}
|
||||
stroke={change != null && change < 0 ? "#3b82f6" : "#ef4444"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
web/components/PriceTargets.tsx
Normal file
167
web/components/PriceTargets.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
// 목표가 / 손절가 시뮬레이터 (Toss "내 목표가" read-only 클론).
|
||||
// - 현재가 기준 변동률 미리 계산해서 표시
|
||||
// - 저장 시 localStorage 에만 — 차트 오버레이는 부모 page 가 같은 상태를 읽어 StockChart 에 전달
|
||||
// - 알림은 별도 AlertsPanel 담당, 여기는 시각화 + 손익 프리뷰만
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { readTargets, writeTargets, type Targets } from "../lib/targets";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
current: number | null;
|
||||
// 저장/해제 시 부모에 변경을 전달 — 차트에 즉시 반영하도록.
|
||||
onChange?: (t: Targets) => void;
|
||||
};
|
||||
|
||||
function parseNum(s: string): number | null {
|
||||
const cleaned = s.replace(/,/g, "").trim();
|
||||
if (!cleaned) return null;
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function pct(price: number | null, current: number | null): number | null {
|
||||
if (price == null || current == null || current <= 0) return null;
|
||||
return ((price - current) / current) * 100;
|
||||
}
|
||||
|
||||
export function PriceTargets({ code, current, onChange }: Props) {
|
||||
const [target, setTarget] = useState("");
|
||||
const [stop, setStop] = useState("");
|
||||
const [saved, setSaved] = useState<Targets>({});
|
||||
|
||||
// 종목 바뀌면 새로 로드. 입력창도 저장값으로 초기화.
|
||||
useEffect(() => {
|
||||
const t = readTargets(code);
|
||||
setSaved(t);
|
||||
setTarget(t.target != null ? String(t.target) : "");
|
||||
setStop(t.stop != null ? String(t.stop) : "");
|
||||
}, [code]);
|
||||
|
||||
const targetN = useMemo(() => parseNum(target), [target]);
|
||||
const stopN = useMemo(() => parseNum(stop), [stop]);
|
||||
const targetPct = pct(targetN, current);
|
||||
const stopPct = pct(stopN, current);
|
||||
|
||||
const dirty =
|
||||
(targetN ?? null) !== (saved.target ?? null) ||
|
||||
(stopN ?? null) !== (saved.stop ?? null);
|
||||
|
||||
const save = () => {
|
||||
const next: Targets = { target: targetN, stop: stopN };
|
||||
writeTargets(code, next);
|
||||
setSaved(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
const clear = () => {
|
||||
setTarget("");
|
||||
setStop("");
|
||||
const next: Targets = {};
|
||||
writeTargets(code, next);
|
||||
setSaved(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const fmtPct = (p: number | null): string =>
|
||||
p == null ? "" : `${p >= 0 ? "+" : ""}${p.toFixed(2)}%`;
|
||||
const pctCls = (p: number | null): string =>
|
||||
p == null ? "text-zinc-500" : p >= 0 ? "text-rose-400" : "text-sky-400";
|
||||
|
||||
// 접힌 상태에서도 유저가 어떤 값을 저장해뒀는지 한눈에 — 둘 다 없으면 InvestmentNote 와
|
||||
// 같은 톤의 "비공개 · 브라우저 저장" 안내. 저장값(savedN)이 아닌 dirty 입력값을 보여주면
|
||||
// 토글 사이클 사이에 안내가 깜빡여 혼란. saved 기준만 표시.
|
||||
const fmtNum = (n: number): string =>
|
||||
n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
const subtitle =
|
||||
saved.target != null && saved.stop != null
|
||||
? `목표 ${fmtNum(saved.target)} · 손절 ${fmtNum(saved.stop)}`
|
||||
: saved.target != null
|
||||
? `목표 ${fmtNum(saved.target)}`
|
||||
: saved.stop != null
|
||||
? `손절 ${fmtNum(saved.stop)}`
|
||||
: "비공개 · 브라우저 저장";
|
||||
|
||||
return (
|
||||
// defaultOpen=true — 입력 중인 텍스트는 Collapsible 의 hidden 본문 덕에 토글로 사라지지
|
||||
// 않지만, 처음 보는 사용자에겐 input 이 펴져 있어야 "여기서 목표가를 적는다" 가 자명.
|
||||
<Collapsible title="내 목표가" subtitle={subtitle} storageKey="targets">
|
||||
<div className="space-y-2">
|
||||
<Row
|
||||
label="목표가"
|
||||
color="bg-rose-400/80"
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
deltaText={fmtPct(targetPct)}
|
||||
deltaCls={pctCls(targetPct)}
|
||||
/>
|
||||
<Row
|
||||
label="손절가"
|
||||
color="bg-sky-400/80"
|
||||
value={stop}
|
||||
onChange={setStop}
|
||||
deltaText={fmtPct(stopPct)}
|
||||
deltaCls={pctCls(stopPct)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/80 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={saved.target == null && saved.stop == null && !target && !stop}
|
||||
className="rounded-md border border-zinc-800 px-2 py-1 text-[11px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
해제
|
||||
</button>
|
||||
</div>
|
||||
{current == null && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">현재가 로딩 중 — 변동률은 잠시 후 표시</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
color,
|
||||
value,
|
||||
onChange,
|
||||
deltaText,
|
||||
deltaCls,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
deltaText: string;
|
||||
deltaCls: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-[11px]">
|
||||
<span className={`inline-block h-2 w-2 rounded-sm ${color}`} aria-hidden />
|
||||
<span className="w-12 shrink-0 text-zinc-400">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="0"
|
||||
className="min-w-0 flex-1 rounded border border-zinc-800 bg-zinc-950/50 px-2 py-1 text-right tabular-nums text-zinc-100 outline-none focus:border-zinc-600"
|
||||
/>
|
||||
<span className={`w-16 shrink-0 text-right tabular-nums ${deltaCls}`}>
|
||||
{deltaText}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
22
web/components/PwaRegister.tsx
Normal file
22
web/components/PwaRegister.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
// 최소 서비스워커 등록.
|
||||
// - dev 에서도 등록한다: 이 사이트의 sw.js 는 fetch handler 가 없어 HMR/네트워크
|
||||
// 응답을 가로채지 않음 → HMR/캐시 꼬임 위험 0. 그리고 Docker 가 next dev 로
|
||||
// 돌고 있어 NODE_ENV 게이트를 두면 실제 배포에서 install 자격 자체가 안 잡힌다.
|
||||
// - 등록 실패는 silently 무시 (Safari private mode, http://192.168.x.x 같은 secure
|
||||
// context 아닌 호스트, 브라우저 정책 거부 등). PWA install 자격만 못 갖출 뿐 앱
|
||||
// 기능엔 영향 없음.
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
/* noop — 등록 실패 시 PWA 기능만 비활성. */
|
||||
});
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
129
web/components/RecentTiles.tsx
Normal file
129
web/components/RecentTiles.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
// 최근 본 종목 카드 그리드. localStorage 의 stockchart.recent 를 구독.
|
||||
// 각 코드별로 /api/chart 2일치 받아서 현재가/전일대비 표시.
|
||||
// 빈 리스트면 패널 자체를 안 그림.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { recent } from "../lib/recent";
|
||||
|
||||
type Tile = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
current: number | null;
|
||||
prev: number | null;
|
||||
};
|
||||
|
||||
export function RecentTiles() {
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const [tiles, setTiles] = useState<Tile[]>([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHydrated(true);
|
||||
const refresh = () => setCodes(recent.get());
|
||||
refresh();
|
||||
return recent.subscribe(refresh);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || codes.length === 0) {
|
||||
setTiles([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.allSettled(
|
||||
codes.map(async (code) => {
|
||||
const c = await api.getChart(code, 5, "1d");
|
||||
const valid = c.ohlcv.filter((p) => p.close != null);
|
||||
const last = valid[valid.length - 1] ?? null;
|
||||
const prev = valid.length >= 2 ? valid[valid.length - 2] : null;
|
||||
return {
|
||||
code,
|
||||
name: c.name,
|
||||
market: c.market,
|
||||
current: last?.close ?? null,
|
||||
prev: prev?.close ?? null,
|
||||
};
|
||||
}),
|
||||
).then((settled) => {
|
||||
if (!alive) return;
|
||||
setTiles(
|
||||
settled.map((r, i) => {
|
||||
if (r.status === "fulfilled") return r.value;
|
||||
return {
|
||||
code: codes[i],
|
||||
name: codes[i],
|
||||
market: "—",
|
||||
current: null,
|
||||
prev: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [codes, hydrated]);
|
||||
|
||||
if (!hydrated || codes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<div className="text-sm font-medium text-zinc-200">최근 본 종목</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => recent.clear()}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
모두 지우기
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
|
||||
{tiles.map((t) => (
|
||||
<Tile key={t.code} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ t }: { t: Tile }) {
|
||||
const change =
|
||||
t.current != null && t.prev != null ? t.current - t.prev : null;
|
||||
const pct =
|
||||
change != null && t.prev != null && t.prev !== 0
|
||||
? (change / t.prev) * 100
|
||||
: null;
|
||||
const tone =
|
||||
change == null || change === 0
|
||||
? "text-zinc-400"
|
||||
: change > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<Link
|
||||
href={`/${t.code}`}
|
||||
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-2.5 transition hover:border-zinc-600"
|
||||
>
|
||||
<div className="truncate text-xs font-medium text-zinc-100">{t.name}</div>
|
||||
<div className="mt-0.5 text-[10px] text-zinc-500">
|
||||
{t.code} · {t.market}
|
||||
</div>
|
||||
<div className="mt-1.5 text-xs tabular-nums text-zinc-100">
|
||||
{t.current != null
|
||||
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—"}
|
||||
</div>
|
||||
<div className={`text-[10px] tabular-nums ${tone}`}>
|
||||
{pct == null
|
||||
? " "
|
||||
: `${pct > 0 ? "+" : ""}${pct.toFixed(2)}%`}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
95
web/components/RelatedStocks.tsx
Normal file
95
web/components/RelatedStocks.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
// 같은 시장 내 거래량/유동성 기준 관련 종목 8개.
|
||||
// /api/markets/related/{code} 응답을 좁은 list 카드로 렌더.
|
||||
// Collapsible 로 감싸 — 모바일에서 사이드바 길이가 부담되면 사용자가 접어 둠.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type RelatedResponse } from "../lib/api";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
export function RelatedStocks({ code }: { code: string }) {
|
||||
const [data, setData] = useState<RelatedResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
setData(null);
|
||||
api
|
||||
.related(code, 8)
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
const subtitle = data?.market ? `${data.market} · 거래량 기준` : undefined;
|
||||
|
||||
return (
|
||||
// defaultOpen=false — 사이드바의 세 collapsible 중 정보 밀도가 가장 낮고 "이 종목 자체"
|
||||
// 와의 직접 연관도 약함(같은 시장의 다른 종목 리스트). 진입 즉시 접혀 있다가 사용자가
|
||||
// 필요할 때 펴는 게 자연스러움. 첫 토글 이후엔 localStorage 가 사용자 선호로 굳음.
|
||||
<Collapsible
|
||||
title="관련 종목"
|
||||
subtitle={subtitle}
|
||||
storageKey="related"
|
||||
defaultOpen={false}
|
||||
>
|
||||
{err && (
|
||||
<div className="text-xs text-zinc-500">관련 종목 로딩 실패: {err}</div>
|
||||
)}
|
||||
{!err && !data && (
|
||||
<div className="text-xs text-zinc-500">관련 종목 로딩 중…</div>
|
||||
)}
|
||||
{!err && data && !data.items.length && (
|
||||
<div className="text-xs text-zinc-500">
|
||||
같은 시장({data.market}) 데이터가 아직 부족합니다.
|
||||
</div>
|
||||
)}
|
||||
{!err && data && data.items.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{data.items.map((m) => {
|
||||
const pct = m.pct_change;
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<li key={m.code}>
|
||||
<Link
|
||||
href={`/${m.code}`}
|
||||
className="flex items-center justify-between gap-2 py-1.5 text-xs hover:bg-zinc-800/40"
|
||||
>
|
||||
<span className="min-w-0 truncate text-zinc-100">{m.name}</span>
|
||||
<span className="shrink-0 text-right tabular-nums">
|
||||
<span className="text-zinc-100">
|
||||
{m.close != null
|
||||
? m.close.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
<span className={`ml-2 ${tone}`}>
|
||||
{pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
28
web/components/ReturnBucket.tsx
Normal file
28
web/components/ReturnBucket.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// 수익률 미니 셀 — IntradayReturns(1D 10분봉 모드) 와 PeriodReturns(일봉 이상) 가 같은
|
||||
// 슬롯에서 모드 전환으로 갈아끼우므로 두 패널의 Bucket 톤/색 규칙은 반드시 동일해야 한다.
|
||||
// 예전엔 각 파일에 동일한 Bucket 이 중복 정의돼 있어 한쪽만 톤을 바꾸면 모드 전환 시
|
||||
// 시각적 점프가 생길 위험이 있었다. 한 곳으로 묶어 단일 source of truth 로 둔다.
|
||||
//
|
||||
// 한국식 컬러 규칙: 상승 = rose, 하락 = sky, 0/null = zinc (Toss 톤).
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
pct: number | null;
|
||||
};
|
||||
|
||||
export function ReturnBucket({ label, pct }: Props) {
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
|
||||
<div className="text-[11px] text-zinc-500">{label}</div>
|
||||
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
|
||||
{pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Symbol } from "../lib/api";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
|
||||
export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||
const [q, setQ] = useState("");
|
||||
@@ -22,7 +23,8 @@ export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||
setErr(null);
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const r = await api.search(term, seedOnly, 15);
|
||||
// with_sparkline=true: 백엔드가 최근 30 종가 + close + pct_change 동봉.
|
||||
const r = await api.search(term, seedOnly, 15, true);
|
||||
setItems(r.items);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
@@ -63,31 +65,61 @@ export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) {
|
||||
{items.length > 0 && (
|
||||
<ul className="mt-2 divide-y divide-zinc-800 rounded-md border border-zinc-800 bg-zinc-900/40">
|
||||
{items.map((it) => (
|
||||
<li key={it.code}>
|
||||
<Link
|
||||
href={`/${it.code}`}
|
||||
className="flex items-center justify-between px-4 py-3 text-sm hover:bg-zinc-800/70"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-100">
|
||||
{it.name}
|
||||
{it.is_seed && (
|
||||
<span className="ml-2 rounded-full bg-emerald-900/60 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
|
||||
SEED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{it.code} · {it.market}
|
||||
{it.sector ? ` · ${it.sector}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-zinc-500">→</span>
|
||||
</Link>
|
||||
</li>
|
||||
<SearchRow key={it.code} it={it} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchRow({ it }: { it: Symbol }) {
|
||||
const pct = it.pct_change ?? null;
|
||||
const tone =
|
||||
pct == null || pct === 0
|
||||
? "text-zinc-400"
|
||||
: pct > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
const stroke = pct != null && pct < 0 ? "#3b82f6" : "#ef4444";
|
||||
const series = it.sparkline ?? [];
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={`/${it.code}`}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3 text-sm hover:bg-zinc-800/70"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-zinc-100">
|
||||
{it.name}
|
||||
{it.is_seed && (
|
||||
<span className="ml-2 rounded-full bg-emerald-900/60 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
|
||||
SEED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{it.code} · {it.market}
|
||||
{it.sector ? ` · ${it.sector}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{series.length >= 2 && (
|
||||
<Sparkline values={series} stroke={stroke} width={80} height={28} />
|
||||
)}
|
||||
<div className="text-right tabular-nums">
|
||||
<div className="text-xs text-zinc-100">
|
||||
{it.close != null
|
||||
? it.close.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—"}
|
||||
</div>
|
||||
<div className={`text-[11px] ${tone}`}>
|
||||
{pct != null ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
121
web/components/SeedTiles.tsx
Normal file
121
web/components/SeedTiles.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
// 학습 대상 SEED 10종목 quick-access 타일.
|
||||
//
|
||||
// 토스 홈의 '인기 종목' 카드 톤. 각 타일이 자체적으로 /api/chart 를 2일치 받아
|
||||
// 현재가/전일대비 를 계산해서 표시. 10개 병렬 호출 — 백엔드는 캐시 친화적이므로 OK.
|
||||
//
|
||||
// 백엔드 신규 엔드포인트가 추가되면 (예: /api/markets/top-volume) 그쪽으로 갈아끼움.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
type Tile = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
current: number | null;
|
||||
prev: number | null;
|
||||
};
|
||||
|
||||
const SEED_TICKERS: Array<Omit<Tile, "current" | "prev">> = [
|
||||
{ code: "005930", name: "삼성전자", market: "KOSPI" },
|
||||
{ code: "000660", name: "SK하이닉스", market: "KOSPI" },
|
||||
{ code: "247540", name: "에코프로비엠", market: "KOSDAQ" },
|
||||
{ code: "042700", name: "한미반도체", market: "KOSPI" },
|
||||
{ code: "034020", name: "두산에너빌리티", market: "KOSPI" },
|
||||
{ code: "012450", name: "한화에어로스페이스", market: "KOSPI" },
|
||||
{ code: "329180", name: "HD현대중공업", market: "KOSPI" },
|
||||
{ code: "035420", name: "NAVER", market: "KOSPI" },
|
||||
{ code: "033780", name: "KT&G", market: "KOSPI" },
|
||||
{ code: "036460", name: "한국가스공사", market: "KOSPI" },
|
||||
];
|
||||
|
||||
export function SeedTiles() {
|
||||
const [tiles, setTiles] = useState<Tile[]>(
|
||||
SEED_TICKERS.map((t) => ({ ...t, current: null, prev: null })),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// 10 병렬 fetch. 한 종목 실패해도 다른 타일은 살린다.
|
||||
Promise.allSettled(
|
||||
SEED_TICKERS.map(async (t) => {
|
||||
const c = await api.getChart(t.code, 5, "1d");
|
||||
const valid = c.ohlcv.filter((p) => p.close != null);
|
||||
const last = valid[valid.length - 1] ?? null;
|
||||
const prev = valid.length >= 2 ? valid[valid.length - 2] : null;
|
||||
return {
|
||||
code: t.code,
|
||||
name: t.name,
|
||||
market: t.market,
|
||||
current: last?.close ?? null,
|
||||
prev: prev?.close ?? null,
|
||||
};
|
||||
}),
|
||||
).then((settled) => {
|
||||
if (!alive) return;
|
||||
const next: Tile[] = settled.map((r, i) => {
|
||||
if (r.status === "fulfilled") return r.value;
|
||||
return { ...SEED_TICKERS[i], current: null, prev: null };
|
||||
});
|
||||
setTiles(next);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<div className="text-sm font-medium text-zinc-200">학습 대상 10종목</div>
|
||||
<div className="text-[11px] text-zinc-500">현재가 · 전일대비</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
|
||||
{tiles.map((t) => (
|
||||
<TileCard key={t.code} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileCard({ t }: { t: Tile }) {
|
||||
const change =
|
||||
t.current != null && t.prev != null ? t.current - t.prev : null;
|
||||
const pct =
|
||||
change != null && t.prev != null && t.prev !== 0
|
||||
? (change / t.prev) * 100
|
||||
: null;
|
||||
const tone =
|
||||
change == null || change === 0
|
||||
? "text-zinc-400"
|
||||
: change > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
return (
|
||||
<Link
|
||||
href={`/${t.code}`}
|
||||
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-3 transition hover:border-zinc-600"
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-zinc-100">{t.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-zinc-500">
|
||||
{t.code} · {t.market}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tabular-nums text-zinc-100">
|
||||
{t.current != null
|
||||
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—"}
|
||||
</div>
|
||||
<div className={`text-[11px] tabular-nums ${tone}`}>
|
||||
{change == null
|
||||
? " "
|
||||
: `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
101
web/components/ShareButton.tsx
Normal file
101
web/components/ShareButton.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
// 종목 페이지 공유 버튼 — 현재 URL 을 공유 / 클립보드에 복사.
|
||||
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 시도 + 짧은 토스트).
|
||||
//
|
||||
// 동작 우선순위:
|
||||
// 1) navigator.share (모바일 native sheet — 성공 시 OS 시트가 피드백, 토스트 안 띄움)
|
||||
// 2) navigator.clipboard.writeText (HTTPS or localhost — 클립보드에 복사 + "링크 복사됨")
|
||||
// 3) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
|
||||
// 4) 모두 실패면 "복사 안됨" 안내
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Modern path — secure context 에서만 사용 가능.
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// 권한 거부 등 — fallback 시도.
|
||||
}
|
||||
}
|
||||
// Legacy fallback. document.execCommand 는 deprecated 지만 비-HTTPS LAN 환경 호환용.
|
||||
if (typeof document === "undefined") return false;
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ShareButton({ code, name }: Props) {
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedback) return;
|
||||
const h = window.setTimeout(() => setFeedback(null), 2000);
|
||||
return () => window.clearTimeout(h);
|
||||
}, [feedback]);
|
||||
|
||||
const onClick = async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = window.location.href;
|
||||
// Web Share API 우선 — 모바일에서 native sheet 뜸. 데스크탑/실패 시 클립보드로 폴백.
|
||||
const shareData = {
|
||||
title: name ? `${name} (${code})` : code,
|
||||
text: name ? `${name} 종목 정보` : code,
|
||||
url,
|
||||
};
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.share === "function"
|
||||
) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
return; // 사용자가 native sheet 에서 처리. 토스트 안 띄움.
|
||||
} catch {
|
||||
// AbortError(사용자 취소) 또는 미지원 — 클립보드로 폴백.
|
||||
}
|
||||
}
|
||||
const ok = await copyToClipboard(url);
|
||||
setFeedback(ok ? "링크 복사됨" : "복사 안됨");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 transition hover:border-zinc-500 hover:text-zinc-200"
|
||||
title="이 종목 페이지 공유"
|
||||
aria-label="이 종목 페이지 공유"
|
||||
>
|
||||
↗ 공유
|
||||
</button>
|
||||
{feedback && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute right-0 top-full z-20 mt-1 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-950/90 px-2 py-0.5 text-[10px] text-zinc-200 shadow"
|
||||
>
|
||||
{feedback}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
web/components/ShortcutsHelp.tsx
Normal file
152
web/components/ShortcutsHelp.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
// 글로벌 키보드 단축키 헬프 오버레이.
|
||||
// `?` (Shift+/) 로 열고 ESC 또는 바깥 클릭 / 닫기 버튼으로 닫음.
|
||||
// 다른 슬라이스에서 키보드 단축키를 추가해도 발견 가능성을 유지하기 위한 공용 패널.
|
||||
//
|
||||
// 등록되는 단축키는 여기 한 곳에서 정의 — 실제 핸들러는 각 컴포넌트가 가지고 있고,
|
||||
// 이 컴포넌트는 단순 표시 + `?` 토글만 담당. 단일 출처(Single Source of Truth)는 아니지만,
|
||||
// 이 사이트가 사용하는 단축키 수가 적어 수동 동기화로 충분.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Shortcut = {
|
||||
keys: string[]; // ['Ctrl', 'K'] 같은 표시용 토큰. 시각적으로 + 로 join.
|
||||
label: string;
|
||||
scope?: string; // '전역' / '종목 페이지' 등 — group 헤더 라벨.
|
||||
};
|
||||
|
||||
const SHORTCUTS: Shortcut[] = [
|
||||
{ scope: "전역", keys: ["/"], label: "종목 검색창 포커스" },
|
||||
{ scope: "전역", keys: ["Ctrl", "K"], label: "종목 검색창 포커스 (Mac: ⌘K)" },
|
||||
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
|
||||
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
|
||||
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
|
||||
{ scope: "종목 페이지", keys: ["H"], label: "현재 가격에 수평선 추가" },
|
||||
{ scope: "종목 페이지", keys: ["Shift", "클릭"], label: "차트 위 클릭 위치에 수평선 추가" },
|
||||
{ scope: "종목 페이지", keys: ["Alt", "클릭"], label: "차트의 가장 가까운 수평선 제거" },
|
||||
{ scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" },
|
||||
{ scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" },
|
||||
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
|
||||
{ scope: "검색 팝오버", keys: ["Enter"], label: "선택한 종목으로 이동" },
|
||||
];
|
||||
|
||||
function isEditableTarget(t: EventTarget | null): boolean {
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
const tag = t.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (t.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// `?` 키 = 토글. 입력 중에는 무시 (사용자가 실제 ? 입력하려는 의도).
|
||||
// ESC 는 모달 안에서 onKeyDown 으로도 처리하지만, 글로벌 핸들러로도 닫음 — 포커스가
|
||||
// 모달 밖에 있어도 닫히도록.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "?" && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// 열릴 때 닫기 버튼에 포커스 — 키보드 사용자가 바로 Tab 흐름을 잡을 수 있게.
|
||||
useEffect(() => {
|
||||
if (open) closeBtnRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// 화면 우하단 floating 버튼 — 모바일/마우스 사용자가 키보드 없이도 발견 가능.
|
||||
// 모달이 열려 있으면 같은 버튼이 닫기 역할.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="fixed bottom-4 right-4 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-xs text-zinc-300 shadow-lg backdrop-blur transition hover:border-zinc-500 hover:text-zinc-100"
|
||||
title="키보드 단축키 (?)"
|
||||
aria-label="키보드 단축키 도움말"
|
||||
aria-expanded={open}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="키보드 단축키"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-5 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">키보드 단축키</h2>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
||||
aria-label="닫기"
|
||||
>
|
||||
닫기 (Esc)
|
||||
</button>
|
||||
</div>
|
||||
<ShortcutTable />
|
||||
<p className="mt-3 text-[10px] text-zinc-500">
|
||||
입력 필드에 포커스가 있으면 단축키는 동작하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// scope 별로 묶어 한 줄에 [키] — 설명 형태로 표시. 같은 scope 가 연속되면 헤더 생략해서
|
||||
// 시각적 노이즈 줄임.
|
||||
function ShortcutTable() {
|
||||
let prevScope: string | undefined;
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{SHORTCUTS.map((s, i) => {
|
||||
const showScope = s.scope && s.scope !== prevScope;
|
||||
prevScope = s.scope;
|
||||
return (
|
||||
<li key={i}>
|
||||
{showScope && (
|
||||
<div className="mb-0.5 mt-2 first:mt-0 text-[10px] uppercase tracking-wide text-zinc-500">
|
||||
{s.scope}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 text-[12px]">
|
||||
<span className="text-zinc-300">{s.label}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{s.keys.map((k, j) => (
|
||||
<span key={j} className="flex items-center gap-1">
|
||||
{j > 0 && <span className="text-[10px] text-zinc-600">+</span>}
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-900 px-1.5 py-0.5 font-mono text-[10px] text-zinc-200">
|
||||
{k}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
49
web/components/Sparkline.tsx
Normal file
49
web/components/Sparkline.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// 가벼운 SVG sparkline. lightweight-charts 인스턴스를 또 만들 필요 없음.
|
||||
// 종가 시계열을 받아 polyline 으로 그린다. 색상은 호출부에서 결정 (KR 관행: 상승=빨강, 하락=파랑).
|
||||
|
||||
type Props = {
|
||||
values: number[];
|
||||
stroke: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Sparkline({
|
||||
values,
|
||||
stroke,
|
||||
width = 140,
|
||||
height = 44,
|
||||
className,
|
||||
}: Props) {
|
||||
if (values.length < 2) return null;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min || 1;
|
||||
const stepX = width / (values.length - 1);
|
||||
const points = values
|
||||
.map((v, i) => {
|
||||
const x = (i * stepX).toFixed(1);
|
||||
const y = (height - ((v - min) / span) * height).toFixed(1);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={`shrink-0 opacity-80 ${className ?? ""}`}
|
||||
aria-hidden
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
39
web/components/StarButton.tsx
Normal file
39
web/components/StarButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
// 관심종목 토글 버튼. 종목 페이지 PriceHero 옆에 들어감.
|
||||
// localStorage 만 건드리므로 백엔드 호출 없음.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { watchlist } from "../lib/watchlist";
|
||||
|
||||
export function StarButton({ code, name }: { code: string; name?: string }) {
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(watchlist.has(code));
|
||||
return watchlist.subscribe(() => setActive(watchlist.has(code)));
|
||||
}, [code]);
|
||||
|
||||
const toggle = () => {
|
||||
const now = watchlist.toggle(code);
|
||||
setActive(now);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={active}
|
||||
title={active ? "관심종목에서 제거" : "관심종목에 추가"}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
active
|
||||
? "border-amber-500/60 bg-amber-500/10 text-amber-300"
|
||||
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{active ? "★" : "☆"}</span>
|
||||
<span>{active ? "관심" : "관심"}</span>
|
||||
{name && <span className="sr-only">{name}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
234
web/components/SymbolSidebar.tsx
Normal file
234
web/components/SymbolSidebar.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
|
||||
//
|
||||
// 표시 항목:
|
||||
// - 오늘: 시가 / 고가 / 저가 / 거래량
|
||||
// - 52주: 최고 / 최저 (1Y 일봉에서 계산) + 게이지
|
||||
// - 펀더멘털: 시가총액 / PER / PBR / EPS / BPS / 배당수익률 (pykrx KRX 스냅샷)
|
||||
//
|
||||
// 차트 폴링과 독립적으로 1Y 일봉 + 펀더멘털 스냅샷을 한 번씩 받는다.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type ChartPayload, type FundamentalsResponse } from "../lib/api";
|
||||
|
||||
export function SymbolSidebar({ code }: { code: string }) {
|
||||
const [chart, setChart] = useState<ChartPayload | null>(null);
|
||||
const [fund, setFund] = useState<FundamentalsResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
setChart(null);
|
||||
setFund(null);
|
||||
api
|
||||
.getChart(code, 365, "1d")
|
||||
.then((c) => {
|
||||
if (alive) setChart(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
// 펀더멘털은 차트와 독립 — 실패해도 시세 표시는 유지.
|
||||
api
|
||||
.fundamentals(code)
|
||||
.then((f) => {
|
||||
if (alive) setFund(f);
|
||||
})
|
||||
.catch(() => {
|
||||
// 무시: 펀더멘털 없으면 그 섹션만 숨김.
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!chart) return null;
|
||||
const points = chart.ohlcv.filter((p) => p.close != null);
|
||||
if (!points.length) return null;
|
||||
const last = points[points.length - 1];
|
||||
|
||||
let yearHigh = -Infinity;
|
||||
let yearLow = Infinity;
|
||||
for (const p of points) {
|
||||
if (p.high != null && p.high > yearHigh) yearHigh = p.high;
|
||||
if (p.low != null && p.low < yearLow) yearLow = p.low;
|
||||
}
|
||||
const yh = yearHigh === -Infinity ? null : yearHigh;
|
||||
const yl = yearLow === Infinity ? null : yearLow;
|
||||
// 52주 위치 (0~1). 종가 기준, 분모 0 방어.
|
||||
let pos: number | null = null;
|
||||
if (yh != null && yl != null && yh > yl && last.close != null) {
|
||||
pos = Math.min(1, Math.max(0, (last.close - yl) / (yh - yl)));
|
||||
}
|
||||
return {
|
||||
open: last.open,
|
||||
high: last.high,
|
||||
low: last.low,
|
||||
close: last.close,
|
||||
volume: last.volume,
|
||||
yearHigh: yh,
|
||||
yearLow: yl,
|
||||
yearPos: pos,
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
|
||||
시세 정보 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
시세 정보 로딩 중…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-3 text-sm font-medium text-zinc-200">시세 정보</div>
|
||||
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<Row label="시가" value={fmt(stats.open)} />
|
||||
<Row label="고가" value={fmt(stats.high)} tone="up" />
|
||||
<Row label="저가" value={fmt(stats.low)} tone="down" />
|
||||
<Row label="거래량" value={fmtCompact(stats.volume)} />
|
||||
<Row label="52주 최고" value={fmt(stats.yearHigh)} tone="up" />
|
||||
<Row label="52주 최저" value={fmt(stats.yearLow)} tone="down" />
|
||||
</dl>
|
||||
{stats.yearPos != null && (
|
||||
<YearRangeBar
|
||||
low={stats.yearLow}
|
||||
high={stats.yearHigh}
|
||||
pos={stats.yearPos}
|
||||
/>
|
||||
)}
|
||||
{fund && fund.status === "ok" && <FundamentalsBlock f={fund} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시가총액 / PER / PBR / EPS / BPS / 배당수익률. KRX 스냅샷 일자 표시.
|
||||
// 값 미존재 (e.g. 신규 상장으로 EPS 안 나옴) 항목은 자동으로 "-".
|
||||
function FundamentalsBlock({ f }: { f: FundamentalsResponse }) {
|
||||
return (
|
||||
<div className="mt-4 border-t border-zinc-800 pt-3">
|
||||
<div className="mb-2 flex items-center justify-between text-[10px] text-zinc-500">
|
||||
<span>기업 지표</span>
|
||||
{f.snapshot_date && (
|
||||
<span className="tabular-nums">{f.snapshot_date} 기준</span>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<Row label="시가총액" value={fmtKrw(f.market_cap)} />
|
||||
<Row label="상장주식수" value={fmtShares(f.shares_outstanding)} />
|
||||
<Row label="PER" value={fmtMultiple(f.per)} />
|
||||
<Row label="PBR" value={fmtMultiple(f.pbr)} />
|
||||
<Row label="EPS" value={fmt(f.eps)} />
|
||||
<Row label="BPS" value={fmt(f.bps)} />
|
||||
<Row label="배당수익률" value={fmtPct(f.dvd_yield)} />
|
||||
<Row label="주당배당금" value={fmt(f.div)} />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 52주 범위 내 현재가 위치 — 토스 종목 상세에 있는 가로 게이지를 흉내냄.
|
||||
function YearRangeBar({
|
||||
low,
|
||||
high,
|
||||
pos,
|
||||
}: {
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
pos: number;
|
||||
}) {
|
||||
const pct = pos * 100;
|
||||
return (
|
||||
<div className="mt-4 border-t border-zinc-800 pt-3">
|
||||
<div className="mb-1.5 flex items-center justify-between text-[10px] text-zinc-500">
|
||||
<span>52주 위치</span>
|
||||
<span className="tabular-nums text-zinc-300">{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 w-full rounded-full bg-zinc-800">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-sky-500/70 via-amber-400/70 to-rose-500/70"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-zinc-950 bg-zinc-100 shadow"
|
||||
style={{ left: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-zinc-500">
|
||||
<span>{fmt(low)}</span>
|
||||
<span>{fmt(high)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "up" | "down";
|
||||
}) {
|
||||
const toneCls =
|
||||
tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : "text-zinc-100";
|
||||
return (
|
||||
<>
|
||||
<dt className="text-zinc-500">{label}</dt>
|
||||
<dd className={`text-right tabular-nums ${toneCls}`}>{value}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function fmtCompact(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// 시가총액은 단위가 매우 큼 — 조/억 까지 단순화.
|
||||
function fmtKrw(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}조`;
|
||||
if (n >= 1e8) return `${(n / 1e8).toFixed(0)}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtShares(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
if (n >= 1e8) return `${(n / 1e8).toFixed(2)}억`;
|
||||
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}만`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// PER/PBR — 음수면 "적자" 표기 (이익 음수 = PER 의미 없음).
|
||||
function fmtMultiple(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
if (n <= 0) return "적자";
|
||||
return `${n.toFixed(2)}배`;
|
||||
}
|
||||
|
||||
function fmtPct(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "-";
|
||||
if (n === 0) return "-";
|
||||
return `${n.toFixed(2)}%`;
|
||||
}
|
||||
69
web/components/ThemeTiles.tsx
Normal file
69
web/components/ThemeTiles.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
// 홈에 들어가는 '테마로 골라보기' 그리드.
|
||||
// /api/themes 인덱스를 받아 카드로 펼치고, 카드 클릭 시 /themes/{slug} 로 이동.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ThemesIndexResponse } from "../lib/api";
|
||||
|
||||
export function ThemeTiles() {
|
||||
const [data, setData] = useState<ThemesIndexResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.themesIndex()
|
||||
.then((r) => {
|
||||
if (alive) setData(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (err)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
테마 로딩 실패: {err}
|
||||
</div>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||
테마 로딩 중…
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold text-zinc-200">테마로 골라보기</h2>
|
||||
<span className="text-[11px] text-zinc-500">{data.total} 카테고리</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{data.items.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`/themes/${t.slug}`}
|
||||
className="group rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5 transition hover:border-zinc-700 hover:bg-zinc-800/60"
|
||||
>
|
||||
<div className="text-sm font-medium text-zinc-100 group-hover:text-rose-300">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-zinc-500">
|
||||
{t.description}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-600">
|
||||
{t.code_count} 종목
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
web/components/TopNav.tsx
Normal file
257
web/components/TopNav.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
// 글로벌 sticky 헤더 — 모든 페이지 상단에 고정. 토스 톱바 톤.
|
||||
// 좌: 로고/타이틀, 중: 컴팩트 검색 (debounced + popover), 우: 관심종목 링크.
|
||||
//
|
||||
// 홈의 큰 SearchBox 와 별개로 동작. 결과 클릭 시 popover 가 닫히고 페이지 이동.
|
||||
// Esc 또는 바깥 클릭으로 popover 닫힘.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, type Symbol } from "../lib/api";
|
||||
import { recent } from "../lib/recent";
|
||||
|
||||
export function TopNav() {
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Symbol[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recentCodes, setRecentCodes] = useState<string[]>([]);
|
||||
const [hi, setHi] = useState(0); // popover 선택 인덱스 (검색 결과 또는 recent)
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// 최근 본 종목 구독 — popover 빈 상태에서 보여줌.
|
||||
useEffect(() => {
|
||||
const refresh = () => setRecentCodes(recent.get());
|
||||
refresh();
|
||||
return recent.subscribe(refresh);
|
||||
}, []);
|
||||
|
||||
// 디바운스 검색. 입력 비면 결과 클리어.
|
||||
useEffect(() => {
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const h = setTimeout(async () => {
|
||||
try {
|
||||
const r = await api.search(term, false, 8, false);
|
||||
setItems(r.items);
|
||||
} catch {
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(h);
|
||||
}, [q]);
|
||||
|
||||
// 바깥 클릭으로 popover 닫기 + 글로벌 단축키.
|
||||
// - "/" 또는 Cmd/Ctrl+K → 검색 인풋 포커스 + popover 열기
|
||||
// (단, 다른 input/textarea/contenteditable 에 포커스 중이면 무시)
|
||||
// - Esc → popover 닫기
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current) return;
|
||||
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const isEditableTarget = (t: EventTarget | null): boolean => {
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
const tag = t.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (t.isContentEditable) return true;
|
||||
return false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
return;
|
||||
}
|
||||
// Cmd/Ctrl+K — OS 공통 검색 단축키. 단, 다른 인풋/편집 영역에서 입력 중이면
|
||||
// 브라우저/편집기 단축키를 뺏지 않도록 동일하게 무시.
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key.toLowerCase() === "k" &&
|
||||
!isEditableTarget(e.target)
|
||||
) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
// "/" — 다른 인풋에 포커스 없을 때만.
|
||||
if (e.key === "/" && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 현재 선택 가능한 코드 목록 — 검색 모드면 items, 빈 모드면 recent.
|
||||
const navCodes = useMemo<string[]>(() => {
|
||||
if (q.trim()) return items.map((it) => it.code);
|
||||
return recentCodes;
|
||||
}, [q, items, recentCodes]);
|
||||
|
||||
// 목록 길이가 바뀌면 hi 를 clamp.
|
||||
useEffect(() => {
|
||||
setHi((h) => (navCodes.length === 0 ? 0 : Math.min(h, navCodes.length - 1)));
|
||||
}, [navCodes]);
|
||||
|
||||
// 인풋 키 이벤트 — ↑↓ 이동, Enter 이동, Esc 닫기.
|
||||
const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
if (navCodes.length === 0) return;
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHi((h) => (h + 1) % navCodes.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (navCodes.length === 0) return;
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHi((h) => (h - 1 + navCodes.length) % navCodes.length);
|
||||
} else if (e.key === "Enter") {
|
||||
if (navCodes.length === 0) return;
|
||||
e.preventDefault();
|
||||
const code = navCodes[hi] ?? navCodes[0];
|
||||
router.push(`/${code}`);
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-12 max-w-5xl items-center gap-4 px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="shrink-0 text-sm font-semibold tracking-tight text-zinc-100 hover:text-white"
|
||||
>
|
||||
Stock Chart
|
||||
</Link>
|
||||
|
||||
<div ref={wrapRef} className="relative flex-1 max-w-md">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
setHi(0);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onInputKey}
|
||||
placeholder="종목 검색 ( / )"
|
||||
aria-label="종목 검색"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 outline-none focus:border-zinc-600"
|
||||
/>
|
||||
{open && q.trim() && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
|
||||
{loading && (
|
||||
<div className="px-3 py-2 text-xs text-zinc-500">검색중…</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-zinc-500">결과 없음</div>
|
||||
)}
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((it, i) => (
|
||||
<li key={it.code}>
|
||||
<Link
|
||||
href={`/${it.code}`}
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
}}
|
||||
className={`flex items-center justify-between gap-2 px-3 py-2 text-xs ${
|
||||
i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 truncate text-zinc-100">
|
||||
{it.name}
|
||||
{it.is_seed && (
|
||||
<span className="ml-1.5 rounded-sm bg-emerald-900/60 px-1 py-0.5 text-[9px] font-semibold text-emerald-300">
|
||||
SEED
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-zinc-500">
|
||||
{it.code} · {it.market}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && !q.trim() && recentCodes.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-zinc-500">
|
||||
<span>최근 본 종목</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => recent.clear()}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
지우기
|
||||
</button>
|
||||
</div>
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{recentCodes.map((c, i) => (
|
||||
<li key={c}>
|
||||
<Link
|
||||
href={`/${c}`}
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`block px-3 py-2 text-xs text-zinc-200 ${
|
||||
i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/compare"
|
||||
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
title="종목 비교"
|
||||
>
|
||||
비교
|
||||
</Link>
|
||||
<Link
|
||||
href="/alerts"
|
||||
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
title="가격 알람"
|
||||
>
|
||||
🔔
|
||||
</Link>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="shrink-0 text-xs text-amber-300 hover:text-amber-200"
|
||||
>
|
||||
★ 관심
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
84
web/components/TradingValuePanel.tsx
Normal file
84
web/components/TradingValuePanel.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
// 거래주체 패널 — chart.trading_value (외국인/기관/개인 순매수) 를 최근 5일 표로.
|
||||
// 별도 백엔드 호출 없음. ChartPayload 에 이미 들어있는 시리즈를 재사용.
|
||||
// 단위: 백엔드는 원(KRW) 단위. UI 는 억원(억원=1e8)으로 환산.
|
||||
// 색: KR 관행 — 매수 우위(+) 빨강, 매도 우위(-) 파랑.
|
||||
|
||||
import type { TradingValuePoint } from "../lib/api";
|
||||
import { CardHeader } from "./CardHeader";
|
||||
|
||||
export function TradingValuePanel({
|
||||
data,
|
||||
}: {
|
||||
data: TradingValuePoint[];
|
||||
}) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
거래주체 데이터 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 최근 5일만 (입력은 오래된→최신 순), 표시는 최신이 위.
|
||||
const recent = data.slice(-5).reverse();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<CardHeader
|
||||
title="거래주체 순매수"
|
||||
subtitle={`최근 ${recent.length}일 · 단위: 억원`}
|
||||
/>
|
||||
<div className="overflow-hidden rounded-md border border-zinc-800">
|
||||
<table className="w-full text-xs tabular-nums">
|
||||
<thead className="bg-zinc-900/60 text-[11px] text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-normal">일자</th>
|
||||
<th className="px-2 py-1.5 text-right font-normal">외국인</th>
|
||||
<th className="px-2 py-1.5 text-right font-normal">기관</th>
|
||||
<th className="px-2 py-1.5 text-right font-normal">개인</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{recent.map((row) => (
|
||||
<tr key={row.date}>
|
||||
<td className="px-2 py-1.5 text-left text-zinc-400">
|
||||
{shortDate(row.date)}
|
||||
</td>
|
||||
<NetCell value={row.foreign_net} />
|
||||
<NetCell value={row.institution_net} />
|
||||
<NetCell value={row.individual_net} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetCell({ value }: { value: number | null }) {
|
||||
if (value == null) {
|
||||
return <td className="px-2 py-1.5 text-right text-zinc-600">—</td>;
|
||||
}
|
||||
const eok = value / 1e8;
|
||||
const tone =
|
||||
eok === 0
|
||||
? "text-zinc-400"
|
||||
: eok > 0
|
||||
? "text-rose-400"
|
||||
: "text-sky-400";
|
||||
const sign = eok > 0 ? "+" : "";
|
||||
return (
|
||||
<td className={`px-2 py-1.5 text-right ${tone}`}>
|
||||
{sign}
|
||||
{eok.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function shortDate(iso: string): string {
|
||||
// 'YYYY-MM-DD' → 'MM-DD'
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})/.exec(iso);
|
||||
return m ? `${m[1]}-${m[2]}` : iso;
|
||||
}
|
||||
104
web/lib/alerts.ts
Normal file
104
web/lib/alerts.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// 가격 알람 — localStorage 기반. 단순 임계값 알람.
|
||||
//
|
||||
// 스키마 (Alert):
|
||||
// id 랜덤 문자열
|
||||
// code 종목코드
|
||||
// name 등록 시점의 종목명 (표시용)
|
||||
// op "gte" = 현재가 ≥ target, "lte" = 현재가 ≤ target
|
||||
// target 임계값 (원)
|
||||
// triggered 한 번 발사되면 true. 사용자가 명시적으로 다시 켜기 전까지 침묵.
|
||||
// createdAt epoch ms
|
||||
//
|
||||
// 발사는 AlertsToaster (글로벌 폴러) 가 처리. 이 모듈은 저장소+pub/sub 만 담당.
|
||||
|
||||
const KEY = "stockchart.alerts";
|
||||
|
||||
export type AlertOp = "gte" | "lte";
|
||||
|
||||
export type Alert = {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string;
|
||||
op: AlertOp;
|
||||
target: number;
|
||||
triggered: boolean;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
function read(): Alert[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
const v = JSON.parse(raw);
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter(
|
||||
(x): x is Alert =>
|
||||
x &&
|
||||
typeof x.id === "string" &&
|
||||
typeof x.code === "string" &&
|
||||
(x.op === "gte" || x.op === "lte") &&
|
||||
typeof x.target === "number" &&
|
||||
typeof x.triggered === "boolean",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: Alert[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(KEY, JSON.stringify(items));
|
||||
window.dispatchEvent(new CustomEvent("alerts:change"));
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export const alerts = {
|
||||
list(): Alert[] {
|
||||
return read();
|
||||
},
|
||||
listForCode(code: string): Alert[] {
|
||||
return read().filter((a) => a.code === code);
|
||||
},
|
||||
add(input: Omit<Alert, "id" | "triggered" | "createdAt"> & { triggered?: boolean }) {
|
||||
const cur = read();
|
||||
cur.unshift({
|
||||
id: genId(),
|
||||
triggered: input.triggered ?? false,
|
||||
createdAt: Date.now(),
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
op: input.op,
|
||||
target: input.target,
|
||||
});
|
||||
write(cur);
|
||||
},
|
||||
remove(id: string) {
|
||||
write(read().filter((a) => a.id !== id));
|
||||
},
|
||||
markTriggered(id: string) {
|
||||
write(read().map((a) => (a.id === id ? { ...a, triggered: true } : a)));
|
||||
},
|
||||
reset(id: string) {
|
||||
write(read().map((a) => (a.id === id ? { ...a, triggered: false } : a)));
|
||||
},
|
||||
clear() {
|
||||
write([]);
|
||||
},
|
||||
subscribe(cb: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === KEY) cb();
|
||||
};
|
||||
const onCustom = () => cb();
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("alerts:change", onCustom as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("alerts:change", onCustom as EventListener);
|
||||
};
|
||||
},
|
||||
};
|
||||
223
web/lib/api.ts
223
web/lib/api.ts
@@ -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;
|
||||
@@ -10,6 +33,10 @@ export type Symbol = {
|
||||
market: string;
|
||||
sector: string | null;
|
||||
is_seed: boolean;
|
||||
// with_sparkline=true 일 때만 채워짐.
|
||||
sparkline?: number[];
|
||||
close?: number | null;
|
||||
pct_change?: number | null;
|
||||
};
|
||||
|
||||
export type SymbolSearch = {
|
||||
@@ -19,6 +46,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 +55,8 @@ export type OhlcvPoint = {
|
||||
volume: number | null;
|
||||
};
|
||||
|
||||
export type ChartInterval = "10m" | "1d" | "1w" | "1mo";
|
||||
|
||||
export type SentimentPoint = {
|
||||
date: string;
|
||||
n_articles: number;
|
||||
@@ -45,7 +75,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[];
|
||||
@@ -72,6 +105,7 @@ export type PredictResponse = {
|
||||
sources_used: string[];
|
||||
steps: PredictionStep[];
|
||||
saved_prediction_ids: number[];
|
||||
saved_shadow_ids?: { chronos: number[]; lgbm: number[] };
|
||||
user_triggered: boolean;
|
||||
};
|
||||
|
||||
@@ -132,6 +166,125 @@ export type NewsResponse = {
|
||||
items: NewsItem[];
|
||||
};
|
||||
|
||||
export type Mover = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
close: number | null;
|
||||
prev_close: number | null;
|
||||
volume: number | null;
|
||||
pct_change: number | null;
|
||||
trading_value?: number | null;
|
||||
};
|
||||
|
||||
export type MoversResponse = {
|
||||
market: string;
|
||||
limit: number;
|
||||
gainers: Mover[];
|
||||
losers: Mover[];
|
||||
by_volume: Mover[];
|
||||
by_trading_value: Mover[];
|
||||
};
|
||||
|
||||
export type RelatedResponse = {
|
||||
code: string;
|
||||
market: string;
|
||||
items: Mover[];
|
||||
};
|
||||
|
||||
export type ThemeIndex = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
code_count: number;
|
||||
};
|
||||
|
||||
export type ThemesIndexResponse = {
|
||||
items: ThemeIndex[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ThemeDetailResponse = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
items: Mover[];
|
||||
};
|
||||
|
||||
export type PeerCompareItem = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string | null;
|
||||
close: number | null;
|
||||
base_close: number | null;
|
||||
pct_change: number | null;
|
||||
};
|
||||
|
||||
export type PeerCompareResponse = {
|
||||
code: string;
|
||||
name?: string;
|
||||
window: number;
|
||||
themes: { slug: string; name: string; code_count: number }[];
|
||||
self_pct: number | null;
|
||||
peer_avg_pct: number | null;
|
||||
peer_count?: number;
|
||||
top: PeerCompareItem[];
|
||||
bottom: PeerCompareItem[];
|
||||
};
|
||||
|
||||
export type IndexPoint = { date: string; value: number };
|
||||
|
||||
export type IndexSeries = {
|
||||
key: "kospi" | "kosdaq";
|
||||
name: string;
|
||||
points: IndexPoint[];
|
||||
latest: number | null;
|
||||
prev: number | null;
|
||||
pct_change: number | null;
|
||||
};
|
||||
|
||||
export type IndicesResponse = {
|
||||
days: number;
|
||||
indices: IndexSeries[];
|
||||
};
|
||||
|
||||
export type OrderbookLevel = {
|
||||
level: number;
|
||||
price: number;
|
||||
qty: number;
|
||||
};
|
||||
|
||||
export type FundamentalsResponse = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
status: "ok" | "no_data";
|
||||
snapshot_date: string | null;
|
||||
market_cap: number | null;
|
||||
shares_outstanding: number | null;
|
||||
per: number | null;
|
||||
pbr: number | null;
|
||||
eps: number | null;
|
||||
bps: number | null;
|
||||
div: number | null; // 주당배당금 (원)
|
||||
dvd_yield: number | null; // 배당수익률 (%)
|
||||
};
|
||||
|
||||
export type OrderbookResponse = {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
status: "ok" | "skipped_missing_key";
|
||||
ts: string | null;
|
||||
current: number | null;
|
||||
prev_close_diff: number | null;
|
||||
prev_close_pct: number | null;
|
||||
asks: OrderbookLevel[];
|
||||
bids: OrderbookLevel[];
|
||||
ask_total: number;
|
||||
bid_total: number;
|
||||
};
|
||||
|
||||
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
@@ -149,18 +302,22 @@ async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
search: (q: string, seedOnly = false, limit = 20) =>
|
||||
search: (q: string, seedOnly = false, limit = 20, withSparkline = false) =>
|
||||
getJson<SymbolSearch>(
|
||||
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`,
|
||||
`/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}&with_sparkline=${withSparkline}`,
|
||||
),
|
||||
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) =>
|
||||
@@ -175,4 +332,48 @@ export const api = {
|
||||
source ? `&source=${encodeURIComponent(source)}` : ""
|
||||
}`,
|
||||
),
|
||||
movers: (market: "ALL" | "KOSPI" | "KOSDAQ" = "ALL", limit = 10) =>
|
||||
getJson<MoversResponse>(`/api/markets/movers?market=${market}&limit=${limit}`),
|
||||
related: (code: string, limit = 8) =>
|
||||
getJson<RelatedResponse>(
|
||||
`/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`,
|
||||
),
|
||||
indices: (days = 60) => getJson<IndicesResponse>(`/api/markets/indices?days=${days}`),
|
||||
themesIndex: () => getJson<ThemesIndexResponse>(`/api/themes`),
|
||||
themeDetail: (slug: string, limit = 30) =>
|
||||
getJson<ThemeDetailResponse>(
|
||||
`/api/themes/${encodeURIComponent(slug)}?limit=${limit}`,
|
||||
),
|
||||
peerCompare: (code: string, window = 21, limit = 5) =>
|
||||
getJson<PeerCompareResponse>(
|
||||
`/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`,
|
||||
),
|
||||
orderbook: (code: string) =>
|
||||
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
|
||||
fundamentals: (code: string) =>
|
||||
getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`),
|
||||
recordView: (code: string) =>
|
||||
getJson<RecordViewResponse>(`/api/views/${encodeURIComponent(code)}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
views: (code: string) =>
|
||||
getJson<ViewsResponse>(`/api/views/${encodeURIComponent(code)}`),
|
||||
};
|
||||
|
||||
export type RecordViewResponse = {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
today_views: number;
|
||||
};
|
||||
|
||||
export type ViewsTrendPoint = {
|
||||
date: string;
|
||||
views: number;
|
||||
};
|
||||
|
||||
export type ViewsResponse = {
|
||||
code: string;
|
||||
today_views: number;
|
||||
last_7d_views: number;
|
||||
trend: ViewsTrendPoint[];
|
||||
};
|
||||
|
||||
30
web/lib/collapsible.ts
Normal file
30
web/lib/collapsible.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 사이드바 collapsible 패널의 펼침/접힘 상태 영속 저장.
|
||||
// 키 형식: `panel-open:<name>` — 종목별이 아닌 panel-name 단위. 사용자는 "관련 종목은 늘
|
||||
// 접어 둔다" 같은 panel 단위 습관이 있지 종목별로 다르게 설정하지 않음. 모든 종목 페이지에서
|
||||
// 같은 상태로 보이는 것이 직관적.
|
||||
//
|
||||
// 값 형식: '1' = open, '0' = closed. 다른 값(수동 편집·legacy 등) 은 default 로 폴백.
|
||||
// 쿼타/잠금 환경에선 silently fail — 그 세션 동안 default 상태 유지.
|
||||
|
||||
const STORAGE_PREFIX = "panel-open:";
|
||||
|
||||
export function readOpen(key: string, defaultOpen: boolean): boolean {
|
||||
if (typeof window === "undefined") return defaultOpen;
|
||||
try {
|
||||
const v = window.localStorage.getItem(STORAGE_PREFIX + key);
|
||||
if (v === "1") return true;
|
||||
if (v === "0") return false;
|
||||
return defaultOpen;
|
||||
} catch {
|
||||
return defaultOpen;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpen(key: string, open: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_PREFIX + key, open ? "1" : "0");
|
||||
} catch {
|
||||
// quota / private mode — 다음 페이지 진입엔 default 로 되돌아감.
|
||||
}
|
||||
}
|
||||
69
web/lib/csv.ts
Normal file
69
web/lib/csv.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// OHLCV → CSV 변환 + 다운로드 트리거.
|
||||
// 차트 데이터를 분석가 친화 CSV (UTF-8 BOM, Excel 호환) 로 내려받게 해줌.
|
||||
|
||||
import type { ChartInterval, OhlcvPoint } from "./api";
|
||||
|
||||
function escapeField(v: string): string {
|
||||
// 표준 RFC 4180 — 쉼표/큰따옴표/개행 포함 시 큰따옴표로 감싸고 내부 큰따옴표는 이중화.
|
||||
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
|
||||
return `"${v.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function num(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return "";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function ohlcvToCsv(ohlcv: OhlcvPoint[]): string {
|
||||
const header = ["date", "open", "high", "low", "close", "volume"];
|
||||
const lines: string[] = [header.join(",")];
|
||||
for (const p of ohlcv) {
|
||||
lines.push(
|
||||
[
|
||||
escapeField(p.date),
|
||||
num(p.open),
|
||||
num(p.high),
|
||||
num(p.low),
|
||||
num(p.close),
|
||||
num(p.volume),
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function downloadCsv(filename: string, csv: string): boolean {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return false;
|
||||
// Excel BOM — 한글 Excel 이 UTF-8 로 추론하도록.
|
||||
const BOM = "\uFEFF";
|
||||
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// `삼성전자_005930_1d_2025-08-01_2025-11-28.csv` — 파일시스템 금지 문자만 `_` 치환.
|
||||
export function csvFilename(
|
||||
name: string,
|
||||
code: string,
|
||||
interval: ChartInterval,
|
||||
from: string,
|
||||
to: string,
|
||||
): string {
|
||||
const safe = (s: string) => s.replace(/[\\/:*?"<>|]/g, "_").trim() || "data";
|
||||
return `${safe(name)}_${code}_${interval}_${from}_${to}.csv`;
|
||||
}
|
||||
107
web/lib/lines.ts
Normal file
107
web/lib/lines.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// 종목별 사용자 수평선(horizontal price lines) 저장소.
|
||||
// TradingView/Toss 식 그리기 도구의 가장 가벼운 형태 — 가격 라인만, per-code, localStorage.
|
||||
// 차트 자체는 라인 ID 를 모르고 좌표만 받음. 추가/삭제는 이 모듈을 통해서만.
|
||||
|
||||
const KEY = (code: string) => `hlines:${code}`;
|
||||
export const HLINE_MAX = 10;
|
||||
|
||||
export type HLine = {
|
||||
id: string;
|
||||
price: number;
|
||||
// 작성 시각 — 디버그/정렬용. UI 에선 노출 안 해도 됨.
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function safeStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readLines(code: string): HLine[] {
|
||||
const s = safeStorage();
|
||||
if (!s) return [];
|
||||
try {
|
||||
const raw = s.getItem(KEY(code));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
const out: HLine[] = [];
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof (item as HLine).id === "string" &&
|
||||
typeof (item as HLine).price === "number" &&
|
||||
Number.isFinite((item as HLine).price) &&
|
||||
(item as HLine).price > 0
|
||||
) {
|
||||
out.push({
|
||||
id: (item as HLine).id,
|
||||
price: (item as HLine).price,
|
||||
createdAt:
|
||||
typeof (item as HLine).createdAt === "string"
|
||||
? (item as HLine).createdAt
|
||||
: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.slice(0, HLINE_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(code: string, lines: HLine[]): boolean {
|
||||
const s = safeStorage();
|
||||
if (!s) return false;
|
||||
try {
|
||||
if (lines.length === 0) s.removeItem(KEY(code));
|
||||
else s.setItem(KEY(code), JSON.stringify(lines.slice(0, HLINE_MAX)));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// id 충돌 가능성이 거의 없는 short id. crypto.randomUUID 미지원 환경 폴백.
|
||||
function newId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// price 같음 검사용 — 유효숫자 4 자리 정도 비교로 부동소수 오차 무시.
|
||||
function nearlyEqual(a: number, b: number): boolean {
|
||||
const denom = Math.max(Math.abs(a), Math.abs(b), 1);
|
||||
return Math.abs(a - b) / denom < 1e-4;
|
||||
}
|
||||
|
||||
export function addLine(code: string, price: number): HLine[] {
|
||||
if (!Number.isFinite(price) || price <= 0) return readLines(code);
|
||||
const cur = readLines(code);
|
||||
// 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발.
|
||||
if (cur.some((l) => nearlyEqual(l.price, price))) return cur;
|
||||
const next = [...cur, { id: newId(), price, createdAt: new Date().toISOString() }];
|
||||
// 저장 실패 시 이전 상태 반환 — UI 가 보여주는 라인과 스토리지가 어긋나
|
||||
// 새로고침 후 사라지는 혼란을 막음 (시크릿 탭/스토리지 잠금 환경 대비).
|
||||
if (!writeRaw(code, next)) return cur;
|
||||
return next.slice(0, HLINE_MAX);
|
||||
}
|
||||
|
||||
export function removeLine(code: string, id: string): HLine[] {
|
||||
const cur = readLines(code);
|
||||
const next = cur.filter((l) => l.id !== id);
|
||||
// 삭제 저장 실패 시 이전 상태 유지 — UI 에서 사라졌다가 새로고침에 부활하면 더 혼란.
|
||||
if (!writeRaw(code, next)) return cur;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function clearLines(code: string): HLine[] {
|
||||
if (!writeRaw(code, [])) return readLines(code);
|
||||
return [];
|
||||
}
|
||||
58
web/lib/notes.ts
Normal file
58
web/lib/notes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// 종목별 사용자 메모 (브라우저 한정 — 비로그인 앱).
|
||||
//
|
||||
// 키: `notes:<code>` → { text, updatedAt }
|
||||
// 투자 결정의 근거/관전 포인트를 짧게 적어두는 용도. 다른 사용자에게는 보이지 않음.
|
||||
|
||||
const PREFIX = "notes:";
|
||||
const MAX_LEN = 2000; // 너무 큰 입력 방어 — localStorage quota 보호.
|
||||
|
||||
export type Note = {
|
||||
text: string;
|
||||
updatedAt: string; // ISO
|
||||
};
|
||||
|
||||
export function readNote(code: string): Note | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${PREFIX}${code}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Note>;
|
||||
if (typeof parsed.text !== "string" || typeof parsed.updatedAt !== "string") return null;
|
||||
return { text: parsed.text, updatedAt: parsed.updatedAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type WriteResult =
|
||||
| { status: "saved"; note: Note }
|
||||
| { status: "deleted" }
|
||||
| { status: "failed" };
|
||||
|
||||
export function writeNote(code: string, text: string): WriteResult {
|
||||
if (typeof window === "undefined") return { status: "failed" };
|
||||
const trimmed = text.slice(0, MAX_LEN);
|
||||
// 공백만이면 키 자체 제거 → 빈 잔여물 방지. 의도된 삭제이므로 failed 와 구분.
|
||||
if (!trimmed.trim()) {
|
||||
clearNote(code);
|
||||
return { status: "deleted" };
|
||||
}
|
||||
const next: Note = { text: trimmed, updatedAt: new Date().toISOString() };
|
||||
try {
|
||||
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(next));
|
||||
return { status: "saved", note: next };
|
||||
} catch {
|
||||
return { status: "failed" }; // quota 등 — UI 가 저장 실패 처리
|
||||
}
|
||||
}
|
||||
|
||||
export function clearNote(code: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export const NOTE_MAX_LEN = MAX_LEN;
|
||||
52
web/lib/recent.ts
Normal file
52
web/lib/recent.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// 최근 본 종목 — localStorage 기반. 종목 페이지 진입 시 자동으로 push.
|
||||
// 단순 string[] 직렬화. 최신이 앞쪽, 최대 MAX 개로 캡.
|
||||
|
||||
const KEY = "stockchart.recent";
|
||||
const MAX = 10;
|
||||
|
||||
function read(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(codes: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(KEY, JSON.stringify(codes));
|
||||
// 같은 탭은 storage 이벤트가 안 떠서 수동 dispatch.
|
||||
window.dispatchEvent(new CustomEvent("recent:change"));
|
||||
}
|
||||
|
||||
export const recent = {
|
||||
get(): string[] {
|
||||
return read();
|
||||
},
|
||||
push(code: string): void {
|
||||
if (!code) return;
|
||||
const cur = read().filter((c) => c !== code);
|
||||
cur.unshift(code);
|
||||
write(cur.slice(0, MAX));
|
||||
},
|
||||
clear(): void {
|
||||
write([]);
|
||||
},
|
||||
subscribe(cb: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === KEY) cb();
|
||||
};
|
||||
const onCustom = () => cb();
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("recent:change", onCustom as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("recent:change", onCustom as EventListener);
|
||||
};
|
||||
},
|
||||
};
|
||||
50
web/lib/targets.ts
Normal file
50
web/lib/targets.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// 종목별 목표가/손절가 저장 (브라우저 한정 — 비로그인 앱).
|
||||
//
|
||||
// 키: `targets:<code>` → { target?: number, stop?: number, savedAt: ISO }
|
||||
// 차트 오버레이 라인과 손익 미리보기에 사용. 알림 없음 (그건 AlertsPanel 담당).
|
||||
|
||||
const PREFIX = "targets:";
|
||||
|
||||
export type Targets = {
|
||||
target?: number | null; // 익절가 (선택)
|
||||
stop?: number | null; // 손절가 (선택)
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
export function readTargets(code: string): Targets {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${PREFIX}${code}`);
|
||||
return raw ? (JSON.parse(raw) as Targets) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTargets(code: string, t: Targets): void {
|
||||
if (typeof window === "undefined") return;
|
||||
// 둘 다 비어 있으면 키 자체를 지움 → 빈 객체 잔여물 제거.
|
||||
// 양수 유한값만 통과. UI 가 이미 가드하지만 lib 자체도 방어 — 외부에서 직접 호출돼도 안전.
|
||||
const cleaned: Targets = {};
|
||||
if (t.target != null && Number.isFinite(t.target) && t.target > 0) cleaned.target = t.target;
|
||||
if (t.stop != null && Number.isFinite(t.stop) && t.stop > 0) cleaned.stop = t.stop;
|
||||
try {
|
||||
if (cleaned.target == null && cleaned.stop == null) {
|
||||
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||
return;
|
||||
}
|
||||
cleaned.savedAt = new Date().toISOString();
|
||||
window.localStorage.setItem(`${PREFIX}${code}`, JSON.stringify(cleaned));
|
||||
} catch {
|
||||
/* quota 초과 등 — 무시 */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTargets(code: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(`${PREFIX}${code}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
61
web/lib/views.ts
Normal file
61
web/lib/views.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// 종목 조회 카운터 클라이언트 dedupe.
|
||||
//
|
||||
// 서버는 받은 POST 횟수만큼 +1. 같은 사용자가 페이지를 새로고침할 때마다 카운트되면
|
||||
// 인플레됨 → 클라이언트가 localStorage 에 (code, KST today) 마크를 남겨 하루 1회만 POST.
|
||||
// 새 탭/시크릿 모드는 새 사용자로 간주 (브라우저 한정 dedupe — 비로그인 앱이라 한계).
|
||||
|
||||
const STORAGE_KEY = "views:logged";
|
||||
|
||||
type LoggedMap = Record<string, string>; // code → "YYYY-MM-DD"
|
||||
|
||||
function kstToday(): string {
|
||||
// KST = UTC+9. naive: Date 가 시스템 TZ 영향을 받으므로 UTC 기준 +9 로 직접 계산.
|
||||
const now = new Date();
|
||||
const utcMs = now.getTime() + now.getTimezoneOffset() * 60_000;
|
||||
const kst = new Date(utcMs + 9 * 60 * 60_000);
|
||||
const y = kst.getFullYear();
|
||||
const m = String(kst.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(kst.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function readLogged(): LoggedMap {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as LoggedMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLogged(m: LoggedMap): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
|
||||
} catch {
|
||||
/* quota 초과 등 — 무시. dedupe 실패 시 최악으로 한 사용자가 N번 카운트될 뿐. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 오늘 이미 카운트된 종목인지. POST 호출 전 dedupe 게이트. */
|
||||
export function wasRecordedToday(code: string): boolean {
|
||||
const today = kstToday();
|
||||
const logged = readLogged();
|
||||
return logged[code] === today;
|
||||
}
|
||||
|
||||
/** POST 성공 이후 호출. 실패 시 마크하지 않아 다음 방문에 재시도 가능. */
|
||||
export function markRecorded(code: string): void {
|
||||
const today = kstToday();
|
||||
const logged = readLogged();
|
||||
logged[code] = today;
|
||||
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
|
||||
const entries = Object.entries(logged);
|
||||
if (entries.length > 100) {
|
||||
const trimmed: LoggedMap = Object.fromEntries(entries.slice(-100));
|
||||
writeLogged(trimmed);
|
||||
} else {
|
||||
writeLogged(logged);
|
||||
}
|
||||
}
|
||||
64
web/lib/watchlist.ts
Normal file
64
web/lib/watchlist.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// 관심종목 — localStorage 기반. 로그인 없이 토스의 '내 종목' 느낌.
|
||||
// 단순 string[] 직렬화. 다중 탭 동기화는 'storage' 이벤트로 처리.
|
||||
|
||||
const KEY = "stockchart.watchlist";
|
||||
|
||||
function read(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(codes: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(KEY, JSON.stringify(codes));
|
||||
// 같은 탭은 storage 이벤트가 안 떠서 수동 dispatch.
|
||||
window.dispatchEvent(new CustomEvent("watchlist:change"));
|
||||
}
|
||||
|
||||
export const watchlist = {
|
||||
get(): string[] {
|
||||
return read();
|
||||
},
|
||||
has(code: string): boolean {
|
||||
return read().includes(code);
|
||||
},
|
||||
add(code: string): void {
|
||||
const cur = read();
|
||||
if (cur.includes(code)) return;
|
||||
write([code, ...cur]);
|
||||
},
|
||||
remove(code: string): void {
|
||||
const cur = read();
|
||||
write(cur.filter((c) => c !== code));
|
||||
},
|
||||
toggle(code: string): boolean {
|
||||
const cur = read();
|
||||
if (cur.includes(code)) {
|
||||
write(cur.filter((c) => c !== code));
|
||||
return false;
|
||||
}
|
||||
write([code, ...cur]);
|
||||
return true;
|
||||
},
|
||||
// 컴포넌트가 변경 구독. unsubscribe 함수 반환.
|
||||
subscribe(cb: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === KEY) cb();
|
||||
};
|
||||
const onCustom = () => cb();
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("watchlist:change", onCustom as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("watchlist:change", onCustom as EventListener);
|
||||
};
|
||||
},
|
||||
};
|
||||
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.
|
||||
|
||||
433
web/package-lock.json
generated
433
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"
|
||||
"@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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
28
web/public/icon.svg
Normal file
28
web/public/icon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="주식 차트">
|
||||
<!-- maskable safe-zone: 중심 80% (102.4 ~ 409.6) 안에 핵심 요소를 둠. -->
|
||||
<rect width="512" height="512" rx="96" fill="#09090b"/>
|
||||
<!-- 격자 가이드 (옅게) -->
|
||||
<g stroke="#27272a" stroke-width="2">
|
||||
<line x1="64" y1="160" x2="448" y2="160"/>
|
||||
<line x1="64" y1="256" x2="448" y2="256"/>
|
||||
<line x1="64" y1="352" x2="448" y2="352"/>
|
||||
</g>
|
||||
<!-- 캔들 (한국 컬러: 상승=빨강 / 하락=파랑) -->
|
||||
<g>
|
||||
<!-- 하락 -->
|
||||
<line x1="144" y1="120" x2="144" y2="300" stroke="#3b82f6" stroke-width="6"/>
|
||||
<rect x="124" y="180" width="40" height="100" fill="#3b82f6"/>
|
||||
<!-- 상승 -->
|
||||
<line x1="224" y1="160" x2="224" y2="360" stroke="#ef4444" stroke-width="6"/>
|
||||
<rect x="204" y="200" width="40" height="140" fill="#ef4444"/>
|
||||
<!-- 하락 -->
|
||||
<line x1="304" y1="200" x2="304" y2="380" stroke="#3b82f6" stroke-width="6"/>
|
||||
<rect x="284" y="240" width="40" height="120" fill="#3b82f6"/>
|
||||
<!-- 상승 (가장 큼 — 우상향 인상) -->
|
||||
<line x1="384" y1="100" x2="384" y2="340" stroke="#ef4444" stroke-width="6"/>
|
||||
<rect x="364" y="140" width="40" height="180" fill="#ef4444"/>
|
||||
</g>
|
||||
<!-- 추세선 (사선 우상향) -->
|
||||
<polyline points="80,380 160,330 240,280 320,260 400,180 448,140"
|
||||
fill="none" stroke="#fbbf24" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
21
web/public/manifest.webmanifest
Normal file
21
web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "주식 차트 · 예측",
|
||||
"short_name": "주식 차트",
|
||||
"description": "한국 주식 차트와 단기 예측을 보는 개인용 앱.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#09090b",
|
||||
"theme_color": "#09090b",
|
||||
"lang": "ko",
|
||||
"categories": ["finance", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
web/public/sw.js
Normal file
13
web/public/sw.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// 최소 서비스워커 — PWA install 자격 (manifest + SW) 만 충족시킨다.
|
||||
// fetch handler 가 없어 네트워크 요청을 가로채지 않음 → 정상 동작은 그대로,
|
||||
// 캐시 stale 위험 없음. 향후 오프라인 셸/사진 캐싱이 필요하면 여기에 추가.
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// 이전 버전 SW 를 즉시 교체. 한 페이지 새로고침이면 새 SW 가 active 가 됨.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
// 모든 열린 탭의 client 를 즉시 control 하도록.
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
Reference in New Issue
Block a user