From e6d780be615d01de333a7c7068919965bf10c112 Mon Sep 17 00:00:00 2001 From: g1nation Date: Wed, 1 Jul 2026 18:43:04 +0900 Subject: [PATCH] =?UTF-8?q?stocks:=20=EB=AF=B8=EA=B5=AD=20=EC=A3=BC?= =?UTF-8?q?=EC=8B=9D=20=EC=A7=80=EC=9B=90(=EA=B0=9C=EB=B3=84=20=EC=A2=85?= =?UTF-8?q?=EB=AA=A9)=20+=20datacollect=20LLM=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20+=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20NAS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미국 주식 (개별 종목 조회 — add/check/judge/analysis/position): - marketUtils: KR(6자리)/US(티커) 시장 판별, 통화·시총 포매팅($/원, B·T/억·조) - yahooClient: US는 raw 티커(.KQ/.KS 미부착), 심볼 라우팅 분리 - yahooFundamentals: Yahoo quoteSummary(무키) cookie+crumb 핸드셰이크로 ROE/PER/PBR/마진/성장/부채 fetch (429·crumb 하드닝) - fundamentalsRouter: 심볼별 Naver(KR)/Yahoo(US) 분기 - criteriaEval: market-aware — US는 유보율 미보고라 유동성·안정성을 부채비율(D/E)·시총($B)으로 대체 - types: Stock.market 필드; slashStocks/llmJudge: 통화·라우팅·프롬프트 US 대응 - 테스트: stocksMarket + criteriaEval US 케이스 (전체 통과) datacollect/llm.ts: LLM을 확장(PC)에서 직접 호출(과거 bridge /api/lm 프록시 제거) → 백엔드 NAS 이전 시 PC→NAS→PC 실패 제거, 스크래핑=NAS·LLM 추론=PC 분리 package.json: datacollectBridge 기본값 nas + https://dc.koritips.com; 버전 2.2.261 Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 12 +- src/features/datacollect/llm.ts | 88 ++++++----- src/features/stocks/criteriaEval.ts | 51 +++++-- src/features/stocks/fundamentalsRouter.ts | 15 ++ src/features/stocks/llmJudge.ts | 2 +- src/features/stocks/marketUtils.ts | 61 ++++++++ src/features/stocks/naverFundamentals.ts | 12 +- src/features/stocks/slashStocks.ts | 118 ++++++++------- src/features/stocks/types.ts | 2 + src/features/stocks/yahooClient.ts | 27 ++-- src/features/stocks/yahooFundamentals.ts | 175 ++++++++++++++++++++++ tests/stocksCriteria.test.ts | 40 +++++ tests/stocksMarket.test.ts | 47 ++++++ 13 files changed, 531 insertions(+), 119 deletions(-) create mode 100644 src/features/stocks/fundamentalsRouter.ts create mode 100644 src/features/stocks/marketUtils.ts create mode 100644 src/features/stocks/yahooFundamentals.ts create mode 100644 tests/stocksMarket.test.ts diff --git a/package.json b/package.json index 0509e53..e6ddff0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.259", + "version": "2.2.261", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -250,18 +250,18 @@ "local", "nas" ], - "default": "local", - "markdownDescription": "Datacollect 백엔드(Bridge)를 어디로 보낼지 선택. **`local`**(기본) = `g1nation.datacollectBridgeUrl`(로컬 `npm run bridge`). **`nas`** = `g1nation.datacollectBridgeNasUrl`(NAS의 경량 Bridge). `nas`인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다." + "default": "nas", + "markdownDescription": "Datacollect 백엔드(Bridge)를 어디로 보낼지 선택. **`nas`**(기본) = `g1nation.datacollectBridgeNasUrl`(NAS 상시 구동 Bridge, 예: `https://dc.koritips.com`). **`local`** = `g1nation.datacollectBridgeUrl`(로컬 `npm run bridge` — 더 이상 권장 안 함). `nas`인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다. NAS 사용 시 `datacollectBridgeNasToken`(= Bridge `BRIDGE_AUTH_TOKEN`)도 설정 필요." }, "g1nation.datacollectBridgeUrl": { "type": "string", "default": "http://127.0.0.1:3002", - "description": "[local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)." + "description": "[local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project). 백엔드를 NAS로 옮긴 경우 보통 사용하지 않음 — `datacollectBridgeTarget=nas` 권장." }, "g1nation.datacollectBridgeNasUrl": { "type": "string", - "default": "", - "markdownDescription": "[nas 타깃] NAS에서 도는 경량 Bridge URL (예: `https://your-nas-domain` 또는 `http://nas-ip:3002`). `datacollectBridgeTarget`을 `nas`로 두면 여기로 호출합니다. 비워두면 로컬로 폴백." + "default": "https://dc.koritips.com", + "markdownDescription": "[nas 타깃] NAS에서 상시 구동하는 경량 Bridge URL. 기본 `https://dc.koritips.com`(DSM 리버스 프록시 → 컨테이너 :3002). `datacollectBridgeTarget`을 `nas`로 두면 여기로 호출합니다. 비워두면 로컬로 폴백." }, "g1nation.datacollectBridgeNasToken": { "type": "string", diff --git a/src/features/datacollect/llm.ts b/src/features/datacollect/llm.ts index f8af14a..a669528 100644 --- a/src/features/datacollect/llm.ts +++ b/src/features/datacollect/llm.ts @@ -1,17 +1,45 @@ /** - * Datacollect LLM 호출 인프라 — bridge `/api/lm` 프록시 통해 OpenAI 호환 chat - * completion 단발 호출. + * Datacollect LLM 호출 인프라 — OpenAI 호환 chat completion 단발 호출. * * v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis * 였으나 datacollect handlers + teamops/communication 양쪽이 import 하므로 * 별도 인프라 모듈로. * + * v2.2.261: LLM 을 **확장(PC)에서 직접** 호출한다(과거엔 bridge `/api/lm` 프록시 경유). + * 확장은 PC 의 Node 프로세스라 `g1nation.ollamaUrl`(보통 127.0.0.1) 이 PC 의 LM Studio/ + * Ollama 를 정확히 가리키고, Node fetch 는 CORS 가 없어 프록시가 불필요하다. Bridge 를 + * NAS 로 옮긴 뒤 프록시 경유 시 PC→NAS→PC 로 LLM 을 찾으려다(=NAS 의 127.0.0.1) 실패하던 + * 문제를 제거 — 스크래핑/저장은 NAS, LLM 추론은 PC 로 깔끔히 분리된다. + * * `repairKoreanGlitches` 는 callLmSynthesis 출력 위생용 — 모델이 한·영 혼합 * 토큰 깨짐 (예: "핵ess") 을 뱉으면 LLM 1회 추가 호출로 교정. */ import * as vscode from 'vscode'; -import { bridgeFetch, BRIDGE_API } from './bridgeClient'; + +/** + * LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는 + * 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw. + */ +async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${lmUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`LM 서버 오류 ${res.status} (${lmUrl}): ${text.slice(0, 200)}`); + } + return await res.json(); + } finally { + clearTimeout(timer); + } +} /** * 모델 출력 붕괴(degeneration) 감지 — 작은/약한 모델이 긴 한국어 입력에서 @@ -43,23 +71,17 @@ async function callLmOnce( lmUrl: string, model: string, sys: string, prompt: string, sampling: { temperature: number; repeat_penalty: number; top_k: number }, ): Promise { - const res = await bridgeFetch(BRIDGE_API.lm.proxy, { - method: 'POST', - body: JSON.stringify({ - url: `${lmUrl}/v1/chat/completions`, - payload: { - model, - messages: [ - { role: 'system', content: sys }, - { role: 'user', content: prompt }, - ], - temperature: sampling.temperature, - top_p: 0.85, - top_k: sampling.top_k, - repeat_penalty: sampling.repeat_penalty, - }, - }), - }, { timeoutMs: 120_000 }); + const res = await lmChat(lmUrl, { + model, + messages: [ + { role: 'system', content: sys }, + { role: 'user', content: prompt }, + ], + temperature: sampling.temperature, + top_p: 0.85, + top_k: sampling.top_k, + repeat_penalty: sampling.repeat_penalty, + }, 120_000); const content = res?.choices?.[0]?.message?.content ?? res?.choices?.[0]?.text ?? res?.answer @@ -124,22 +146,16 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt */ async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise { try { - const res = await bridgeFetch(BRIDGE_API.lm.proxy, { - method: 'POST', - body: JSON.stringify({ - url: `${lmUrl}/v1/chat/completions`, - payload: { - model, - messages: [ - { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' }, - { role: 'user', content: text }, - ], - temperature: 0, - top_p: 0.7, - top_k: 20, - }, - }), - }, { timeoutMs: 120_000 }); + const res = await lmChat(lmUrl, { + model, + messages: [ + { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' }, + { role: 'user', content: text }, + ], + temperature: 0, + top_p: 0.7, + top_k: 20, + }, 120_000); const fixed = String( res?.choices?.[0]?.message?.content ?? res?.choices?.[0]?.text diff --git a/src/features/stocks/criteriaEval.ts b/src/features/stocks/criteriaEval.ts index 77d31e1..31a6f3b 100644 --- a/src/features/stocks/criteriaEval.ts +++ b/src/features/stocks/criteriaEval.ts @@ -19,6 +19,7 @@ */ import type { Stock } from './types'; import type { Fundamentals } from './naverFundamentals'; +import { detectMarket, type StockMarket } from './marketUtils'; export type CriterionState = 'pass' | 'fail' | 'unknown' | 'llm'; export interface CriterionResult { @@ -90,6 +91,11 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = const cap = fresh?.marketCapEok ?? marketCapEok(stock.시가총액); const listedYears = yearsSinceListing(stock.상장일, now); const biz = (stock['최대 먹거리'] || '').trim(); + // 시장 판별 — 미국 종목은 유보율(retentionRatio)을 보고하지 않으므로 '유동성'·'안정성' + // 기준을 부채비율(D/E)·시가총액(native)으로 대체한다. ROE/성장성/수익성/PBR/PER 등 + // 나머지 6개 기준은 시장 무관 보편 지표라 그대로 공유. + const market: StockMarket = fresh?.market ?? detectMarket(stock.심볼); + const capNative = fresh?.marketCapNative; // US=USD 시총 const R = (keyword: string, cond: boolean | undefined, detail: string, label?: string): CriterionResult => ({ keyword, state: cond === undefined ? 'unknown' : cond ? 'pass' : 'fail', detail, label }); @@ -119,8 +125,14 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = } results.push(R('성장성', growth, growthDetail)); - results.push(R('유동성', ret === undefined ? undefined : ret >= 1000, - `유보율 ${fmt(ret)} (기준 ≥1,000%)`)); + if (market === 'US') { + // 미국: 유보율 미보고 → 부채비율(D/E) ≤100% 를 '재무여력' 대체 지표로. + results.push(R('유동성', debt === undefined ? undefined : debt <= 100, + `부채비율(D/E) ${fmt(debt)} (기준 ≤100% · 미국은 유보율 미보고 → 재무여력 대체)`)); + } else { + results.push(R('유동성', ret === undefined ? undefined : ret >= 1000, + `유보율 ${fmt(ret)} (기준 ≥1,000%)`)); + } const profitImproved = opm !== undefined && opm >= 20; results.push(R('수익성', opm === undefined ? undefined : opm >= 10, @@ -140,14 +152,22 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = else tech = { keyword: '기술력', state: 'llm', detail: `PBR ${fmt(pbr, '')} ≥2, 최대먹거리 '${biz}' — 기술영역 여부 정성 판단 필요` }; results.push(tech); - // 안정성 — 유보율·시총에 더해 부채비율(있으면) ≤100% 동반 요구. 유보율은 자본금 - // 크기에 왜곡되는 지표라 단독으론 약함 — 부채 가드가 실질 안전판. - const stabilityBase = ret === undefined || cap === undefined ? undefined : (ret >= 3000 && cap >= 5000); - const stabilityDebtOk = debt === undefined ? true : debt <= 100; - results.push(R('안정성', - stabilityBase === undefined ? undefined : (stabilityBase && stabilityDebtOk), - `유보율 ${fmt(ret)} ≥3,000% AND 시총 ${cap === undefined ? '-' : cap.toLocaleString() + '억'} ≥5,000억` - + (debt !== undefined ? ` AND 부채비율 ${fmt(debt)} ≤100%` : ''))); + if (market === 'US') { + // 미국: 부채비율(D/E) ≤100% AND 시가총액 ≥ $2B (대형·우량 안전판). 유보율 미사용. + const usCapB = capNative === undefined ? undefined : capNative / 1e9; + const usStable = debt === undefined || usCapB === undefined ? undefined : (debt <= 100 && usCapB >= 2); + results.push(R('안정성', usStable, + `부채비율(D/E) ${fmt(debt)} ≤100% AND 시총 ${usCapB === undefined ? '-' : '$' + usCapB.toFixed(1) + 'B'} ≥$2B`)); + } else { + // 한국 — 유보율·시총에 더해 부채비율(있으면) ≤100% 동반 요구. 유보율은 자본금 + // 크기에 왜곡되는 지표라 단독으론 약함 — 부채 가드가 실질 안전판. + const stabilityBase = ret === undefined || cap === undefined ? undefined : (ret >= 3000 && cap >= 5000); + const stabilityDebtOk = debt === undefined ? true : debt <= 100; + results.push(R('안정성', + stabilityBase === undefined ? undefined : (stabilityBase && stabilityDebtOk), + `유보율 ${fmt(ret)} ≥3,000% AND 시총 ${cap === undefined ? '-' : cap.toLocaleString() + '억'} ≥5,000억` + + (debt !== undefined ? ` AND 부채비율 ${fmt(debt)} ≤100%` : ''))); + } results.push(R('PBR', pbr === undefined ? undefined : pbr <= 1.5, `PBR ${fmt(pbr, '')} (기준 ≤1.5)`)); @@ -157,13 +177,18 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = results.push(R('PER', per === undefined ? undefined : per <= 12, `PER ${fmt(per, '배')} (기준 ≤12배)`)); + const capText = market === 'US' + ? (capNative === undefined ? '-' : `$${(capNative / 1e9).toFixed(1)}B`) + : (cap === undefined ? '-' : `${cap.toLocaleString()}억`); return { results, - dataSource: fresh ? `Naver 실시간 ${now.toISOString().slice(0, 10)}` : 'stocks.json 저장값', + dataSource: fresh + ? `${fresh.source ?? (market === 'US' ? 'Yahoo' : 'Naver')} 실시간 ${now.toISOString().slice(0, 10)}` + : 'stocks.json 저장값', numbers: { - ROE: fmt(roe), 영업이익률: fmt(opm), 유보율: fmt(ret), + ROE: fmt(roe), 영업이익률: fmt(opm), 유보율: market === 'US' ? 'N/A(미보고)' : fmt(ret), PBR: fmt(pbr, ''), PER: fmt(per, '배'), - 부채비율: fmt(debt), 시가총액: cap === undefined ? '-' : `${cap.toLocaleString()}억`, + 부채비율: fmt(debt), 시가총액: capText, '매출 YoY': fmt(revYoY), '영업이익 YoY': fmt(opYoY), }, }; diff --git a/src/features/stocks/fundamentalsRouter.ts b/src/features/stocks/fundamentalsRouter.ts new file mode 100644 index 0000000..20942be --- /dev/null +++ b/src/features/stocks/fundamentalsRouter.ts @@ -0,0 +1,15 @@ +import { detectMarket } from './marketUtils'; +import { fetchFundamentals as fetchNaverFundamentals, type Fundamentals } from './naverFundamentals'; +import { fetchYahooFundamentals } from './yahooFundamentals'; + +/** + * 시장별 펀더멘털 라우터 — 한국 심볼은 Naver, 미국 티커는 Yahoo quoteSummary. + * + * judge/analysis 가 심볼 하나를 평가할 때 이 함수만 호출하면 된다. discover 는 + * 한국 유니버스(Naver screener) 전용이라 기존 fetchAllFundamentals(Naver)를 직접 쓴다. + */ +export async function fetchFundamentalsRouted(symbol: string, timeoutMs = 10000): Promise { + return detectMarket(symbol) === 'US' + ? fetchYahooFundamentals(symbol, timeoutMs) + : fetchNaverFundamentals(symbol, timeoutMs); +} diff --git a/src/features/stocks/llmJudge.ts b/src/features/stocks/llmJudge.ts index b05d88e..92a5785 100644 --- a/src/features/stocks/llmJudge.ts +++ b/src/features/stocks/llmJudge.ts @@ -31,7 +31,7 @@ export interface JudgeResult { } const SYSTEM_PROMPT = [ - '당신은 한국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게', + '당신은 한국·미국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게', '계산한 결과다 — 숫자를 재계산하거나 통과/미통과 판정을 뒤집지 말 것.', '요청된 출력 형식 외의 텍스트를 절대 추가하지 말 것.', ].join('\n'); diff --git a/src/features/stocks/marketUtils.ts b/src/features/stocks/marketUtils.ts new file mode 100644 index 0000000..2e129de --- /dev/null +++ b/src/features/stocks/marketUtils.ts @@ -0,0 +1,61 @@ +/** + * 시장(한국/미국) 판별 + 통화·금액 포매팅 유틸. + * + * stocks 모듈 전체가 원래 한국 전용(6자리 코드, 원, 억)이었다. 미국 종목을 + * 함께 다루려면 "이 심볼이 어느 시장인가"를 한 곳에서 결정해야 한다 — 심볼 + * 모양으로 자동 판별하되, Stock.market 이 명시돼 있으면 그걸 우선한다. + * + * - KR: 6자리 숫자 코드 (예: 005930). 통화 원(KRW). 시총 단위 '억'. + * - US: 알파벳 티커 (예: AAPL, BRK.B). 통화 $(USD). 시총 native(달러). + */ + +export type StockMarket = 'KR' | 'US'; + +/** 6자리 숫자면 한국, 아니면 미국으로 본다. (.KS/.KQ 접미사가 붙은 한국 심볼도 KR.) */ +export function detectMarket(symbol: string): StockMarket { + const s = (symbol || '').trim().toUpperCase(); + if (/^\d{6}(\.K[QS])?$/.test(s)) return 'KR'; + return 'US'; +} + +/** Stock.market 이 명시돼 있으면 그대로, 없으면 심볼로 판별. */ +export function marketOf(stock: { 심볼: string; market?: StockMarket }): StockMarket { + return stock.market ?? detectMarket(stock.심볼); +} + +export function currencyOf(market: StockMarket): 'KRW' | 'USD' { + return market === 'US' ? 'USD' : 'KRW'; +} + +/** 시장에 맞는 통화 표기 — KR='원', US='$'(접두). */ +export function formatMoney(n: number | undefined, market: StockMarket): string { + if (typeof n !== 'number' || !Number.isFinite(n)) return '-'; + if (market === 'US') { + // 미국 가격은 소수점 둘째 자리까지(주가가 0.xx~수천 달러 범위). + const v = n >= 1000 ? Math.round(n).toLocaleString() : n.toLocaleString(undefined, { maximumFractionDigits: 2 }); + return `$${v}`; + } + return `${Math.round(n).toLocaleString()}원`; +} + +/** + * 시가총액을 사람이 읽는 텍스트로. KR 은 '억/조', US 은 '$xxB/$xxM'. + * - capEok: 억 단위(한국 펀더멘털 경로). US 는 capNative(달러)로 호출. + */ +export function formatMarketCapKR(capEok: number | undefined): string { + if (typeof capEok !== 'number' || !Number.isFinite(capEok) || capEok <= 0) return '-'; + if (capEok >= 10000) { + const jo = Math.floor(capEok / 10000); + const rest = Math.round(capEok % 10000); + return rest > 0 ? `${jo}조 ${rest.toLocaleString()}억` : `${jo}조`; + } + return `${Math.round(capEok).toLocaleString()}억`; +} + +export function formatMarketCapUS(capUsd: number | undefined): string { + if (typeof capUsd !== 'number' || !Number.isFinite(capUsd) || capUsd <= 0) return '-'; + if (capUsd >= 1e12) return `$${(capUsd / 1e12).toFixed(2)}T`; + if (capUsd >= 1e9) return `$${(capUsd / 1e9).toFixed(2)}B`; + if (capUsd >= 1e6) return `$${(capUsd / 1e6).toFixed(1)}M`; + return `$${Math.round(capUsd).toLocaleString()}`; +} diff --git a/src/features/stocks/naverFundamentals.ts b/src/features/stocks/naverFundamentals.ts index 5722cfb..a5e271b 100644 --- a/src/features/stocks/naverFundamentals.ts +++ b/src/features/stocks/naverFundamentals.ts @@ -30,10 +30,18 @@ export interface Fundamentals { per?: number; pbr?: number; eps?: number; - marketCapEok?: number; // 억 단위 + marketCapEok?: number; // 억 단위 (한국 경로 전용 — 미국은 marketCapNative 사용) currentPrice?: number; /** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */ sectorHint?: string; + /** 시장 — 'KR'(Naver) | 'US'(Yahoo). 미지정 시 호출자가 심볼로 판별. */ + market?: 'KR' | 'US'; + /** 시가총액 native 단위 (US=USD). KR 은 marketCapEok 사용. */ + marketCapNative?: number; + /** 통화 — currentPrice/marketCapNative 의 단위. */ + currency?: 'KRW' | 'USD'; + /** 데이터 출처 라벨 — 'Naver' | 'Yahoo quoteSummary'. 정확도/투명성 표기용. */ + source?: string; } interface NaverIntegrationResponse { @@ -114,7 +122,7 @@ export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Prom ]); if (!integ && !fin) return null; - const out: Fundamentals = { symbol }; + const out: Fundamentals = { symbol, market: 'KR', currency: 'KRW', source: 'Naver' }; // integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적). if (integ?.totalInfos) { diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index 68eba26..534e32d 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -2,7 +2,9 @@ import * as vscode from 'vscode'; import { logInfo } from '../../utils'; import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore'; import { fetchAllPrices, fetchYahooPrice, fetchYahooHistory, evalMa224Recovery, evalDropRecovery, evalMaAlignment, evalRsi14, type Ma224RecoveryResult, type DropRecoveryResult, type MaAlignmentResult, type Rsi14Result } from './yahooClient'; -import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals'; +import type { Fundamentals } from './naverFundamentals'; +import { fetchFundamentalsRouted } from './fundamentalsRouter'; +import { detectMarket, formatMoney, formatMarketCapKR, formatMarketCapUS, type StockMarket } from './marketUtils'; import { AIService } from '../../core/services'; import { classifyAll } from './signalClassifier'; import { writeStocksStore } from './stocksStore'; @@ -41,15 +43,12 @@ function chunk(view: Webview | undefined, value: string) { view?.postMessage({ type: 'streamChunk', value }); } -function formatPrice(n: number | undefined): string { - if (typeof n !== 'number' || !Number.isFinite(n)) return '-'; - return n.toLocaleString(); -} - function renderListLine(s: ClassifiedStock): string { - const cur = formatPrice(s.현재가); + const mkt: StockMarket = s.market ?? detectMarket(s.심볼); + const cur = typeof s.현재가 === 'number' ? formatMoney(s.현재가, mkt) : '-'; const rec = s.매수권장가 ?? '-'; - return ` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${rec} → ${s.signalText}`; + const flag = mkt === 'US' ? '🇺🇸 ' : ''; + return ` · ${flag}${s.이름} (${s.심볼}): ${cur} / 권장 ${rec} → ${s.signalText}`; } async function cmdList(view: Webview | undefined): Promise { @@ -82,7 +81,7 @@ async function cmdCheck(view: Webview | undefined): Promise { const symbols = store.map(s => s.심볼).filter(Boolean); const prices = await fetchAllPrices(symbols, (sym, p) => { const name = store.find(s => s.심볼 === sym)?.이름 ?? sym; - chunk(view, ` · ${name}: ${p !== null ? p.toLocaleString() + '원' : '조회 실패'}\n`); + chunk(view, ` · ${name}: ${p !== null ? formatMoney(p, detectMarket(sym)) : '조회 실패'}\n`); }); for (const s of store) { const p = prices.get(s.심볼); @@ -123,10 +122,13 @@ async function cmdAdd(arg: string, view: Webview | undefined): Promise { chunk(view, '\n사용법: `/stocks add <심볼> <이름> [투자성향]`\n 투자성향: 스윙/중기 | 장기투자 | 저평가우량주 (기본: 스윙/중기)\n'); return; } - const [symbol, name, profileRaw] = parts; + const [symbolRaw, name, profileRaw] = parts; + // 미국 티커는 대문자 정규화(aapl→AAPL), 한국 코드는 그대로. 시장은 심볼로 자동 판별해 저장. + const market = detectMarket(symbolRaw); + const symbol = market === 'US' ? symbolRaw.toUpperCase() : symbolRaw; const profile = (profileRaw as Stock['투자성향']) || '스윙/중기'; - const r = addStock({ 이름: name, 심볼: symbol, 투자성향: profile }); - chunk(view, r.ok ? `\n✅ 추가: ${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`); + const r = addStock({ 이름: name, 심볼: symbol, market, 투자성향: profile }); + chunk(view, r.ok ? `\n✅ 추가: ${market === 'US' ? '🇺🇸 ' : ''}${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`); } async function cmdRemove(arg: string, view: Webview | undefined): Promise { @@ -138,12 +140,13 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise async function cmdJudge(arg: string, view: Webview | undefined): Promise { if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; } const symbol = arg.trim(); - // 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 Naver 실시간 수치를 시도. + const srcName = detectMarket(symbol) === 'US' ? 'Yahoo' : 'Naver'; + // 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 실시간 수치를 시도(한국=Naver, 미국=Yahoo). // 실패하면 stocks.json 저장값으로 폴백(결과에 데이터 출처 표기). - chunk(view, `\n📡 Naver 펀더멘털 갱신 중: ${symbol}...\n`); + chunk(view, `\n📡 ${srcName} 펀더멘털 갱신 중: ${symbol}...\n`); let fresh: Fundamentals | undefined; try { - fresh = (await fetchAllFundamentals([symbol])).get(symbol) ?? undefined; + fresh = (await fetchFundamentalsRouted(symbol)) ?? undefined; } catch { /* 폴백 — 저장값 사용 */ } chunk(view, fresh ? '✅ 실시간 수치 확보\n' : '⚠️ 실시간 조회 실패 — 저장값으로 평가\n'); chunk(view, `🤖 필터 평가 중 (수치 판정=코드, 근거 서술=LLM)...\n`); @@ -162,10 +165,15 @@ async function cmdJudge(arg: string, view: Webview | undefined): Promise { // + LLM 종합 평가 (권장도/근거/리스크). stocks.json 에 종목이 없어도 동작. const ANALYSIS_SYSTEM_PROMPT = [ - '당신은 한국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *Naver 펀더멘털*', - '+ *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.', + '당신은 한국·미국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *펀더멘털*', + '(한국=Naver, 미국=Yahoo quoteSummary) + *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.', '외부 정보 추측 금지 — 주어진 데이터에서만 판단.', '', + '**통화·시장 규칙:** 컨텍스트 첫 줄의 (시장/통화) 표기를 따른다. 한국=원, 미국=달러($).', + '가격·타점을 쓸 때 그 통화 기호를 그대로 사용하고 단위를 섞지 말 것.', + '미국 종목은 **유보율(retention ratio)을 보고하지 않는다 — "N/A"는 부정 신호가 아니다.**', + '미국의 재무 안정성은 부채비율(D/E)·시가총액으로만 판단하고, 유보율 부재를 약점으로 적지 말 것.', + '', '**평가 6차원:**', ' 1. 가치 — PBR / PER (저평가 여부)', ' 2. 수익성 — ROE / 영업이익률 절대 수준', @@ -213,7 +221,7 @@ const ANALYSIS_SYSTEM_PROMPT = [ '', '### 익절 타점', '- **1차**: 1년 최고가 × 0.98 (가격 X원), 보유 비중 30~50% 축소', - '- **2차**: 다음 심리적 라운드 넘버(현재가 위 가장 가까운 10,000/50,000/100,000원 단위, X원), 잔량 추가 축소', + '- **2차**: 다음 심리적 라운드 넘버(한국: 현재가 위 가장 가까운 10,000/50,000/100,000원 단위 / 미국: $10/$50/$100/$1000 단위), 잔량 추가 축소', '- **3차**: 동종 업종 평균 PER 대비 20% 초과 시 전량 익절 고려. *업종 평균 PER 데이터는 현재 미수집 — 사용자가 수동 비교 필요.*', '', '### 관망 해제 트리거', @@ -262,25 +270,31 @@ function buildAnalysisContext( rsi: Rsi14Result | null, preferred: PreferredStockInfo | null, ): string { + const mkt: StockMarket = f.market ?? detectMarket(symbol); + const money = (n?: number) => n !== undefined ? formatMoney(n, mkt) : '-'; + const capText = mkt === 'US' ? formatMarketCapUS(f.marketCapNative) : formatMarketCapKR(f.marketCapEok); const lines: string[] = [ - `종목 심볼: ${symbol}`, + `종목 심볼: ${symbol} (시장: ${mkt === 'US' ? '미국 🇺🇸' : '한국'}, 통화: ${f.currency ?? (mkt === 'US' ? 'USD' : 'KRW')})`, `섹터: ${f.sectorHint ?? '-'}`, '', - '── Naver 펀더멘털 ──', - `시가총액: ${f.marketCapEok !== undefined ? Math.round(f.marketCapEok).toLocaleString() + '억' : '-'}`, + `── ${f.source ?? (mkt === 'US' ? 'Yahoo quoteSummary' : 'Naver')} 펀더멘털 ──`, + `시가총액: ${capText}`, `ROE: ${f.roe !== undefined ? f.roe.toFixed(2) + '%' : '-'}`, `영업이익률: ${f.operatingMargin !== undefined ? f.operatingMargin.toFixed(1) + '%' : '-'}`, - `유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`, - `부채비율: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`, + // 유보율은 한국 전용 지표 — 미국은 미보고(부정 신호 아님). 부채비율(D/E)로 안정성 판단. + mkt === 'US' + ? '유보율: N/A (미국 미보고 — 부정 신호 아님. 안정성은 부채비율로 판단)' + : `유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`, + `부채비율${mkt === 'US' ? '(D/E)' : ''}: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`, `PER: ${f.per !== undefined ? f.per.toFixed(1) : '-'}`, `PBR: ${f.pbr !== undefined ? f.pbr.toFixed(2) : '-'}`, - `현재가: ${f.currentPrice !== undefined ? f.currentPrice.toLocaleString() + '원' : '-'}`, + `현재가: ${money(f.currentPrice)}`, '', '── Yahoo 1년 기술 지표 ──', ]; if (ma224) { - const ma = ma224.ma224Today !== undefined ? Math.round(ma224.ma224Today).toLocaleString() : '-'; - const cp = ma224.currentPrice !== undefined ? ma224.currentPrice.toLocaleString() : '-'; + const ma = money(ma224.ma224Today); + const cp = money(ma224.currentPrice); if (ma224.notApplicable) { // N/A 상태 — passed:false 와 시각적으로 구분 (⚪ vs ❌). LLM 도 이걸 보고 음성 신호 아님으로 인지. lines.push( @@ -300,9 +314,9 @@ function buildAnalysisContext( lines.push('224일선 분석: 시세 데이터 부족 (224일 미만)'); } if (drop) { - const cp = drop.currentPrice !== undefined ? drop.currentPrice.toLocaleString() : '-'; - const hi = drop.high1y !== undefined ? drop.high1y.toLocaleString() : '-'; - const lo = drop.low60d !== undefined ? drop.low60d.toLocaleString() : '-'; + const cp = money(drop.currentPrice); + const hi = money(drop.high1y); + const lo = money(drop.low60d); const fromHigh = drop.fromHighRatio !== undefined ? `${(drop.fromHighRatio * 100).toFixed(1)}% 수준` : '-'; const fromLow = drop.fromLowRatio !== undefined ? `+${((drop.fromLowRatio - 1) * 100).toFixed(1)}% 반등` : '-'; const verdict = drop.passed @@ -317,10 +331,9 @@ function buildAnalysisContext( lines.push('낙폭과대 분석: 시세 데이터 부족 (60일 미만)'); } if (align) { - const fmt = (n?: number) => n !== undefined ? Math.round(n).toLocaleString() : '-'; lines.push( `MA 배열: ${align.alignment === '정배열' ? '✅ 정배열' : align.alignment === '역배열' ? '❌ 역배열' : '⚠️ 혼조'}`, - ` · MA5 ${fmt(align.ma5)} / MA20 ${fmt(align.ma20)} / MA60 ${fmt(align.ma60)} / MA120 ${fmt(align.ma120)}`, + ` · MA5 ${money(align.ma5)} / MA20 ${money(align.ma20)} / MA60 ${money(align.ma60)} / MA120 ${money(align.ma120)}`, ); } else { lines.push('MA 배열: 시세 데이터 부족 (120일 미만)'); @@ -356,16 +369,19 @@ async function cmdAnalysis(arg: string, view: Webview | undefined): Promise` — 펀더멘털 + 1년 차트 패턴 종합 분석 (judge보다 깊음). stocks.json 미등록 종목도 가능.\n'); return; } - chunk(view, `\n🔍 **${symbol} 심층 분석** — Naver 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`); + const mkt: StockMarket = detectMarket(symbol); + const fundSrc = mkt === 'US' ? 'Yahoo quoteSummary' : 'Naver'; + chunk(view, `\n🔍 **${mkt === 'US' ? '🇺🇸 ' : ''}${symbol} 심층 분석** — ${fundSrc} 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`); - // 1. Naver 펀더멘털 - const fundsMap = await fetchAllFundamentals([symbol]); - const f = fundsMap.get(symbol); + // 1. 펀더멘털 (한국=Naver, 미국=Yahoo quoteSummary) + const f = await fetchFundamentalsRouted(symbol); if (!f) { - chunk(view, '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n'); + chunk(view, mkt === 'US' + ? '\n❌ Yahoo 에서 펀더멘털 데이터를 못 가져왔습니다. 티커를 확인해주세요(예: AAPL, MSFT). Yahoo crumb 일시 차단일 수도 있으니 잠시 후 재시도.\n' + : '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n'); return; } - chunk(view, ' · Naver 펀더멘털 OK\n'); + chunk(view, ` · ${fundSrc} 펀더멘털 OK\n`); // 2. Yahoo 1년 시세 + 기술 지표 const history = await fetchYahooHistory(symbol); @@ -433,20 +449,22 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수', '', '예: `/stocks position 50000000 2 5` → 50M × 2% ÷ 5% = 20M', - '예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주', + '예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주 (한국)', + '예: `/stocks position AAPL 100000 2 5` → 미국 티커 ($ 표기)', '', ].join('\n')); return; } - // 첫 인자가 6자리 숫자면 심볼, 아니면 곧바로 숫자 인자. + // 첫 인자가 종목 심볼이면(6자리 코드 또는 알파벳 티커) 분리, 아니면 곧바로 숫자 인자. let symbol: string | undefined; let nums: string[]; - if (/^[0-9]{6}$/.test(parts[0])) { - symbol = parts[0]; + if (/^[0-9]{6}$/.test(parts[0]) || /^[A-Za-z][A-Za-z.\-]*$/.test(parts[0])) { + symbol = /^[A-Za-z]/.test(parts[0]) ? parts[0].toUpperCase() : parts[0]; nums = parts.slice(1); } else { nums = parts; } + const mkt: StockMarket = symbol ? detectMarket(symbol) : 'KR'; if (nums.length < 3) { chunk(view, '\n❌ 인자 부족 — 총자산, 리스크%, 손절% 3개 필요.\n'); return; @@ -468,10 +486,10 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise 50) { chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n'); @@ -483,8 +501,8 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise <이름>` — 종목 추가', + ' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)', ' `/stocks remove <심볼>` — 종목 제거', ' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)', - ' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합)', + ' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합, 한·미)', ' `/stocks position [심볼] <총자산> <리스크%> <손절%>` — 포지션 사이징 (적정 투자금)', - ' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)', + ' `/stocks discover [min] [max]` — Naver 크롤 발굴 (한국 전용, 시총 억 단위, default 1000-5000)', ' `/stocks discover sector <섹터> [min] [max]` — 섹터별 발굴 (예: `discover sector 반도체`)', ' `/stocks discover sectors` — 발굴 가능한 섹터 목록', ' `/stocks report` — 텔레그램 보고서 즉시 발송', diff --git a/src/features/stocks/types.ts b/src/features/stocks/types.ts index 43e4114..d1236ee 100644 --- a/src/features/stocks/types.ts +++ b/src/features/stocks/types.ts @@ -11,6 +11,8 @@ export interface Stock { 이름: string; 심볼: string; + /** 시장 — 'KR'(6자리 코드) | 'US'(알파벳 티커). 미지정 시 심볼로 자동 판별(marketUtils). */ + market?: 'KR' | 'US'; /** ISO date — 보통 'YYYY-MM-DD'. */ 상장일?: string; 유보율?: string; diff --git a/src/features/stocks/yahooClient.ts b/src/features/stocks/yahooClient.ts index 3ae758d..abc451c 100644 --- a/src/features/stocks/yahooClient.ts +++ b/src/features/stocks/yahooClient.ts @@ -1,19 +1,26 @@ import { logError, logInfo } from '../../utils'; +import { detectMarket } from './marketUtils'; + +/** + * Yahoo 심볼 후보 생성 — 시장에 따라 suffix 처리. + * - 이미 '.' 있으면 (예: 005930.KS, BRK.B) 그대로 단일 후보. + * - 한국(6자리): `.KQ`(코스닥) 우선, `.KS`(코스피) 재시도. + * - 미국(알파벳 티커): suffix 없이 raw 그대로. (`AAPL.KQ` 로 잘못 붙던 버그 차단.) + */ +function yahooCandidates(symbol: string): string[] { + if (symbol.includes('.')) return [symbol]; + return detectMarket(symbol) === 'US' ? [symbol] : [`${symbol}.KQ`, `${symbol}.KS`]; +} /** * Yahoo Finance public chart endpoint 로 현재가 fetch. invest_results/quick_check.js - * 의 동일 로직 — symbol 에 suffix 없으면 `.KQ` (코스닥) 우선, 실패 시 `.KS` (코스피) 재시도. + * 의 동일 로직 — 한국 심볼은 `.KQ`/`.KS` 자동 부착, 미국 티커는 그대로. * - * Yahoo 가 한국 종목은 `<6자리>.KQ` 또는 `<6자리>.KS` 형식. US 종목은 그대로. - * symbol 에 이미 `.` 있으면 그대로 사용. - * - * Returns null 이면 두 suffix 다 실패 — 호출자가 skip 처리. + * Returns null 이면 모든 후보 실패 — 호출자가 skip 처리. */ export async function fetchYahooPrice(symbol: string, timeoutMs = 8000): Promise { if (!symbol) return null; - const candidates: string[] = symbol.includes('.') - ? [symbol] - : [`${symbol}.KQ`, `${symbol}.KS`]; + const candidates: string[] = yahooCandidates(symbol); for (const yahooSymbol of candidates) { try { @@ -73,9 +80,7 @@ export interface YahooHistory { export async function fetchYahooHistory(symbol: string, timeoutMs = 10000): Promise { if (!symbol) return null; - const candidates: string[] = symbol.includes('.') - ? [symbol] - : [`${symbol}.KQ`, `${symbol}.KS`]; + const candidates: string[] = yahooCandidates(symbol); for (const yahooSymbol of candidates) { try { diff --git a/src/features/stocks/yahooFundamentals.ts b/src/features/stocks/yahooFundamentals.ts new file mode 100644 index 0000000..1ebccae --- /dev/null +++ b/src/features/stocks/yahooFundamentals.ts @@ -0,0 +1,175 @@ +import { logError, logInfo } from '../../utils'; +import type { Fundamentals } from './naverFundamentals'; + +/** + * 미국 종목 펀더멘털 — Yahoo Finance `quoteSummary` (무키, 비공식). + * + * Naver 가 한국 종목용이듯, 미국 종목은 Yahoo quoteSummary 가 ROE/PER/PBR/마진/ + * 성장률/부채를 한 번에 준다. API 키 불필요. 단, 최근 Yahoo 가 quoteSummary 에 + * **crumb + cookie** 인증을 요구한다(차트 v8 endpoint 와 달리). 그래서: + * 1. fc.yahoo.com 또는 finance.yahoo.com 에서 Set-Cookie(A1/A3) 확보 + * 2. /v1/test/getcrumb 으로 cookie 와 짝이 되는 crumb 토큰 확보 + * 3. quoteSummary?crumb=… 호출 (Cookie 헤더 동봉) + * cookie+crumb 는 모듈 레벨에 캐시하고, 401/"Invalid Crumb" 면 1회 재발급 후 재시도. + * + * 반환은 Naver 경로와 동일한 Fundamentals 모양 — criteriaEval/analysis 가 그대로 재사용. + * 단위 변환: Yahoo 비율은 소수(0.15=15%) → ×100. debtToEquity 는 이미 %(D/E). + * + * Caveat: 비공식 — Yahoo 가 막거나 crumb 흐름을 바꾸면 깨질 수 있음. 개인 학습용 가정. + */ + +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; +const MODULES = ['financialData', 'defaultKeyStatistics', 'summaryDetail', 'price', 'assetProfile'].join(','); + +interface Session { cookie: string; crumb: string; } +let cachedSession: Session | null = null; + +/** Set-Cookie 헤더(들)에서 name=value 쌍만 추출해 Cookie 헤더 문자열로 합성. */ +function cookieHeaderFrom(res: Response): string { + // Node18 undici: getSetCookie() 가 배열 반환(권장). 구버전 폴백: 단일 헤더 split. + const anyHeaders = res.headers as any; + let raw: string[] = []; + if (typeof anyHeaders.getSetCookie === 'function') { + raw = anyHeaders.getSetCookie(); + } else { + const single = res.headers.get('set-cookie'); + if (single) raw = single.split(/,(?=\s*\w+=)/); // "A=1, B=2" 대략 분리 + } + return raw.map(c => c.split(';')[0].trim()).filter(Boolean).join('; '); +} + +/** cookie + crumb 발급. 실패 시 null. */ +async function establishSession(timeoutMs: number): Promise { + let cookie = ''; + for (const url of ['https://fc.yahoo.com/', 'https://finance.yahoo.com/']) { + try { + const res = await fetch(url, { + headers: { 'User-Agent': UA, 'Accept': 'text/html' }, + signal: AbortSignal.timeout(timeoutMs), + redirect: 'follow', + }); + const c = cookieHeaderFrom(res); + if (c) { cookie = c; break; } + } catch { /* 다음 후보 */ } + } + if (!cookie) { + logError('Yahoo quoteSummary: cookie 확보 실패.'); + return null; + } + try { + const res = await fetch('https://query1.finance.yahoo.com/v1/test/getcrumb', { + headers: { 'User-Agent': UA, 'Accept': 'text/plain', 'Cookie': cookie }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + // 429(rate limit)/403 등 — body 가 "Too Many Requests" 같은 에러 문구일 수 있음. + logError('Yahoo quoteSummary: getcrumb HTTP 오류.', { status: res.status }); + return null; + } + const crumb = (await res.text()).trim(); + // 진짜 crumb 은 ~11자, 공백 없음. "Too Many Requests"/HTML/빈 문자열은 거부 — + // 공백이 있거나 너무 길면(>24) 에러 응답을 crumb 으로 오인하지 않도록 차단. + if (!crumb || crumb.length > 24 || /\s/.test(crumb) || crumb.includes('<')) { + logError('Yahoo quoteSummary: crumb 형식 비정상 — 거부.', { sample: crumb.slice(0, 40) }); + return null; + } + return { cookie, crumb }; + } catch (e: any) { + logError('Yahoo quoteSummary: crumb fetch 예외.', { error: e?.message ?? String(e) }); + return null; + } +} + +async function getSession(timeoutMs: number, force = false): Promise { + if (cachedSession && !force) return cachedSession; + cachedSession = await establishSession(timeoutMs); + return cachedSession; +} + +/** quoteSummary 필드는 { raw, fmt } 객체 — raw 숫자만 안전 추출. */ +function raw(node: any): number | undefined { + if (node === undefined || node === null) return undefined; + if (typeof node === 'number') return Number.isFinite(node) ? node : undefined; + const r = node.raw; + return typeof r === 'number' && Number.isFinite(r) ? r : undefined; +} + +/** 소수 비율(0.153) → 퍼센트(15.3). undefined 보존. */ +function pct(node: any): number | undefined { + const v = raw(node); + return v === undefined ? undefined : v * 100; +} + +async function fetchQuoteSummary(symbol: string, timeoutMs: number): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + const session = await getSession(timeoutMs, attempt > 0); + if (!session) return null; + const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}` + + `?modules=${MODULES}&crumb=${encodeURIComponent(session.crumb)}`; + try { + const res = await fetch(url, { + headers: { 'User-Agent': UA, 'Accept': 'application/json', 'Cookie': session.cookie }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.status === 401 || res.status === 403) { + cachedSession = null; // crumb 만료 → 다음 attempt 에서 재발급 + continue; + } + if (res.status === 429) { + // rate limit — 즉시 재시도는 무의미. 세션 무효화 후 null(호출자가 안내·폴백). + cachedSession = null; + logError('Yahoo quoteSummary: 429 rate limit.', { symbol }); + return null; + } + if (!res.ok) { + logError('Yahoo quoteSummary HTTP 오류.', { symbol, status: res.status }); + return null; + } + const data: any = await res.json(); + const result = data?.quoteSummary?.result?.[0]; + if (!result) { + const errDesc = data?.quoteSummary?.error?.description; + if (errDesc && /crumb/i.test(errDesc) && attempt === 0) { cachedSession = null; continue; } + return null; + } + return result; + } catch (e: any) { + logError('Yahoo quoteSummary fetch 예외.', { symbol, error: e?.message ?? String(e) }); + return null; + } + } + return null; +} + +/** 미국 종목 1개의 펀더멘털. 실패 시 null. */ +export async function fetchYahooFundamentals(symbol: string, timeoutMs = 10000): Promise { + const r = await fetchQuoteSummary(symbol, timeoutMs); + if (!r) return null; + + const fd = r.financialData ?? {}; + const ks = r.defaultKeyStatistics ?? {}; + const sd = r.summaryDetail ?? {}; + const pr = r.price ?? {}; + const ap = r.assetProfile ?? {}; + + const out: Fundamentals = { + symbol, + market: 'US', + currency: (pr.currency || fd.financialCurrency || 'USD') as 'USD', + source: 'Yahoo quoteSummary', + roe: pct(fd.returnOnEquity), + operatingMargin: pct(fd.operatingMargins), + // 유보율(retentionRatio)은 미국 미보고 — undefined 유지(criteriaEval 이 market 로 대체 처리). + debtRatio: raw(fd.debtToEquity), // 이미 % (D/E) + revenueGrowthYoY: pct(fd.revenueGrowth), + opProfitGrowthYoY: pct(fd.earningsGrowth), // 영업이익 성장 근사치 + per: raw(sd.trailingPE) ?? raw(ks.trailingPE), + pbr: raw(ks.priceToBook), + eps: raw(ks.trailingEps), + currentPrice: raw(fd.currentPrice) ?? raw(pr.regularMarketPrice), + marketCapNative: raw(pr.marketCap) ?? raw(sd.marketCap), + sectorHint: ap.sector || ap.industry || undefined, + }; + logInfo('Yahoo quoteSummary OK.', { symbol, roe: out.roe, per: out.per, pbr: out.pbr }); + return out; +} diff --git a/tests/stocksCriteria.test.ts b/tests/stocksCriteria.test.ts index 3e07768..27531d7 100644 --- a/tests/stocksCriteria.test.ts +++ b/tests/stocksCriteria.test.ts @@ -117,6 +117,46 @@ describe('stocks criteriaEval', () => { expect(ev.results.find(r => r.keyword === '안정성')!.state).toBe('fail'); }); + // ── 미국 종목 (market-aware) ───────────────────────────────────────── + + test('US — 유보율 미보고여도 부채비율로 유동성 통과, 안정성은 시총·부채', () => { + const stock: Stock = { 이름: 'Apple', 심볼: 'AAPL', 투자성향: '스윙/중기' }; + const fresh = { + symbol: 'AAPL', market: 'US' as const, currency: 'USD' as const, source: 'Yahoo quoteSummary', + roe: 150, operatingMargin: 30, per: 28, pbr: 45, debtRatio: 140, + revenueGrowthYoY: 8, marketCapNative: 3.2e12, + }; + const ev = evaluateCriteria(stock, fresh, NOW); + const byKw = (k: string) => ev.results.find(r => r.keyword === k)!; + // 부채비율 140% > 100 → 유동성·안정성 미통과 (유보율 기준은 적용 안 됨) + expect(byKw('유동성').state).toBe('fail'); + expect(byKw('유동성').detail).toMatch(/부채비율/); + expect(byKw('안정성').state).toBe('fail'); + // 유보율은 numbers 에 N/A 로 표기 (KR 의 '5,800%' 자리) + expect(ev.numbers.유보율).toBe('N/A(미보고)'); + // 시가총액은 $B 표기 + expect(ev.numbers.시가총액).toMatch(/\$/); + }); + + test('US — 저부채·대형주는 유동성·안정성 통과', () => { + const stock: Stock = { 이름: 'TestCo', 심볼: 'TST', 투자성향: '저평가우량주' }; + const fresh = { + symbol: 'TST', market: 'US' as const, currency: 'USD' as const, + roe: 18, operatingMargin: 22, per: 9, pbr: 0.9, debtRatio: 40, + revenueGrowthYoY: 14, marketCapNative: 5e9, + }; + const ev = evaluateCriteria(stock, fresh, NOW); + const byKw = (k: string) => ev.results.find(r => r.keyword === k)!; + expect(byKw('유동성').state).toBe('pass'); // D/E 40% ≤100 + expect(byKw('안정성').state).toBe('pass'); // D/E ≤100 AND $5B ≥$2B + }); + + test('US — 심볼만으로 시장 판별 (fresh 없이 stocks.json 저장값)', () => { + const ev = evaluateCriteria({ 이름: 'NVIDIA', 심볼: 'NVDA' }, undefined, NOW); + // 유보율 자리가 N/A → US 경로 진입 확인 + expect(ev.numbers.유보율).toBe('N/A(미보고)'); + }); + test('PER 키워드 — ≤12배 통과, 저평가우량주 우선순위에 포함', () => { const { ev, verdict } = judge({ 이름: '저PER주', 심볼: '000013', 투자성향: '저평가우량주', diff --git a/tests/stocksMarket.test.ts b/tests/stocksMarket.test.ts new file mode 100644 index 0000000..f378433 --- /dev/null +++ b/tests/stocksMarket.test.ts @@ -0,0 +1,47 @@ +/** + * marketUtils — 시장 판별 + 통화/금액 포매팅 테스트. + */ +import { + detectMarket, marketOf, currencyOf, formatMoney, + formatMarketCapKR, formatMarketCapUS, +} from '../src/features/stocks/marketUtils'; + +describe('stocks marketUtils', () => { + test('detectMarket — 6자리 숫자 = KR, 알파벳 = US', () => { + expect(detectMarket('005930')).toBe('KR'); + expect(detectMarket('005935')).toBe('KR'); + expect(detectMarket('005930.KS')).toBe('KR'); + expect(detectMarket('035720.KQ')).toBe('KR'); + expect(detectMarket('AAPL')).toBe('US'); + expect(detectMarket('aapl')).toBe('US'); + expect(detectMarket('BRK.B')).toBe('US'); + expect(detectMarket('TSLA')).toBe('US'); + }); + + test('marketOf — 명시 market 우선, 없으면 심볼 판별', () => { + expect(marketOf({ 심볼: 'AAPL' })).toBe('US'); + expect(marketOf({ 심볼: '005930' })).toBe('KR'); + // 명시값이 심볼 판별을 덮어씀 (예: ADR 등 예외) + expect(marketOf({ 심볼: 'ABCDEF', market: 'KR' })).toBe('KR'); + }); + + test('currencyOf', () => { + expect(currencyOf('KR')).toBe('KRW'); + expect(currencyOf('US')).toBe('USD'); + }); + + test('formatMoney — 원/달러 분기', () => { + expect(formatMoney(72000, 'KR')).toBe('72,000원'); + expect(formatMoney(232.45, 'US')).toBe('$232.45'); + expect(formatMoney(1234.5, 'US')).toBe('$1,235'); // ≥1000 은 정수 반올림 + expect(formatMoney(undefined, 'US')).toBe('-'); + }); + + test('formatMarketCap — KR 억/조, US B/T', () => { + expect(formatMarketCapKR(5000)).toBe('5,000억'); + expect(formatMarketCapKR(12000)).toBe('1조 2,000억'); + expect(formatMarketCapKR(20000)).toBe('2조'); + expect(formatMarketCapUS(3.2e12)).toBe('$3.20T'); + expect(formatMarketCapUS(5e9)).toBe('$5.00B'); + }); +});