stocks: 미국 주식 지원(개별 종목) + datacollect LLM 직접 호출 + 백엔드 기본 NAS

미국 주식 (개별 종목 조회 — 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:43:04 +09:00
parent 4288b212d5
commit e6d780be61
13 changed files with 531 additions and 119 deletions
+6 -6
View File
@@ -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",
+52 -36
View File
@@ -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<any> {
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<string> {
const res = await bridgeFetch<any>(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<string> {
try {
const res = await bridgeFetch<any>(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
+38 -13
View File
@@ -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),
},
};
+15
View File
@@ -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<Fundamentals | null> {
return detectMarket(symbol) === 'US'
? fetchYahooFundamentals(symbol, timeoutMs)
: fetchNaverFundamentals(symbol, timeoutMs);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export interface JudgeResult {
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게',
'당신은 한국·미국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게',
'계산한 결과다 — 숫자를 재계산하거나 통과/미통과 판정을 뒤집지 말 것.',
'요청된 출력 형식 외의 텍스트를 절대 추가하지 말 것.',
].join('\n');
+61
View File
@@ -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()}`;
}
+10 -2
View File
@@ -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) {
+68 -50
View File
@@ -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<void> {
@@ -82,7 +81,7 @@ async function cmdCheck(view: Webview | undefined): Promise<void> {
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<void> {
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<void> {
@@ -138,12 +140,13 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise<void>
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
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<void> {
// + 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<void
chunk(view, '\n사용법: `/stocks analysis <심볼>` — 펀더멘털 + 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<void
' `/stocks position <심볼> <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수',
'',
'예: `/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<void
const positionRatio = positionWon / total * 100;
chunk(view, '\n💰 **포지션 사이징 계산**\n');
chunk(view, ` · 총 자산: ${total.toLocaleString()}\n`);
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${Math.round(maxLoss).toLocaleString()})\n`);
chunk(view, ` · 총 자산: ${formatMoney(total, mkt)}\n`);
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${formatMoney(maxLoss, mkt)})\n`);
chunk(view, ` · 손절폭: ${stopPct}%\n`);
chunk(view, `\n → **권장 투자금: ${Math.round(positionWon).toLocaleString()}** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
chunk(view, `\n → **권장 투자금: ${formatMoney(positionWon, mkt)}** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
if (positionRatio > 50) {
chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n');
@@ -483,8 +501,8 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise<void
if (typeof price === 'number') {
const shares = Math.floor(positionWon / price);
const actualWon = shares * price;
chunk(view, ` · 현재가: ${price.toLocaleString()}\n`);
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${actualWon.toLocaleString()})\n`);
chunk(view, ` · 현재가: ${formatMoney(price, mkt)}\n`);
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${formatMoney(actualWon, mkt)})\n`);
} else {
chunk(view, ' ⚠️ Yahoo 현재가 fetch 실패 — 주수 계산 skip\n');
}
@@ -594,18 +612,18 @@ function cmdPath(view: Webview | undefined): void {
function cmdHelp(view: Webview | undefined): void {
chunk(view, [
'\n📈 **Stocks 명령**',
'\n📈 **Stocks 명령** (한국 6자리 코드 + 미국 티커 🇺🇸 지원)',
'',
' `/stocks list` — 종목 + 신호',
' `/stocks check` — 현재가 갱신',
' `/stocks signal` — 매수사정권 종목만',
' `/stocks sync` — Google Sheets 동기화',
' `/stocks add <심볼> <이름>` — 종목 추가',
' `/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` — 텔레그램 보고서 즉시 발송',
+2
View File
@@ -11,6 +11,8 @@
export interface Stock {
이름: string;
심볼: string;
/** 시장 — 'KR'(6자리 코드) | 'US'(알파벳 티커). 미지정 시 심볼로 자동 판별(marketUtils). */
market?: 'KR' | 'US';
/** ISO date — 보통 'YYYY-MM-DD'. */
상장일?: string;
유보율?: string;
+16 -11
View File
@@ -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<number | null> {
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<YahooHistory | null> {
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 {
+175
View File
@@ -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<Session | null> {
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<Session | null> {
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<any | null> {
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<Fundamentals | null> {
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;
}
+40
View File
@@ -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', : '저평가우량주',
+47
View File
@@ -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');
});
});