diff --git a/docs/superpowers/plans/2026-06-20-kr-market.md b/docs/superpowers/plans/2026-06-20-kr-market.md new file mode 100644 index 0000000..e0c5398 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-kr-market.md @@ -0,0 +1,626 @@ +# 국장(KR market) 탭 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** US/국장 탭으로 시장을 구분해 한국 종목(.KS/.KQ)을 검색·분석하되, 국장에서는 공포지수·금리·밸류에이션을 숨기고 ₩ 네이티브로 표시한다. + +**Architecture:** 시장은 Yahoo `meta.currency`(KRW/USD)로 판별해 데이터층에 `market` 필드를 싣는다(종목 단위, `/stock/[ticker]` 직접 진입도 견고). 탭은 검색 범위와 매크로 카드 노출만 제어한다. 한글 검색은 큐레이션 매핑(`krStocks.ts`) + Yahoo(.KS/.KQ 필터) 결합. 새 외부 API/키 없음. + +**Tech Stack:** Next.js 15 (App Router) · TypeScript · Tailwind · Vitest · path alias `@/` → `src/` + +## Global Constraints + +- 외부 호출은 전부 서버 라우트(`/api/*`)에서만. 이 기능은 새 외부 호출/키를 추가하지 않는다(Yahoo 재사용). +- 테스트는 `test/` 디렉터리(코로케이션 아님). 실행: `npm test` (= `vitest run`), 파일 한정: `npm test -- `. +- 시장 판별: `meta.currency === "KRW"` → `market="KR"`, 그 외 → `"US"`. +- KR 종목: 가격 ₩ 네이티브(환산 없음), 밸류에이션 숨김. US 종목: 기존 $ + ₩ 환산 + 밸류에이션. +- 국장 탭: 매크로 카드(`MacroDial` — 공포지수·금리·환율) 미렌더. +- `.KS` = 코스피, `.KQ` = 코스닥. +- 커밋 규칙: 사용자 RULES.md "명시 요청 없이 자동 커밋 금지". 커밋 스텝은 실행 단계에서 사용자가 진행 승인했을 때 수행(이 기능 구현은 승인됨). 브랜치 `feat/kr-market`에서만 작업, 변경 파일만 명시 경로로 stage(`git add .` 금지 — 동시 작업 가능성). + +--- + +### Task 1: 통화/시장 데이터층 + +**Files:** +- Modify: `src/lib/providers/yahoo.ts` (add `currency` to `Quote`, capture `meta.currency`) +- Modify: `src/lib/types.ts` (add `market` to `EnrichedStock`) +- Modify: `src/lib/enrich.ts` (compute `market`, gate `priceKrw`) +- Test: `test/enrich.test.ts` (add currency to existing quotes; assert market/priceKrw) + +**Interfaces:** +- Consumes: nothing new +- Produces: + - `Quote.currency: string` (in `yahoo.ts`) + - `EnrichedStock.market: "US" | "KR"` (in `types.ts`) + +- [ ] **Step 1: Add `currency` to `Quote` and capture it in `yahoo.ts`** + +In `src/lib/providers/yahoo.ts`, extend the `Quote` type (currently ends with `marketTime: number;`): + +```ts +export type Quote = { + price: number; previousClose: number; volume: number; + high52: number; low52: number; marketTime: number; + currency: string; +}; +``` + +In `getYahooData`, inside the `const quote: Quote = { ... }` object literal, add a `currency` field: + +```ts + marketTime: meta.regularMarketTime ? meta.regularMarketTime * 1000 : Date.now(), + currency: meta.currency ?? "USD", +``` + +- [ ] **Step 2: Add `market` to `EnrichedStock` in `types.ts`** + +In `src/lib/types.ts`, in the `EnrichedStock` type, on the line after `price: number | null; priceKrw: number | null; changePct: number | null; isStale: boolean;` add: + +```ts + market: "US" | "KR"; +``` + +- [ ] **Step 3: Write failing assertions in `test/enrich.test.ts`** + +First, the existing quotes now need a `currency` field (the type requires it). Update BOTH existing quote literals: the first test's quote gets `currency: "USD"`, and the second test's quote gets `currency: "USD"`. Add them like: + +```ts + quote: { price: 85, previousClose: 86, volume: 2000, high52: 100, low52: 70, marketTime: Date.now(), currency: "USD" }, +``` +and +```ts + { quote: { price: 85, previousClose: 86, volume: 1000, high52: 100, low52: 70, marketTime: 0, currency: "USD" }, +``` + +Then append to the FIRST test ("combines config + market data into an EnrichedStock") assertions: + +```ts + expect(s.market).toBe("US"); + expect(s.priceKrw).toBe(85 * 1500); +``` + +Then add a NEW test for a Korean stock: + +```ts +it("treats KRW-currency quotes as KR market with no won conversion", () => { + const krCloses = Array.from({ length: 250 }, (_, i) => 70000 + i * 10); + const s = enrichStock( + { ticker: "005930.KS", name: "삼성전자", buyThesis: "", sellRisk: "", theme: { aiInfra: false, trump: false, tariff: false }, highOverride: null }, + { quote: { price: 75000, previousClose: 74000, volume: 1000, high52: 90000, low52: 60000, marketTime: 0, currency: "KRW" }, + history: { closes: krCloses, volumes: krCloses.map(() => 1000) }, + valuation: { per: null, psr: null, pbr: null, roe: null }, + usdKrw: 1500, thresholds: DEFAULT_THRESHOLDS, isStale: false }, + ); + expect(s.market).toBe("KR"); + expect(s.priceKrw).toBeNull(); + expect(s.price).toBe(75000); +}); +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `npm test -- enrich` +Expected: FAIL — `s.market` is `undefined` (field/logic not implemented). + +- [ ] **Step 5: Implement in `src/lib/enrich.ts`** + +After `const sma200 = sma(closes, 200);` (and the disparity lines), add the market computation near the top of the function — place it right after `const price = d.quote?.price ?? null;`: + +```ts + const market: "US" | "KR" = d.quote?.currency === "KRW" ? "KR" : "US"; +``` + +Change the `priceKrw` field in the returned object. Current: +```ts + price, priceKrw: price != null && d.usdKrw != null ? price * d.usdKrw : null, changePct, isStale: d.isStale, +``` +to (gate on US, and add `market`): +```ts + price, priceKrw: market === "US" && price != null && d.usdKrw != null ? price * d.usdKrw : null, changePct, isStale: d.isStale, + market, +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test -- enrich` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/providers/yahoo.ts src/lib/types.ts src/lib/enrich.ts test/enrich.test.ts +git commit -m "feat: detect market via currency; KR uses native KRW (no won conversion)" +``` + +--- + +### Task 2: 한글명 매핑 `krStocks.ts` + +**Files:** +- Create: `src/lib/krStocks.ts` +- Test: `test/krStocks.test.ts` + +**Interfaces:** +- Consumes: `SymbolMatch` type (type-only import from `@/lib/providers/search`) +- Produces: + - `type KrStock = { ticker: string; krName: string; aliases?: string[] }` + - `KR_STOCKS: KrStock[]` + - `resolveKrName(query: string): SymbolMatch[]` (exchange: `.KQ`→"코스닥" else "코스피") + +- [ ] **Step 1: Write the failing test in `test/krStocks.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; +import { resolveKrName } from "@/lib/krStocks"; + +describe("resolveKrName", () => { + it("resolves an exact Korean name to its ticker", () => { + const r = resolveKrName("삼성전자"); + expect(r.some((m) => m.symbol === "005930.KS")).toBe(true); + }); + it("ignores surrounding spaces and is case-insensitive", () => { + expect(resolveKrName(" 삼성 전자 ").some((m) => m.symbol === "005930.KS")).toBe(true); + }); + it("partial query matches multiple Samsung names", () => { + const syms = resolveKrName("삼성").map((m) => m.symbol); + expect(syms).toContain("005930.KS"); + expect(syms.length).toBeGreaterThan(1); + }); + it("labels exchange from suffix", () => { + const ecopro = resolveKrName("에코프로비엠").find((m) => m.symbol === "247540.KQ"); + expect(ecopro?.exchange).toBe("코스닥"); + const samsung = resolveKrName("삼성전자").find((m) => m.symbol === "005930.KS"); + expect(samsung?.exchange).toBe("코스피"); + }); + it("returns [] for empty or unknown queries", () => { + expect(resolveKrName("")).toEqual([]); + expect(resolveKrName("zzzznotarealstock")).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- krStocks` +Expected: FAIL — cannot find module `@/lib/krStocks`. + +- [ ] **Step 3: Implement `src/lib/krStocks.ts`** + +```ts +import type { SymbolMatch } from "@/lib/providers/search"; + +export type KrStock = { ticker: string; krName: string; aliases?: string[] }; + +/** 코스피/코스닥 주요 종목 큐레이션 (확장 가능). .KS=코스피, .KQ=코스닥. + * 주의: 각 코드는 Yahoo 차트(KRW 반환)로 검증할 것 — Step 5 참고. */ +export const KR_STOCKS: KrStock[] = [ + { ticker: "005930.KS", krName: "삼성전자" }, + { ticker: "000660.KS", krName: "SK하이닉스" }, + { ticker: "005380.KS", krName: "현대차" }, + { ticker: "000270.KS", krName: "기아" }, + { ticker: "373220.KS", krName: "LG에너지솔루션" }, + { ticker: "207940.KS", krName: "삼성바이오로직스" }, + { ticker: "068270.KS", krName: "셀트리온" }, + { ticker: "035420.KS", krName: "NAVER", aliases: ["네이버"] }, + { ticker: "035720.KS", krName: "카카오" }, + { ticker: "012330.KS", krName: "현대모비스" }, + { ticker: "005490.KS", krName: "POSCO홀딩스", aliases: ["포스코홀딩스", "포스코"] }, + { ticker: "105560.KS", krName: "KB금융" }, + { ticker: "055550.KS", krName: "신한지주" }, + { ticker: "006400.KS", krName: "삼성SDI" }, + { ticker: "051910.KS", krName: "LG화학" }, + { ticker: "028260.KS", krName: "삼성물산" }, + { ticker: "015760.KS", krName: "한국전력", aliases: ["한전"] }, + { ticker: "032830.KS", krName: "삼성생명" }, + { ticker: "086790.KS", krName: "하나금융지주" }, + { ticker: "066570.KS", krName: "LG전자" }, + { ticker: "247540.KQ", krName: "에코프로비엠" }, + { ticker: "086520.KQ", krName: "에코프로" }, + { ticker: "196170.KQ", krName: "알테오젠" }, + { ticker: "293490.KQ", krName: "카카오게임즈" }, + { ticker: "263750.KQ", krName: "펄어비스" }, + { ticker: "058470.KQ", krName: "리노공업" }, + { ticker: "035900.KQ", krName: "JYP Ent.", aliases: ["JYP"] }, +]; + +const norm = (s: string) => s.replace(/\s+/g, "").toLowerCase(); + +export function resolveKrName(query: string): SymbolMatch[] { + const nq = norm(query); + if (!nq) return []; + return KR_STOCKS.filter((s) => { + const hay = [s.krName, ...(s.aliases ?? [])].map(norm); + return hay.some((h) => h.includes(nq)); + }) + .map((s) => ({ + symbol: s.ticker, + name: s.krName, + exchange: s.ticker.endsWith(".KQ") ? "코스닥" : "코스피", + })) + .slice(0, 8); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- krStocks` +Expected: PASS. + +- [ ] **Step 5: Verify ticker codes against Yahoo (data correctness)** + +For a SAMPLE of the table (at least 005930.KS, 247540.KQ, 035420.KS, 000660.KS), confirm Yahoo returns KRW data: + +Run (one example): `curl -s -H "User-Agent: Mozilla/5.0" "https://query2.finance.yahoo.com/v8/finance/chart/247540.KQ?range=5d&interval=1d" | python3 -c "import sys,json;m=json.load(sys.stdin)['chart']['result'][0]['meta'];print(m.get('currency'),m.get('regularMarketPrice'))"` +Expected: `KRW `. If any sampled code returns an error or wrong currency, correct that entry in `KR_STOCKS` and re-run Step 4. Note in the report which codes you verified. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/krStocks.ts test/krStocks.test.ts +git commit -m "feat: krStocks Korean-name → ticker mapping + resolveKrName" +``` + +--- + +### Task 3: 시장별 검색 필터 + +**Files:** +- Modify: `src/lib/providers/search.ts` (add `filterByMarket`, extend `searchSymbols`) +- Modify: `src/app/api/search/route.ts` (read `market` param) +- Test: `test/search.test.ts` (Create) + +**Interfaces:** +- Consumes: `resolveKrName` (Task 2) +- Produces: + - `filterByMarket(matches: SymbolMatch[], market: "US" | "KR"): SymbolMatch[]` + - `searchSymbols(query: string, market?: "US" | "KR"): Promise` + +- [ ] **Step 1: Write the failing test in `test/search.test.ts`** + +```ts +import { describe, it, expect } from "vitest"; +import { filterByMarket } from "@/lib/providers/search"; + +const sample = [ + { symbol: "AAPL", name: "Apple", exchange: "NASDAQ" }, + { symbol: "005930.KS", name: "SamsungElec", exchange: "Korea" }, + { symbol: "247540.KQ", name: "EcoProBM", exchange: "Korea" }, +]; + +describe("filterByMarket", () => { + it("KR keeps only .KS/.KQ symbols", () => { + expect(filterByMarket(sample, "KR").map((m) => m.symbol)).toEqual(["005930.KS", "247540.KQ"]); + }); + it("US excludes .KS/.KQ symbols", () => { + expect(filterByMarket(sample, "US").map((m) => m.symbol)).toEqual(["AAPL"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- search` +Expected: FAIL — `filterByMarket` is not exported. + +- [ ] **Step 3: Rewrite `src/lib/providers/search.ts`** + +Replace the whole file with: + +```ts +import { resolveKrName } from "@/lib/krStocks"; + +export type SymbolMatch = { symbol: string; name: string; exchange: string }; + +const isKrSymbol = (s: string) => /\.(KS|KQ)$/i.test(s); + +export function filterByMarket(matches: SymbolMatch[], market: "US" | "KR"): SymbolMatch[] { + return matches.filter((m) => (market === "KR" ? isKrSymbol(m.symbol) : !isKrSymbol(m.symbol))); +} + +async function fetchYahoo(q: string): Promise { + for (const base of ["https://query2.finance.yahoo.com", "https://query1.finance.yahoo.com"]) { + try { + const url = `${base}/v1/finance/search?q=${encodeURIComponent(q)}"esCount=8&newsCount=0`; + const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" }, cache: "no-store" }); + if (!res.ok) continue; + const json = await res.json(); + const quotes: unknown[] = json?.quotes ?? []; + const out = quotes + .map((x) => x as { symbol?: string; shortname?: string; longname?: string; quoteType?: string; exchDisp?: string; exchange?: string }) + .filter((x) => x.symbol && (x.quoteType === "EQUITY" || x.quoteType === "ETF")) + .map((x) => ({ symbol: x.symbol as string, name: x.shortname || x.longname || (x.symbol as string), exchange: x.exchDisp || x.exchange || "" })); + if (out.length) return out; + } catch { + // try next base + } + } + return []; +} + +export async function searchSymbols(query: string, market: "US" | "KR" = "US"): Promise { + const q = query.trim(); + if (!q) return []; + const yahoo = filterByMarket(await fetchYahoo(q), market); + if (market === "KR") { + const mapped = resolveKrName(q); + const seen = new Set(mapped.map((m) => m.symbol)); + return [...mapped, ...yahoo.filter((m) => !seen.has(m.symbol))].slice(0, 8); + } + return yahoo; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- search` +Expected: PASS. + +- [ ] **Step 5: Update `src/app/api/search/route.ts`** + +Replace the file with: + +```ts +import { NextResponse } from "next/server"; +import { searchSymbols } from "@/lib/providers/search"; + +export async function GET(req: Request) { + 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 }); +} +``` + +- [ ] **Step 6: Typecheck + commit** + +Run: `npx tsc --noEmit` — only pre-existing `.next/types` errors allowed; nothing in search.ts/route.ts. + +```bash +git add src/lib/providers/search.ts src/app/api/search/route.ts test/search.test.ts +git commit -m "feat: market-scoped symbol search (KR resolves Korean names + .KS/.KQ filter)" +``` + +--- + +### Task 4: US/국장 탭 + 매크로 숨김 + 검색/인기/최근 분리 + +**Files:** +- Modify: `src/components/SearchBox.tsx` (add `market` prop) +- Modify: `src/lib/popular.ts` (add `POPULAR_KR`) +- Modify: `src/app/page.tsx` (tab state + conditional macro + per-market recent/popular) + +**Interfaces:** +- Consumes: `searchSymbols` market param (via `/api/search?market=`, Task 3) +- Produces: `SearchBox` now requires a `market: "US" | "KR"` prop; `POPULAR_KR` export. + +- [ ] **Step 1: Add `market` prop to `SearchBox.tsx`** + +Change the signature and fetch URL, re-run search when market changes, and set a market-aware placeholder. + +Signature (line ~6): +```tsx +export function SearchBox({ onPick, market }: { onPick: (p: Picked) => void; market: "US" | "KR" }) { +``` + +In the debounced effect, include `market` in the fetch and deps. Change the fetch line: +```tsx + const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&market=${market}`); +``` +Change the effect dependency array from `[q]` to `[q, market]`. + +Change the input `placeholder`: +```tsx + placeholder={market === "KR" ? "종목 검색 (예: 삼성전자, 005930, SK하이닉스)" : "종목 검색 (예: AAPL, tesla, NVDA)"} +``` + +- [ ] **Step 2: Add `POPULAR_KR` to `src/lib/popular.ts`** + +Match the existing `POPULAR_TICKERS` shape (`{ ticker: string; name: string }[]`). Append: + +```ts +export const POPULAR_KR = [ + { ticker: "005930.KS", name: "삼성전자" }, + { ticker: "000660.KS", name: "SK하이닉스" }, + { ticker: "373220.KS", name: "LG에너지솔루션" }, + { ticker: "035420.KS", name: "NAVER" }, + { ticker: "035720.KS", name: "카카오" }, + { ticker: "005380.KS", name: "현대차" }, + { ticker: "247540.KQ", name: "에코프로비엠" }, + { ticker: "068270.KS", name: "셀트리온" }, +]; +``` + +- [ ] **Step 3: Update `src/app/page.tsx` — tab state, market wiring, conditional macro, per-market recent/popular** + +Add the import for `POPULAR_KR` (line 10 currently imports `POPULAR_TICKERS`): +```tsx +import { POPULAR_TICKERS, POPULAR_KR } from "@/lib/popular"; +``` + +Replace the `RECENT_KEY` constant with a per-market helper: +```tsx +const recentKey = (m: "US" | "KR") => `recent-tickers-${m.toLowerCase()}`; +``` + +Add `market` state (with the other `useState`s): +```tsx + const [market, setMarket] = useState<"US" | "KR">("US"); +``` + +Replace the recent-loading `useEffect` body's localStorage read so it keys off market, and re-run on market change: +```tsx + useEffect(() => { + fetch("/api/macro").then((r) => r.json()).then(setMacro).catch(() => {}); + setSession(usSession(Date.now()).label); + }, []); + + useEffect(() => { + try { + const r = localStorage.getItem(recentKey(market)); + setRecent(r ? JSON.parse(r) : []); + } catch { + setRecent([]); + } + }, [market]); +``` + +In `analyze`, change the localStorage write key from `RECENT_KEY` to `recentKey(market)`: +```tsx + localStorage.setItem(recentKey(market), JSON.stringify(next)); +``` +(Also add `market` to the `useCallback` dependency array: `}, [market]);`.) + +Add the tab toggle UI right after the header `
...
` (after line with ``'s closing div, before the session line). Insert: +```tsx +
+ {(["US", "KR"] as const).map((m) => ( + + ))} +
+``` + +Gate the session line and the macro card on US only. Change: +```tsx + {session &&

미국장 {session}

} + +``` +to: +```tsx + {market === "US" && session &&

미국장 {session}

} + {market === "US" && } +``` + +Pass `market` to SearchBox: +```tsx + +``` + +In the "인기 종목" nav, choose the list by market: +```tsx + {(market === "US" ? POPULAR_TICKERS : POPULAR_KR).map((p) => ( +``` + +- [ ] **Step 4: Typecheck + build** + +Run: `npx tsc --noEmit && npm run build` +Expected: no type errors in changed files; build succeeds. (`npm run lint` is broken project-wide — no ESLint config; skip it.) + +- [ ] **Step 5: Visual check (manual)** + +Run `npm run dev`; toggle 국장 탭 → MacroDial/세션 라벨 사라짐, placeholder가 한글 예시, 인기종목이 한국 종목; "삼성전자" 검색 → 005930.KS 후보 노출. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/SearchBox.tsx src/lib/popular.ts src/app/page.tsx +git commit -m "feat: US/국장 tabs — hide macro on 국장, market-scoped search/popular/recent" +``` + +--- + +### Task 5: 종목 카드 시장별 표시 + KR 밸류에이션 생략 + +**Files:** +- Modify: `src/components/StockAnalysis.tsx` (KR price ₩ native, hide ValuationBadges) +- Modify: `src/lib/stockData.ts` (skip Finnhub for KR tickers) + +**Interfaces:** +- Consumes: `EnrichedStock.market` (Task 1) +- Produces: nothing (final UI/data) + +- [ ] **Step 1: KR-aware price display in `StockAnalysis.tsx`** + +Replace the price `

...

` block (the one rendering `${stock.price.toFixed(2)}`, changePct, and the `₩` priceKrw line). New block branches on `stock.market`: + +```tsx +

+ {stock.market === "KR" ? ( + ₩{Math.round(stock.price).toLocaleString()} + ) : ( + ${stock.price.toFixed(2)} + )} + {stock.changePct != null && ( + = 0 ? "text-green-600" : "text-red-500"}`}> + {stock.changePct >= 0 ? "+" : ""} + {stock.changePct.toFixed(2)}% + + )} + {stock.market === "US" && stock.priceKrw != null && ( + ₩{Math.round(stock.priceKrw).toLocaleString()} + )} +

+``` + +- [ ] **Step 2: Hide `ValuationBadges` for KR** + +Change the final `` line to: +```tsx + {stock.market === "US" && } +``` + +- [ ] **Step 3: Skip Finnhub for KR tickers in `stockData.ts`** + +In `getEnrichedStock`, before the `Promise.all`, add a KR check, and use it to short-circuit valuation: + +```ts + const isKr = /\.(KS|KQ)$/i.test(ticker); + const [yahoo, valuation, usdKrw] = await Promise.all([ + getYahooData(ticker), + isKr ? Promise.resolve({ per: null, psr: null, pbr: null, roe: null }) : getValuation(ticker, finnhubKey), + getUsdKrw(), + ]); +``` + +(Replace the existing `getValuation(ticker, finnhubKey)` line inside the `Promise.all` with the ternary above; add the `const isKr` line above it.) + +- [ ] **Step 4: Typecheck + build** + +Run: `npx tsc --noEmit && npm run build` +Expected: no type errors in changed files; build succeeds. + +- [ ] **Step 5: Visual check (manual)** + +`npm run dev`; 국장 탭에서 삼성전자(005930.KS) 분석 → 가격이 `₩75,000`류로 표시, $/환산 없음, 밸류에이션 배지 없음; RSI·매수존·이동평균·매매신호는 정상 표시. 미국 종목(AAPL)은 기존대로($+₩+밸류에이션). + +- [ ] **Step 6: Commit** + +```bash +git add src/components/StockAnalysis.tsx src/lib/stockData.ts +git commit -m "feat: KR stocks show native ₩ and hide valuation; skip Finnhub for KR" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- 시장 판별(currency) → Task 1 ✅ +- KR ₩ 네이티브 / priceKrw 미환산 → Task 1(enrich) + Task 5(UI) ✅ +- `market` 필드 → Task 1 ✅ +- 한글 검색(krStocks 매핑 + 코드 검증) → Task 2 ✅ +- 시장별 검색 필터 + 한글명 병합 + /api/search market param → Task 3 ✅ +- US/국장 탭 + 매크로 숨김 + 탭별 인기/최근 + SearchBox market → Task 4 ✅ +- KR 밸류에이션 숨김 + 가격형식 → Task 5 ✅ +- KR Finnhub 호출 생략 → Task 5 ✅ +- RSI/매수존/이평선/매매신호 시장 공통(무변경) → 어느 태스크도 계산 로직 변경 안 함 ✅ +- 새 외부 API/키 없음 → 확인 ✅ +- 테스트: enrich(market), krStocks(resolveKrName), search(filterByMarket) → Tasks 1/2/3 ✅ + +**2. Placeholder scan:** TBD/TODO 없음. 모든 코드 스텝에 실제 코드. UI 태스크는 단위테스트 없는 대신 typecheck+build+수동 시각확인 명시. ✅ + +**3. Type consistency:** +- `market: "US" | "KR"` — types.ts 정의 → enrich 반환 → StockAnalysis/page 사용까지 동일 리터럴 유니온. +- `Quote.currency: string` — yahoo.ts 정의 → enrich가 `d.quote?.currency` 읽음 → enrich 테스트 quote에 포함. +- `SymbolMatch {symbol,name,exchange}` — search.ts 정의 → krStocks가 type-only import해 `resolveKrName` 반환 → search가 병합. 순환참조 없음(krStocks는 type-only import, search는 value import). +- `filterByMarket`/`searchSymbols(query, market)` 시그니처 — Task 3 정의와 Task 4 사용(`?market=`) 일치. +- `resolveKrName(query): SymbolMatch[]` — Task 2 정의 = Task 3 사용 일치. diff --git a/docs/superpowers/specs/2026-06-20-kr-market-design.md b/docs/superpowers/specs/2026-06-20-kr-market-design.md new file mode 100644 index 0000000..39620e2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-kr-market-design.md @@ -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·키: 없음 diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 7911f97..d8ceddf 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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 }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index f5800dd..94377ba 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(null); @@ -18,16 +18,22 @@ export default function Page() { const [error, setError] = useState(null); const [recent, setRecent] = useState([]); 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); @@ -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; }); @@ -52,7 +58,7 @@ export default function Page() { } finally { setLoading(false); } - }, []); + }, [market]); return (
@@ -60,9 +66,22 @@ export default function Page() {

종목 분석

- {session &&

미국장 {session}

} - - +
+ {(["US", "KR"] as const).map((m) => ( + + ))} +
+ {market === "US" && session &&

미국장 {session}

} + {market === "US" && } + {recent.length > 0 && (
@@ -96,7 +115,7 @@ export default function Page() {
- + {stock.market === "US" && } ); } diff --git a/src/lib/enrich.ts b/src/lib/enrich.ts index 5143e4a..84c7c21 100644 --- a/src/lib/enrich.ts +++ b/src/lib/enrich.ts @@ -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 @@ -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, diff --git a/src/lib/krStocks.ts b/src/lib/krStocks.ts new file mode 100644 index 0000000..f4745ae --- /dev/null +++ b/src/lib/krStocks.ts @@ -0,0 +1,52 @@ +import type { SymbolMatch } from "@/lib/providers/search"; + +export type KrStock = { ticker: string; krName: string; aliases?: string[] }; + +/** 코스피/코스닥 주요 종목 큐레이션 (확장 가능). .KS=코스피, .KQ=코스닥. + * 주의: 각 코드는 Yahoo 차트(KRW 반환)로 검증할 것 — Step 5 참고. */ +export const KR_STOCKS: KrStock[] = [ + { ticker: "005930.KS", krName: "삼성전자" }, + { ticker: "000660.KS", krName: "SK하이닉스" }, + { ticker: "005380.KS", krName: "현대차" }, + { ticker: "000270.KS", krName: "기아" }, + { ticker: "373220.KS", krName: "LG에너지솔루션" }, + { ticker: "207940.KS", krName: "삼성바이오로직스" }, + { ticker: "068270.KS", krName: "셀트리온" }, + { ticker: "035420.KS", krName: "NAVER", aliases: ["네이버"] }, + { ticker: "035720.KS", krName: "카카오" }, + { ticker: "012330.KS", krName: "현대모비스" }, + { ticker: "005490.KS", krName: "POSCO홀딩스", aliases: ["포스코홀딩스", "포스코"] }, + { ticker: "105560.KS", krName: "KB금융" }, + { ticker: "055550.KS", krName: "신한지주" }, + { ticker: "006400.KS", krName: "삼성SDI" }, + { ticker: "051910.KS", krName: "LG화학" }, + { ticker: "028260.KS", krName: "삼성물산" }, + { ticker: "015760.KS", krName: "한국전력", aliases: ["한전"] }, + { ticker: "032830.KS", krName: "삼성생명" }, + { ticker: "086790.KS", krName: "하나금융지주" }, + { ticker: "066570.KS", krName: "LG전자" }, + { ticker: "247540.KQ", krName: "에코프로비엠" }, + { ticker: "086520.KQ", krName: "에코프로" }, + { ticker: "196170.KQ", krName: "알테오젠" }, + { ticker: "293490.KQ", krName: "카카오게임즈" }, + { ticker: "263750.KQ", krName: "펄어비스" }, + { ticker: "058470.KQ", krName: "리노공업" }, + { ticker: "035900.KQ", krName: "JYP Ent.", aliases: ["JYP"] }, +]; + +const norm = (s: string) => s.replace(/\s+/g, "").toLowerCase(); + +export function resolveKrName(query: string): SymbolMatch[] { + const nq = norm(query); + if (!nq) return []; + return KR_STOCKS.filter((s) => { + const hay = [s.krName, ...(s.aliases ?? [])].map(norm); + return hay.some((h) => h.includes(nq)); + }) + .map((s) => ({ + symbol: s.ticker, + name: s.krName, + exchange: s.ticker.endsWith(".KQ") ? "코스닥" : "코스피", + })) + .slice(0, 8); +} diff --git a/src/lib/popular.ts b/src/lib/popular.ts index b1d31dd..7925bc1 100644 --- a/src/lib/popular.ts +++ b/src/lib/popular.ts @@ -14,3 +14,14 @@ export const POPULAR_TICKERS: { ticker: string; name: string }[] = [ { ticker: "JPM", name: "JPMorgan" }, { ticker: "V", name: "Visa" }, ]; + +export const POPULAR_KR = [ + { ticker: "005930.KS", name: "삼성전자" }, + { ticker: "000660.KS", name: "SK하이닉스" }, + { ticker: "373220.KS", name: "LG에너지솔루션" }, + { ticker: "035420.KS", name: "NAVER" }, + { ticker: "035720.KS", name: "카카오" }, + { ticker: "005380.KS", name: "현대차" }, + { ticker: "247540.KQ", name: "에코프로비엠" }, + { ticker: "068270.KS", name: "셀트리온" }, +]; diff --git a/src/lib/providers/search.ts b/src/lib/providers/search.ts index 2cfdc82..961f213 100644 --- a/src/lib/providers/search.ts +++ b/src/lib/providers/search.ts @@ -1,8 +1,14 @@ +import { resolveKrName } from "@/lib/krStocks"; + export type SymbolMatch = { symbol: string; name: string; exchange: string }; -export async function searchSymbols(query: string): Promise { - const q = query.trim(); - if (!q) return []; +const isKrSymbol = (s: string) => /\.(KS|KQ)$/i.test(s); + +export function filterByMarket(matches: SymbolMatch[], market: "US" | "KR"): SymbolMatch[] { + return matches.filter((m) => (market === "KR" ? isKrSymbol(m.symbol) : !isKrSymbol(m.symbol))); +} + +async function fetchYahoo(q: string): Promise { for (const base of ["https://query2.finance.yahoo.com", "https://query1.finance.yahoo.com"]) { try { const url = `${base}/v1/finance/search?q=${encodeURIComponent(q)}"esCount=8&newsCount=0`; @@ -21,3 +27,15 @@ export async function searchSymbols(query: string): Promise { } return []; } + +export async function searchSymbols(query: string, market: "US" | "KR" = "US"): Promise { + const q = query.trim(); + if (!q) return []; + const yahoo = filterByMarket(await fetchYahoo(q), market); + if (market === "KR") { + const mapped = resolveKrName(q); + const seen = new Set(mapped.map((m) => m.symbol)); + return [...mapped, ...yahoo.filter((m) => !seen.has(m.symbol))].slice(0, 8); + } + return yahoo; +} diff --git a/src/lib/providers/yahoo.ts b/src/lib/providers/yahoo.ts index 63df67b..0ee76cb 100644 --- a/src/lib/providers/yahoo.ts +++ b/src/lib/providers/yahoo.ts @@ -1,6 +1,7 @@ export type Quote = { price: number; previousClose: number; volume: number; high52: number; low52: number; marketTime: number; + currency: string; }; export type DailyHistory = { closes: number[]; volumes: number[] }; export type YahooData = { quote: Quote; history: DailyHistory }; @@ -43,6 +44,7 @@ export async function getYahooData(ticker: string): Promise { high52: meta.fiftyTwoWeekHigh ?? (closes.length ? Math.max(...closes.slice(-252), price) : price), low52: meta.fiftyTwoWeekLow ?? (closes.length ? Math.min(...closes.slice(-252), price) : price), marketTime: meta.regularMarketTime ? meta.regularMarketTime * 1000 : Date.now(), + currency: meta.currency ?? "USD", }; return { quote, history: { closes, volumes } }; } catch { diff --git a/src/lib/stockData.ts b/src/lib/stockData.ts index c110140..a44e35b 100644 --- a/src/lib/stockData.ts +++ b/src/lib/stockData.ts @@ -13,9 +13,10 @@ export const getEnrichedStock = cache( const ticker = rawTicker.trim().toUpperCase(); if (!ticker) return null; const finnhubKey = process.env.FINNHUB_API_KEY ?? ""; + const isKr = /\.(KS|KQ)$/i.test(ticker); const [yahoo, valuation, usdKrw] = await Promise.all([ getYahooData(ticker), - getValuation(ticker, finnhubKey), + isKr ? Promise.resolve({ per: null, psr: null, pbr: null, roe: null }) : getValuation(ticker, finnhubKey), getUsdKrw(), ]); if (!yahoo) return null; diff --git a/src/lib/types.ts b/src/lib/types.ts index 3923808..74294e7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -54,6 +54,7 @@ export type MacroRegime = { export type EnrichedStock = { ticker: string; name: string; price: number | null; priceKrw: number | null; changePct: number | null; isStale: boolean; + market: "US" | "KR"; high: number; highSource: "sheet" | "52w"; zones: Zone[]; nearestUnreached: NearestZone; drawdownPct: number; zoneStatus: ZoneStatus; rsi14: number | null; sma50: number | null; sma200: number | null; diff --git a/test/enrich.test.ts b/test/enrich.test.ts index 1683605..7ec8e54 100644 --- a/test/enrich.test.ts +++ b/test/enrich.test.ts @@ -12,7 +12,7 @@ const volumes = Array.from({ length: 250 }, () => 1000); it("combines config + market data into an EnrichedStock", () => { const s = enrichStock(item, { - quote: { price: 85, previousClose: 86, volume: 2000, high52: 100, low52: 70, marketTime: Date.now() }, + quote: { price: 85, previousClose: 86, volume: 2000, high52: 100, low52: 70, marketTime: Date.now(), currency: "USD" }, history: { closes, volumes }, valuation: { per: 30, psr: 20, pbr: 40, roe: 90 }, usdKrw: 1500, @@ -35,12 +35,14 @@ it("combines config + market data into an EnrichedStock", () => { expect(s.disparity200).not.toBeNull(); expect(s.disparity200!).toBeLessThan(0); expect(s.signal50).not.toBeNull(); + expect(s.market).toBe("US"); + expect(s.priceKrw).toBe(85 * 1500); }); it("uses sheet high override when provided", () => { const s = enrichStock( { ...item, highOverride: 120 }, - { quote: { price: 85, previousClose: 86, volume: 1000, high52: 100, low52: 70, marketTime: 0 }, + { quote: { price: 85, previousClose: 86, volume: 1000, high52: 100, low52: 70, marketTime: 0, currency: "USD" }, history: null, valuation: { per: null, psr: null, pbr: null, roe: null }, usdKrw: null, thresholds: DEFAULT_THRESHOLDS, isStale: false }, ); @@ -51,3 +53,17 @@ it("uses sheet high override when provided", () => { expect(s.disparity50).toBeNull(); expect(s.signal50).toBeNull(); }); + +it("treats KRW-currency quotes as KR market with no won conversion", () => { + const krCloses = Array.from({ length: 250 }, (_, i) => 70000 + i * 10); + const s = enrichStock( + { ticker: "005930.KS", name: "삼성전자", buyThesis: "", sellRisk: "", theme: { aiInfra: false, trump: false, tariff: false }, highOverride: null }, + { quote: { price: 75000, previousClose: 74000, volume: 1000, high52: 90000, low52: 60000, marketTime: 0, currency: "KRW" }, + history: { closes: krCloses, volumes: krCloses.map(() => 1000) }, + valuation: { per: null, psr: null, pbr: null, roe: null }, + usdKrw: 1500, thresholds: DEFAULT_THRESHOLDS, isStale: false }, + ); + expect(s.market).toBe("KR"); + expect(s.priceKrw).toBeNull(); + expect(s.price).toBe(75000); +}); diff --git a/test/krStocks.test.ts b/test/krStocks.test.ts new file mode 100644 index 0000000..0dfe47a --- /dev/null +++ b/test/krStocks.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { resolveKrName } from "@/lib/krStocks"; + +describe("resolveKrName", () => { + it("resolves an exact Korean name to its ticker", () => { + const r = resolveKrName("삼성전자"); + expect(r.some((m) => m.symbol === "005930.KS")).toBe(true); + }); + it("ignores surrounding spaces and is case-insensitive", () => { + expect(resolveKrName(" 삼성 전자 ").some((m) => m.symbol === "005930.KS")).toBe(true); + }); + it("partial query matches multiple Samsung names", () => { + const syms = resolveKrName("삼성").map((m) => m.symbol); + expect(syms).toContain("005930.KS"); + expect(syms.length).toBeGreaterThan(1); + }); + it("labels exchange from suffix", () => { + const ecopro = resolveKrName("에코프로비엠").find((m) => m.symbol === "247540.KQ"); + expect(ecopro?.exchange).toBe("코스닥"); + const samsung = resolveKrName("삼성전자").find((m) => m.symbol === "005930.KS"); + expect(samsung?.exchange).toBe("코스피"); + }); + it("returns [] for empty or unknown queries", () => { + expect(resolveKrName("")).toEqual([]); + expect(resolveKrName("zzzznotarealstock")).toEqual([]); + }); +}); diff --git a/test/search.test.ts b/test/search.test.ts new file mode 100644 index 0000000..8257b0a --- /dev/null +++ b/test/search.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { filterByMarket } from "@/lib/providers/search"; + +const sample = [ + { symbol: "AAPL", name: "Apple", exchange: "NASDAQ" }, + { symbol: "005930.KS", name: "SamsungElec", exchange: "Korea" }, + { symbol: "247540.KQ", name: "EcoProBM", exchange: "Korea" }, +]; + +describe("filterByMarket", () => { + it("KR keeps only .KS/.KQ symbols", () => { + expect(filterByMarket(sample, "KR").map((m) => m.symbol)).toEqual(["005930.KS", "247540.KQ"]); + }); + it("US excludes .KS/.KQ symbols", () => { + expect(filterByMarket(sample, "US").map((m) => m.symbol)).toEqual(["AAPL"]); + }); +});