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:
+6
-6
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export interface JudgeResult {
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게',
|
||||
'당신은 한국·미국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게',
|
||||
'계산한 결과다 — 숫자를 재계산하거나 통과/미통과 판정을 뒤집지 말 것.',
|
||||
'요청된 출력 형식 외의 텍스트를 절대 추가하지 말 것.',
|
||||
].join('\n');
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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` — 텔레그램 보고서 즉시 발송',
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
export interface Stock {
|
||||
이름: string;
|
||||
심볼: string;
|
||||
/** 시장 — 'KR'(6자리 코드) | 'US'(알파벳 티커). 미지정 시 심볼로 자동 판별(marketUtils). */
|
||||
market?: 'KR' | 'US';
|
||||
/** ISO date — 보통 'YYYY-MM-DD'. */
|
||||
상장일?: string;
|
||||
유보율?: string;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', 투자성향: '저평가우량주',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user