Skip to content
626 changes: 626 additions & 0 deletions docs/superpowers/plans/2026-06-20-kr-market.md

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions docs/superpowers/specs/2026-06-20-kr-market-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 국장(국내 증시) 탭 — 설계

- 작성일: 2026-06-20
- 상태: 승인됨 (구현 계획 작성 단계로 진행)
- 브랜치: feat/kr-market (develop 기반)

## 배경 / 목표

현재 popol은 미국 주식만 검색·분석한다. 같은 키리스 Yahoo v8 chart 엔드포인트가
한국 종목(`.KS` 코스피, `.KQ` 코스닥)도 KRW로 완전히 지원함을 확인했다(삼성전자
005930.KS → price 354,000 KRW, 일봉 485개). RSI·이동평균·매수존·이격도/매매신호는
가격 시계열 기반이라 시장 무관하게 동작한다.

목표: **US / 국장 탭**으로 시장을 구분하고, 국장에서는 **공포지수·금리·밸류에이션을
숨긴다.** 한국 종목은 ₩ 네이티브로 표시한다. 한글명 검색을 지원한다.

비목표(YAGNI): 한국장 장중 세션 시간 계산, 한국 매크로 지표(코스피 F&G 등), 전 종목
한글명 커버리지, 새 외부 API/키.

## 핵심 접근: 하이브리드

- **탭(US/국장)** → 검색 범위 + 매크로 카드(공포지수·금리·환율) 노출 여부를 결정.
- **종목의 통화(Yahoo `meta.currency`)** → 가격 표시 형식 + 밸류에이션 숨김을 결정.
`/stock/[ticker]` 직접 진입 시에도 시장을 올바로 판별하기 위함(탭 상태가 없는 경로).

## 시장 판별 규칙

- `meta.currency === "KRW"` → `market = "KR"`, 그 외 → `market = "US"`.
- 보조: 티커가 `.KS`/`.KQ`로 끝나면 KR(검색/Finnhub 생략 판단에 사용). 최종 판별은 통화 기준.

## 컴포넌트 설계

### 1. `src/lib/types.ts`
- `Quote`에 `currency: string` 추가.
- `EnrichedStock`에 `market: "US" | "KR"` 추가.

### 2. `src/lib/providers/yahoo.ts`
- `meta.currency`를 `Quote.currency`에 담는다(없으면 `"USD"` 기본).

### 3. `src/lib/enrich.ts`
- `market = d.quote?.currency === "KRW" ? "KR" : "US"`.
- `priceKrw`: **US만** `price * usdKrw`. KR이면 `null`(이미 원화, UI가 price를 ₩로 표시).
- 나머지 계산(RSI/SMA/zones/disparity/signal/range52w/volumeSpike) 변경 없음.

### 4. `src/lib/krStocks.ts` (신규)
- 코스피/코스닥 **주요 종목** `{ ticker: string; krName: string; aliases?: string[] }[]` 큐레이션 테이블.
- `resolveKrName(query: string): SymbolMatch[]` — 공백/대소문자/영문동의어 정규화 후 부분일치 조회.
반환은 기존 `SymbolMatch {symbol,name,exchange}` 형태(exchange는 접미사에서 `.KS`→"코스피", `.KQ`→"코스닥").
- 시드(아래)는 구현 시 **각 코드를 Yahoo 차트로 검증**(KRW 반환 + 이름 일치)하고 불일치는 수정/제거.
대형주 중심 시드(.KS=코스피, .KQ=코스닥): 삼성전자 005930.KS, SK하이닉스 000660.KS,
현대차 005380.KS, 기아 000270.KS, LG에너지솔루션 373220.KS, 삼성바이오로직스 207940.KS,
셀트리온 068270.KS, NAVER 035420.KS, 카카오 035720.KS, 현대모비스 012330.KS,
POSCO홀딩스 005490.KS, KB금융 105560.KS, 신한지주 055550.KS, 삼성SDI 006400.KS,
LG화학 051910.KS, 삼성물산 028260.KS, 한국전력 015760.KS, 삼성생명 032830.KS,
하나금융지주 086790.KS, LG전자 066570.KS, 에코프로비엠 247540.KQ, 에코프로 086520.KQ,
알테오젠 196170.KQ, 카카오게임즈 293490.KQ, 펄어비스 263750.KQ, 리노공업 058470.KQ,
JYP Ent. 035900.KQ. (확장 가능 — 테이블 밖 종목은 코드/영문으로 Yahoo 검색.)

### 5. `src/lib/providers/search.ts` + `src/app/api/search/route.ts`
- `searchSymbols(query, market: "US" | "KR")`:
- **KR**: ① `resolveKrName(query)` 히트가 있으면 그 결과 우선. ② 그리고/또는 Yahoo 검색
결과 중 **한국 거래소(symbol이 `.KS`/`.KQ`로 끝나거나 exchange가 한국)**만 남긴다.
한글 쿼리는 Yahoo가 0건이므로 ①이 담당, 영문/코드는 ②가 담당. 중복은 symbol로 dedupe.
- **US**: Yahoo 검색에서 `.KS`/`.KQ`(한국) 제외.
- `/api/search`는 `market` 쿼리파라미터(기본 "US")를 받아 전달.

### 6. `src/components/SearchBox.tsx`
- `market: "US" | "KR"` prop 추가 → `/api/search?...&market=` 전달, placeholder를 시장별로
(US: "AAPL, tesla, NVDA" / KR: "삼성전자, 005930, SK하이닉스").

### 7. `src/app/page.tsx`
- `market` 상태("US" 기본) + 상단 `US / 국장` 토글 탭.
- **국장 탭: `MacroDial` 미렌더**(공포지수·금리·환율 숨김).
- `SearchBox`에 `market` 전달.
- 인기종목·최근검색을 **탭별 분리**: `POPULAR_KR`(신규) 표시, 최근검색은 시장별 localStorage 키
(`recent-tickers-us` / `recent-tickers-kr`).
- 미국장 세션 라벨은 US 탭에서만(국장 탭 숨김).

### 8. `src/components/StockAnalysis.tsx`
- `market === "KR"`: 가격 `₩{price.toLocaleString()}` 네이티브, `$`/환산 라인 없음;
**`ValuationBadges` 미렌더**.
- `market === "US"`: 기존 동작($ + ₩ 환산 + 밸류에이션).
- RSI·매수존·이동평균·매매신호는 양쪽 공통.

### 9. `src/lib/stockData.ts`
- KR 종목(티커 `.KS`/`.KQ`)이면 Finnhub `getValuation` 호출 생략(어차피 숨김) — `null` 밸류에이션.

### 10. `src/lib/popular.ts`
- `POPULAR_KR` 추가(주요 한국 종목, krStocks 시드에서 선별). 기존 `POPULAR_TICKERS`는 US로 유지.

## 데이터 흐름

```
탭(market) ──► SearchBox ──► /api/search?market ──► searchSymbols(q, market)
│ KR: resolveKrName + Yahoo(.KS/.KQ)
│ US: Yahoo(한국 제외)
선택 ticker ──► /api/stock ──► getEnrichedStock
│ getYahooData → meta.currency
│ KR이면 Finnhub 생략
enrichStock → market, priceKrw(US만)
page: market==="US"이면 MacroDial 렌더
StockAnalysis: market로 가격형식 + ValuationBadges 노출 분기
```

## 엣지 케이스

- `meta.currency` 누락 → "USD"로 간주(기존 US 동작).
- KR 종목 데이터 부족(상장 이력 짧음) → 기존 null 가드대로 해당 지표 "—".
- 한글 검색 무매칭 + Yahoo 0건 → 빈 결과(기존 처리).
- 6자리 코드 입력 → Yahoo 검색이 `.KS`/`.KQ` 자동 부여(코드 검색 동작 확인됨).
- `/stock/[ticker]` 직접 진입 KR 종목 → 통화 기준으로 ₩ 표시·밸류에이션 숨김(탭 무관).

## 테스트 (Vitest, test/ 디렉터리)

- `enrich`: currency "KRW" → `market:"KR"`, `priceKrw` null; "USD" → `market:"US"`, priceKrw 환산.
- `krStocks`: `resolveKrName` — 정확명/별칭/공백·대소문자 정규화 → 올바른 symbol; 무매칭 → [].
- `search` 순수 필터: KR 결과에서 비한국 제외 / US 결과에서 `.KS`·`.KQ` 제외 (네트워크 분리, 필터 함수 단위 테스트).

## 변경 범위 요약

- 수정: types, yahoo, enrich, stockData, search(provider+route), SearchBox, page, StockAnalysis, popular
- 신규: krStocks.ts (+ 테스트: enrich 보강, krStocks, search 필터)
- 새 외부 API·키: 없음
6 changes: 4 additions & 2 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { NextResponse } from "next/server";
import { searchSymbols } from "@/lib/providers/search";

export async function GET(req: Request) {
const q = new URL(req.url).searchParams.get("q") ?? "";
const matches = await searchSymbols(q);
const params = new URL(req.url).searchParams;
const q = params.get("q") ?? "";
const market = params.get("market") === "KR" ? "KR" : "US";
const matches = await searchSymbols(q, market);
return NextResponse.json({ matches });
}
43 changes: 31 additions & 12 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { MacroDial } from "@/components/MacroDial";
import { ThemeToggle } from "@/components/ThemeToggle";
import { usSession } from "@/lib/marketHours";
import Link from "next/link";
import { POPULAR_TICKERS } from "@/lib/popular";
import { POPULAR_TICKERS, POPULAR_KR } from "@/lib/popular";

const RECENT_KEY = "recent-tickers";
const recentKey = (m: "US" | "KR") => `recent-tickers-${m.toLowerCase()}`;

export default function Page() {
const [macro, setMacro] = useState<MacroData | null>(null);
Expand All @@ -18,16 +18,22 @@ export default function Page() {
const [error, setError] = useState<string | null>(null);
const [recent, setRecent] = useState<Picked[]>([]);
const [session, setSession] = useState("");
const [market, setMarket] = useState<"US" | "KR">("US");

useEffect(() => {
fetch("/api/macro").then((r) => r.json()).then(setMacro).catch(() => {});
setSession(usSession(Date.now()).label);
try {
const r = localStorage.getItem(RECENT_KEY);
if (r) setRecent(JSON.parse(r));
} catch {}
}, []);

useEffect(() => {
try {
const r = localStorage.getItem(recentKey(market));
setRecent(r ? JSON.parse(r) : []);
} catch {
setRecent([]);
}
}, [market]);

const analyze = useCallback(async (p: Picked) => {
setLoading(true);
setError(null);
Expand All @@ -42,7 +48,7 @@ export default function Page() {
setRecent((prev) => {
const next = [p, ...prev.filter((x) => x.symbol !== p.symbol)].slice(0, 8);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
localStorage.setItem(recentKey(market), JSON.stringify(next));
} catch {}
return next;
});
Expand All @@ -52,17 +58,30 @@ export default function Page() {
} finally {
setLoading(false);
}
}, []);
}, [market]);

return (
<main className="mx-auto max-w-md pb-10">
<div className="flex items-center justify-between px-4 pt-3">
<h1 className="text-lg font-bold">종목 분석</h1>
<ThemeToggle />
</div>
{session && <p className="px-4 pb-1 text-xs text-zinc-500">미국장 {session}</p>}
<MacroDial macro={macro} />
<SearchBox onPick={analyze} />
<div className="flex gap-1 px-4 pt-2">
{(["US", "KR"] as const).map((m) => (
<button
key={m}
onClick={() => { setMarket(m); setStock(null); setError(null); }}
className={`rounded-full px-3 py-1 text-sm font-medium ${
market === m ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800"
}`}
>
{m === "US" ? "미국장" : "국장"}
</button>
))}
</div>
{market === "US" && session && <p className="px-4 pb-1 text-xs text-zinc-500">미국장 {session}</p>}
{market === "US" && <MacroDial macro={macro} />}
<SearchBox onPick={analyze} market={market} />

{recent.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 py-1">
Expand Down Expand Up @@ -96,7 +115,7 @@ export default function Page() {
<nav className="px-3 pt-6" aria-label="인기 종목">
<div className="mb-2 px-1 text-xs text-zinc-500">인기 종목 바로 분석</div>
<div className="flex flex-wrap gap-2">
{POPULAR_TICKERS.map((p) => (
{(market === "US" ? POPULAR_TICKERS : POPULAR_KR).map((p) => (
<Link
key={p.ticker}
href={`/stock/${p.ticker}`}
Expand Down
15 changes: 12 additions & 3 deletions src/app/stock/[ticker]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getEnrichedStock } from "@/lib/stockData";
import { POPULAR_TICKERS } from "@/lib/popular";
import { POPULAR_TICKERS, POPULAR_KR } from "@/lib/popular";
import { KR_STOCKS } from "@/lib/krStocks";
import { StockAnalysis } from "@/components/StockAnalysis";

type Params = { params: Promise<{ ticker: string }> };
Expand All @@ -11,7 +12,10 @@ type Params = { params: Promise<{ ticker: string }> };
export const dynamic = "force-dynamic";

// Chart meta has no company name; resolve display name for seed tickers.
const displayName = (t: string) => POPULAR_TICKERS.find((p) => p.ticker === t)?.name;
const displayName = (t: string) =>
POPULAR_TICKERS.find((p) => p.ticker === t)?.name ??
POPULAR_KR.find((p) => p.ticker === t)?.name ??
KR_STOCKS.find((s) => s.ticker === t)?.krName;

export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { ticker } = await params;
Expand All @@ -20,7 +24,12 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
if (!stock) {
return { title: `${t} — 종목을 찾을 수 없음`, robots: { index: false, follow: false } };
}
const price = stock.price != null ? `$${stock.price.toFixed(2)}` : "";
const price =
stock.price != null
? stock.market === "KR"
? `₩${Math.round(stock.price).toLocaleString()}`
: `$${stock.price.toFixed(2)}`
: "";
const rsi = stock.rsi14 != null ? `RSI ${stock.rsi14.toFixed(0)}` : "";
const cross =
stock.crossState === "golden" ? "골든크로스" : stock.crossState === "death" ? "데드크로스" : "";
Expand Down
8 changes: 4 additions & 4 deletions src/components/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";

export type Picked = { symbol: string; name: string };

export function SearchBox({ onPick }: { onPick: (p: Picked) => void }) {
export function SearchBox({ onPick, market }: { onPick: (p: Picked) => void; market: "US" | "KR" }) {
const [q, setQ] = useState("");
const [matches, setMatches] = useState<{ symbol: string; name: string; exchange: string }[]>([]);
const [open, setOpen] = useState(false);
Expand All @@ -20,7 +20,7 @@ export function SearchBox({ onPick }: { onPick: (p: Picked) => void }) {
timer.current = setTimeout(async () => {
setLoading(true);
try {
const r = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&market=${market}`);
const j = await r.json();
setMatches(j.matches ?? []);
setOpen(true);
Expand All @@ -33,7 +33,7 @@ export function SearchBox({ onPick }: { onPick: (p: Picked) => void }) {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, [q]);
}, [q, market]);

const pick = (symbol: string, name: string) => {
setOpen(false);
Expand All @@ -56,7 +56,7 @@ export function SearchBox({ onPick }: { onPick: (p: Picked) => void }) {
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => matches.length > 0 && setOpen(true)}
placeholder="종목 검색 (예: AAPL, tesla, NVDA)"
placeholder={market === "KR" ? "종목 검색 (예: 삼성전자, 005930, SK하이닉스)" : "종목 검색 (예: AAPL, tesla, NVDA)"}
className="w-full rounded-xl border border-zinc-300 bg-transparent px-4 py-3 text-base outline-none focus:border-zinc-500 dark:border-zinc-700"
autoComplete="off"
/>
Expand Down
10 changes: 7 additions & 3 deletions src/components/StockAnalysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,18 @@ export function StockAnalysis({ stock }: { stock: EnrichedStock }) {
</h2>
{stock.price != null && (
<p className="mt-0.5">
<span className="text-lg font-semibold tabular-nums">${stock.price.toFixed(2)}</span>
{stock.market === "KR" ? (
<span className="text-lg font-semibold tabular-nums">₩{Math.round(stock.price).toLocaleString()}</span>
) : (
<span className="text-lg font-semibold tabular-nums">${stock.price.toFixed(2)}</span>
)}
{stock.changePct != null && (
<span className={`ml-2 text-sm ${stock.changePct >= 0 ? "text-green-600" : "text-red-500"}`}>
{stock.changePct >= 0 ? "+" : ""}
{stock.changePct.toFixed(2)}%
</span>
)}
{stock.priceKrw != null && (
{stock.market === "US" && stock.priceKrw != null && (
<span className="ml-2 text-sm text-zinc-500">₩{Math.round(stock.priceKrw).toLocaleString()}</span>
)}
</p>
Expand Down Expand Up @@ -94,7 +98,7 @@ export function StockAnalysis({ stock }: { stock: EnrichedStock }) {
</div>
</div>

<ValuationBadges stock={stock} />
{stock.market === "US" && <ValuationBadges stock={stock} />}
</div>
);
}
4 changes: 3 additions & 1 deletion src/lib/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type EnrichInput = {

export function enrichStock(item: WatchlistItem, d: EnrichInput): EnrichedStock {
const price = d.quote?.price ?? null;
const market: "US" | "KR" = d.quote?.currency === "KRW" ? "KR" : "US";
const changePct =
d.quote && d.quote.previousClose
? ((d.quote.price - d.quote.previousClose) / d.quote.previousClose) * 100
Expand All @@ -35,7 +36,8 @@ export function enrichStock(item: WatchlistItem, d: EnrichInput): EnrichedStock

return {
ticker: item.ticker, name: item.name,
price, priceKrw: price != null && d.usdKrw != null ? price * d.usdKrw : null, changePct, isStale: d.isStale,
price, priceKrw: market === "US" && price != null && d.usdKrw != null ? price * d.usdKrw : null, changePct, isStale: d.isStale,
market,
high, highSource,
zones, nearestUnreached, zoneStatus,
drawdownPct: price != null && high > 0 ? drawdownPct(high, price) : 0,
Expand Down
Loading
Loading