feat(peers): 동종업종(테마) 비교 패널
- backend/app/seed/themes.py: themes_for_code(code) 헬퍼 추가 (코드 → 소속 테마들)
- backend/app/api/themes.py: GET /api/themes/peer/{code} 신규
· 종목이 속한 모든 테마의 코드 합집합을 peer 풀로
· 단일 SQL (ROW_NUMBER OVER PARTITION BY code) 로 21영업일 누적수익률 일괄 계산
· 본인 / peer 평균 / 상·하위 5종목 반환
- web/lib/api.ts: PeerCompare 타입 + api.peerCompare()
- web/components/PeerComparePanel.tsx: 테마 태그 + 3-칸 요약 (본인/평균/격차) + 상·하위 5
- [code]/page.tsx 에 PeriodReturns 아래 부착. 테마 무소속 종목은 패널 자동 숨김.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ 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_index
|
||||
from app.seed.themes import THEMES, theme_by_slug, themes_for_code, themes_index
|
||||
|
||||
router = APIRouter(prefix="/api/themes", tags=["themes"])
|
||||
|
||||
@@ -27,6 +27,112 @@ 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)
|
||||
|
||||
@@ -229,3 +229,8 @@ def theme_by_slug(slug: str) -> Theme | None:
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user