a52bf6ee85
/stocks judge — 조건 판정 정확도 (P2/P3/P4):
- criteriaEval.ts 신설: 8개 키워드 중 수치 기준 7개("5,800%" 파싱·임계값
비교)와 충족/미충족 판정·투자성향별 대표 3개 선택을 코드로 결정론 계산.
LLM 은 '기술력' 도메인 정성 판단(키워드 모호 시)과 근거 서술만 담당,
실패 시 결정론 폴백 → judge 가 LLM 형식 오류로 실패하는 경로 제거.
- cmdJudge: 판정 전 Naver 실시간 펀더멘털 fetch(실패 시 저장값 폴백) +
결과에 데이터 출처 표기.
- tests/stocksCriteria.test.ts: 사용자 실제 분류 패턴(마녀공장/기가비스/
엔켐) 픽스처 8건 — 코드 판정이 기존 패턴과 일치함을 고정.
/meet — 할루시네이션·문맥 누락 (P1/P5/P6):
- 근거 인용 의무: 결정·액션마다 발언 원문 인용(근거: "…") — 인용 불가
항목은 결정/액션 금지 (날조 구조적 억제).
- 60K 하드 자르기 폐지 → 12K 조각 추출(Map) + 병합(Reduce) 2단계.
lost-in-the-middle·후반부 증발 해소, 커버리지 60K→144K자.
- g1nation.meetVerifyPass(기본 off): 결정·액션을 근거 소스와 LLM 대조해
확인 불가 항목을 '⚠️ 검증 결과' 섹션으로 표시.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
630 lines
33 KiB
TypeScript
630 lines
33 KiB
TypeScript
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 { AIService } from '../../core/services';
|
||
import { classifyAll } from './signalClassifier';
|
||
import { writeStocksStore } from './stocksStore';
|
||
import { syncToSheets } from './sheetsSync';
|
||
import { judgeStock } from './llmJudge';
|
||
import { sendStocksReport, buildReportText } from './telegramReport';
|
||
import { runOnceNow } from './stocksWatcher';
|
||
import { discoverStocks } from './stockDiscovery';
|
||
import { analyzeTopCandidates, renderTopFiveForChat, renderTopFiveForTelegram, sendTopFiveToTelegram } from './discoveryAnalyzer';
|
||
import type { ClassifiedStock, Stock } from './types';
|
||
|
||
/**
|
||
* `/stocks <subcommand> [args]` 라우터 — slashRouter 의 단일 handler 로 등록되어
|
||
* 첫 단어로 sub-routing.
|
||
*
|
||
* Subcommands:
|
||
* /stocks — 도움말
|
||
* /stocks list — 종목 + 신호 표시
|
||
* /stocks check — 현재가 갱신 (Yahoo)
|
||
* /stocks signal — 매수사정권 종목만
|
||
* /stocks sync — Google Sheets 동기화
|
||
* /stocks add <심볼> <이름> [투자성향]
|
||
* /stocks remove <심볼>
|
||
* /stocks judge <심볼> — LLM 4-criteria 평가
|
||
* /stocks discover [min] [max] — Naver 크롤 발굴 (시총 범위 억 단위, default 1000-5000)
|
||
* /stocks report — 텔레그램 보고서 즉시 발송
|
||
* /stocks run — watcher 한 사이클 즉시 실행 (현재가+sync+보고서)
|
||
* /stocks path — stocks.json 경로 표시
|
||
*/
|
||
|
||
interface Webview { postMessage(msg: any): Thenable<boolean> | boolean; }
|
||
|
||
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 rec = s.매수권장가 ?? '-';
|
||
return ` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${rec} → ${s.signalText}`;
|
||
}
|
||
|
||
async function cmdList(view: Webview | undefined): Promise<void> {
|
||
const store = readStocksStore();
|
||
if (store.length === 0) {
|
||
chunk(view, `\n종목 없음. \`/stocks add <심볼> <이름>\` 으로 추가하세요.\n경로: ${getStocksFilePath() ?? '(워크스페이스 없음)'}\n`);
|
||
return;
|
||
}
|
||
const g = classifyAll(store);
|
||
const lines: string[] = ['\n📋 **종목 목록 (분류별)**\n'];
|
||
if (g.swing.length) {
|
||
lines.push(`\n**스윙/중기** (${g.swing.length}개)`);
|
||
g.swing.forEach(s => lines.push(renderListLine(s)));
|
||
}
|
||
if (g.long.length) {
|
||
lines.push(`\n**장기투자** (${g.long.length}개)`);
|
||
g.long.forEach(s => lines.push(renderListLine(s)));
|
||
}
|
||
if (g.ultraLow.length) {
|
||
lines.push(`\n**저평가우량주** (${g.ultraLow.length}개)`);
|
||
g.ultraLow.forEach(s => lines.push(renderListLine(s)));
|
||
}
|
||
chunk(view, lines.join('\n') + '\n');
|
||
}
|
||
|
||
async function cmdCheck(view: Webview | undefined): Promise<void> {
|
||
const store = readStocksStore();
|
||
if (store.length === 0) { chunk(view, '\n종목 없음.\n'); return; }
|
||
chunk(view, `\n🔄 ${store.length}개 종목 현재가 갱신 중 (Yahoo, 1초/종목)...\n`);
|
||
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`);
|
||
});
|
||
for (const s of store) {
|
||
const p = prices.get(s.심볼);
|
||
if (typeof p === 'number') s.현재가 = p;
|
||
}
|
||
writeStocksStore(store);
|
||
const updated = [...prices.values()].filter(p => p !== null).length;
|
||
chunk(view, `\n✅ ${updated}/${store.length}개 종목 갱신 완료.\n`);
|
||
}
|
||
|
||
async function cmdSignal(view: Webview | undefined): Promise<void> {
|
||
const store = readStocksStore();
|
||
const g = classifyAll(store);
|
||
const buyZone = g.all.filter(s => s.signal === 'BUY_ZONE');
|
||
if (buyZone.length === 0) {
|
||
chunk(view, '\n🚨 매수사정권 종목 없음.\n');
|
||
return;
|
||
}
|
||
chunk(view, `\n🚨 **매수사정권 ${buyZone.length}개**\n\n`);
|
||
for (const s of buyZone) {
|
||
chunk(view, renderListLine(s) + '\n');
|
||
}
|
||
}
|
||
|
||
async function cmdSync(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||
chunk(view, '\n📊 Google Sheets 동기화 중...\n');
|
||
const r = await syncToSheets(context);
|
||
if (r.ok) {
|
||
chunk(view, `\n✅ 동기화 완료: ${r.updatedRanges.length}개 시트.\n${r.updatedRanges.map(x => ` · ${x}`).join('\n')}\n`);
|
||
} else {
|
||
chunk(view, `\n❌ 동기화 실패:\n${r.errors.map(e => ` · ${e}`).join('\n')}\n`);
|
||
}
|
||
}
|
||
|
||
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
||
const parts = arg.split(/\s+/);
|
||
if (parts.length < 2) {
|
||
chunk(view, '\n사용법: `/stocks add <심볼> <이름> [투자성향]`\n 투자성향: 스윙/중기 | 장기투자 | 저평가우량주 (기본: 스윙/중기)\n');
|
||
return;
|
||
}
|
||
const [symbol, name, profileRaw] = parts;
|
||
const profile = (profileRaw as Stock['투자성향']) || '스윙/중기';
|
||
const r = addStock({ 이름: name, 심볼: symbol, 투자성향: profile });
|
||
chunk(view, r.ok ? `\n✅ 추가: ${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`);
|
||
}
|
||
|
||
async function cmdRemove(arg: string, view: Webview | undefined): Promise<void> {
|
||
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks remove <심볼>`\n'); return; }
|
||
const r = removeStock(arg.trim());
|
||
chunk(view, r.ok ? `\n✅ 제거: ${arg.trim()}\n` : `\n❌ ${r.reason}\n`);
|
||
}
|
||
|
||
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 실시간 수치를 시도.
|
||
// 실패하면 stocks.json 저장값으로 폴백(결과에 데이터 출처 표기).
|
||
chunk(view, `\n📡 Naver 펀더멘털 갱신 중: ${symbol}...\n`);
|
||
let fresh: Fundamentals | undefined;
|
||
try {
|
||
fresh = (await fetchAllFundamentals([symbol])).get(symbol) ?? undefined;
|
||
} catch { /* 폴백 — 저장값 사용 */ }
|
||
chunk(view, fresh ? '✅ 실시간 수치 확보\n' : '⚠️ 실시간 조회 실패 — 저장값으로 평가\n');
|
||
chunk(view, `🤖 필터 평가 중 (수치 판정=코드, 근거 서술=LLM)...\n`);
|
||
const r = await judgeStock(symbol, { fresh });
|
||
if (!r.ok) {
|
||
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
|
||
return;
|
||
}
|
||
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n📅 데이터: ${r.dataSource}\n`);
|
||
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
|
||
}
|
||
|
||
// ─── /stocks analysis <심볼> — 단일 종목 심층 분석 (judge 보다 깊음) ───
|
||
// judge: 4-criteria 펀더멘털 평가만 (stocks.json 에 저장된 데이터 사용)
|
||
// analysis: Naver 펀더멘털 fresh fetch + Yahoo 1년 시세 기반 기술 지표(224회복/낙폭과대)
|
||
// + LLM 종합 평가 (권장도/근거/리스크). stocks.json 에 종목이 없어도 동작.
|
||
|
||
const ANALYSIS_SYSTEM_PROMPT = [
|
||
'당신은 한국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *Naver 펀더멘털*',
|
||
'+ *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.',
|
||
'외부 정보 추측 금지 — 주어진 데이터에서만 판단.',
|
||
'',
|
||
'**평가 6차원:**',
|
||
' 1. 가치 — PBR / PER (저평가 여부)',
|
||
' 2. 수익성 — ROE / 영업이익률 절대 수준',
|
||
' 3. 재무 안정성 — 유보율, 시가총액, **부채비율** (200% 이하 안전, 100% 이하 우량)',
|
||
' 4. 추세 (기술) — 224일선 회복 + **MA 정배열/역배열** (5/20/60/120일)',
|
||
' · 정배열 = 추세 매수 안전',
|
||
' · 역배열 = 펀더 좋아도 대기 권장',
|
||
' · MA224 "N/A — 추세 확립" 상태는 **부정 신호 아님** (현재가가 MA224 한참 위라 회복 패턴 평가 무관). passed:false 와 혼동 금지.',
|
||
' 5. 안전마진 — 낙폭과대 (1년 고점 대비 하락 + 최근 저점에서 반등). raw 데이터의 `failReason` 가 명시되면 그것을 그대로 인용 — 인과 거꾸로(예: "반등이 커서 미통과" 같은 오해석) 금지.',
|
||
' 6. 진입 타이밍 — **RSI(14)**',
|
||
' · 과열 (≥70) = 매수 자제',
|
||
' · 침체 (≤30) = 단기 반등 여지',
|
||
' · 중립 (30~70) = 정상',
|
||
'',
|
||
'**판단 절제 규칙 — 과잉 해석 차단:**',
|
||
' a. **PBR/PER 절대값으로 "고평가/저평가" 단정 금지.** 업종 평균 데이터가 컨텍스트에 없으면 "PBR X.X (업종 평균 데이터 부재 — 절대값만 제시)" 식으로만. "부담" / "고평가" 같은 강한 단어는 업종 비교나 5년 역사적 평균이 있을 때만 사용.',
|
||
' b. **거래량 ±20% 미만 변동은 무의미.** 5일/60일 평균 차이가 약 -10% 정도면 "유의미한 변화 아님"으로 처리하고 "상승 동력 약화" 같은 강한 해석으로 끌고 가지 말 것. 진짜 약화 신호는 -20% 이상 또는 추세적 감소.',
|
||
' c. **우선주 (symbol 끝자리 5/7/9) 는 특이 항목 반영 필수.** 컨텍스트에 `[우선주 정보]` 블록이 있으면 보통주 대비 할인율을 매수 의견 근거에 한 줄 언급. 우선주는 일반적으로 보통주 대비 10~30% 할인이 정상 — 그 범위 내면 "정상", 좁으면 "프리미엄", 넓으면 "확대" 로 평가. 배당수익률 데이터는 현재 미수집이므로 추측 금지.',
|
||
' d. **오탈자 자기 점검.** 출력 직전 한국어 토큰 깨짐(예: "순위" → "순칭", "근거" → "근례")이 없는지 확인. 의심스러우면 다시 쓸 것.',
|
||
'',
|
||
'**출력 형식 (정확히 이대로, 다른 헤더 추가 금지):**',
|
||
'## 종합 평가',
|
||
'2-3 문장 핵심 의견.',
|
||
'',
|
||
'## 매수 의견',
|
||
'- **권장도**: 매수권 / 관망 / 회피 중 하나',
|
||
'- **근거**: 위 6차원 중 핵심 2-3개를 *수치 인용* 으로',
|
||
'',
|
||
'## 매매 타점',
|
||
'*가격·% 모두 raw 데이터(MA값, 1년 최고가, 60일 저점, RSI, 부채비율 등)에서 직접 도출. 추측·외부 정보 금지. `__원` 같은 placeholder가 아니라 실제 수치로 채울 것.*',
|
||
'',
|
||
'### 매수 진입 타점',
|
||
'권장도가 **회피**면 이 sub-section 통째로 한 줄: "회피 권장 — 매수 타점 산정 생략."',
|
||
'권장도가 매수권/관망이면 *현재 기술 상태에 부합하는 시나리오만* 1-2개:',
|
||
'- **1순위 — MA20 지지**: MA 정배열일 때만. MA20 ±1% 영역(가격 범위 X원~Y원). 거래량 감소 후 반등 캔들 확인.',
|
||
'- **2순위 — MA60 눌림**: 단기 조정 심화 시. MA60 부근(Z원). 낙폭과대 동시 충족 시 적극.',
|
||
'- **3순위 — 낙폭과대 + RSI ≤30**: RSI 30 이하 + 60일 저점(W원) 근접. 부채비율·유보율 이상 없을 것.',
|
||
'현재 데이터로 어떤 시나리오도 부합 안 하면 한 줄: "현재 매수 타점 조건 부적합 — 추가 조정 대기."',
|
||
'',
|
||
'### 손절 기준',
|
||
'**종가 기준 이탈**만 적용 (장중 터치는 손절 미해당). *위 매수 시나리오에 출력한 항목에 한해서만:*',
|
||
'- MA20 진입 시 → MA20(X원) 종가 이탈, 최대 허용 약 -5%',
|
||
'- MA60 진입 시 → MA60(X원) 종가 이탈, 최대 허용 약 -8%',
|
||
'- 낙폭과대 진입 시 → 60일 저점(X원) 종가 이탈, 최대 허용 약 -10%',
|
||
'',
|
||
'### 익절 타점',
|
||
'- **1차**: 1년 최고가 × 0.98 (가격 X원), 보유 비중 30~50% 축소',
|
||
'- **2차**: 다음 심리적 라운드 넘버(현재가 위 가장 가까운 10,000/50,000/100,000원 단위, X원), 잔량 추가 축소',
|
||
'- **3차**: 동종 업종 평균 PER 대비 20% 초과 시 전량 익절 고려. *업종 평균 PER 데이터는 현재 미수집 — 사용자가 수동 비교 필요.*',
|
||
'',
|
||
'### 관망 해제 트리거',
|
||
'권장도가 **관망**일 때만 작성. 매수권/회피면 이 sub-section 통째로 생략(line drop).',
|
||
'아래 *2개 이상 동시 충족* 시 진입 전환:',
|
||
'1. MA20 또는 MA60 종가 지지 확인',
|
||
'2. 거래량 60일 평균 (현재 약 X) 이하 감소 후 반등',
|
||
'3. RSI 50 이하로 눌린 후 재반등 (현재 RSI Y)',
|
||
'4. 1년 최고가 대비 -10% 이상 추가 조정',
|
||
'',
|
||
'## 리스크',
|
||
'1-2줄, 주의해야 할 점.',
|
||
].join('\n');
|
||
|
||
/**
|
||
* 한국 우선주 → 보통주 심볼 도출. 우선주 마지막 자리는 5/7/9 (1종/2종/3종 우선주).
|
||
* 보통주는 마지막 자리 0. 예: 005935 → 005930, 005385 → 005380.
|
||
* 패턴이 아니면 null (보통주거나 다른 형식).
|
||
*/
|
||
function deriveCommonStockSymbol(symbol: string): string | null {
|
||
if (!/^[0-9]{6}$/.test(symbol)) return null;
|
||
const last = symbol[symbol.length - 1];
|
||
if (last === '5' || last === '7' || last === '9') {
|
||
return symbol.slice(0, -1) + '0';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export interface PreferredStockInfo {
|
||
/** 우선주 → 보통주로 환산한 6자리 심볼. */
|
||
commonSymbol: string;
|
||
/** Yahoo 에서 가져온 보통주 현재가. */
|
||
commonPrice: number;
|
||
/** 우선주 현재가. */
|
||
preferredPrice: number;
|
||
/** (보통주 - 우선주) / 보통주 × 100. 양수 = 우선주가 더 쌈(할인), 음수 = 프리미엄. */
|
||
discountPct: number;
|
||
}
|
||
|
||
function buildAnalysisContext(
|
||
symbol: string,
|
||
f: Fundamentals,
|
||
ma224: Ma224RecoveryResult | null,
|
||
drop: DropRecoveryResult | null,
|
||
align: MaAlignmentResult | null,
|
||
rsi: Rsi14Result | null,
|
||
preferred: PreferredStockInfo | null,
|
||
): string {
|
||
const lines: string[] = [
|
||
`종목 심볼: ${symbol}`,
|
||
`섹터: ${f.sectorHint ?? '-'}`,
|
||
'',
|
||
'── Naver 펀더멘털 ──',
|
||
`시가총액: ${f.marketCapEok !== undefined ? Math.round(f.marketCapEok).toLocaleString() + '억' : '-'}`,
|
||
`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) + '%' : '-'}`,
|
||
`PER: ${f.per !== undefined ? f.per.toFixed(1) : '-'}`,
|
||
`PBR: ${f.pbr !== undefined ? f.pbr.toFixed(2) : '-'}`,
|
||
`현재가: ${f.currentPrice !== undefined ? f.currentPrice.toLocaleString() + '원' : '-'}`,
|
||
'',
|
||
'── Yahoo 1년 기술 지표 ──',
|
||
];
|
||
if (ma224) {
|
||
const ma = ma224.ma224Today !== undefined ? Math.round(ma224.ma224Today).toLocaleString() : '-';
|
||
const cp = ma224.currentPrice !== undefined ? ma224.currentPrice.toLocaleString() : '-';
|
||
if (ma224.notApplicable) {
|
||
// N/A 상태 — passed:false 와 시각적으로 구분 (⚪ vs ❌). LLM 도 이걸 보고 음성 신호 아님으로 인지.
|
||
lines.push(
|
||
`224일선(MA224) 회복: ⚪ N/A — ${ma224.notApplicableReason ?? '추세 확립, 회복 패턴 평가 무관'}`,
|
||
` · 현재가 ${cp} vs MA224 ${ma}`,
|
||
' · ⚠️ 부정 신호가 아닙니다 — 회복 패턴은 *장기 하락 후 상향 돌파* 신호이므로, 이미 한참 위에 있는 종목엔 적용 무관.',
|
||
);
|
||
} else {
|
||
lines.push(
|
||
`224일선(MA224) 회복: ${ma224.passed ? '✅ 통과' : '❌ 미통과'}`,
|
||
` · 현재가 ${cp} vs MA224 ${ma}`,
|
||
` · 최근 30일 중 MA224 아래 일수: ${ma224.daysBelowLast30 ?? '-'}`,
|
||
` · 거래량 확인: 5일평균 ${ma224.vol5dAvg !== undefined ? Math.round(ma224.vol5dAvg).toLocaleString() : '-'} vs 60일평균 ${ma224.vol60dAvg !== undefined ? Math.round(ma224.vol60dAvg).toLocaleString() : '-'} → ${ma224.volumeConfirmed ? '✅' : '❌'}`,
|
||
);
|
||
}
|
||
} else {
|
||
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 fromHigh = drop.fromHighRatio !== undefined ? `${(drop.fromHighRatio * 100).toFixed(1)}% 수준` : '-';
|
||
const fromLow = drop.fromLowRatio !== undefined ? `+${((drop.fromLowRatio - 1) * 100).toFixed(1)}% 반등` : '-';
|
||
const verdict = drop.passed
|
||
? '✅ 통과'
|
||
: `❌ 미통과${drop.failReason ? ` — ${drop.failReason}` : ''}`;
|
||
lines.push(
|
||
`낙폭과대: ${verdict}`,
|
||
` · 1년 최고가 ${hi} → 현재가 ${cp} (${fromHigh})`,
|
||
` · 60일 저점 ${lo} → 현재가 ${cp} (${fromLow})`,
|
||
);
|
||
} else {
|
||
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)}`,
|
||
);
|
||
} else {
|
||
lines.push('MA 배열: 시세 데이터 부족 (120일 미만)');
|
||
}
|
||
if (rsi) {
|
||
const tag = rsi.classification === '과열' ? '🔥 과열' : rsi.classification === '침체' ? '🧊 침체' : '🟢 중립';
|
||
lines.push(`RSI(14): ${rsi.rsi.toFixed(1)} (${tag})`);
|
||
} else {
|
||
lines.push('RSI: 시세 데이터 부족 (15일 미만)');
|
||
}
|
||
if (preferred) {
|
||
const sign = preferred.discountPct > 0 ? '할인' : '프리미엄';
|
||
// 우선주 할인 범위 가이드: 통상 10~30% 정상, 좁으면 프리미엄, 넓으면 확대.
|
||
const rangeNote = preferred.discountPct >= 30 ? '확대 (보통주 대비 매우 저평가)'
|
||
: preferred.discountPct >= 10 ? '정상 범위 (10~30%)'
|
||
: preferred.discountPct > 0 ? '좁음 (10% 미만 — 보통주 대비 프리미엄 수준)'
|
||
: '역전 (우선주가 보통주보다 비쌈 — 극히 이례적)';
|
||
lines.push(
|
||
'',
|
||
'── 우선주 특이정보 ──',
|
||
`보통주 ${preferred.commonSymbol} 현재가: ${preferred.commonPrice.toLocaleString()}원`,
|
||
`우선주 ${symbol} 현재가: ${preferred.preferredPrice.toLocaleString()}원`,
|
||
`보통주 대비 ${sign}율: ${Math.abs(preferred.discountPct).toFixed(1)}% — ${rangeNote}`,
|
||
'※ 우선주는 배당수익률 + 보통주 대비 할인율이 핵심 투자 포인트. 배당 데이터는 미수집(사용자 확인 필요).',
|
||
);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
async function cmdAnalysis(arg: string, view: Webview | undefined): Promise<void> {
|
||
const symbol = (arg.trim().split(/\s+/)[0] || '').trim();
|
||
if (!symbol) {
|
||
chunk(view, '\n사용법: `/stocks analysis <심볼>` — 펀더멘털 + 1년 차트 패턴 종합 분석 (judge보다 깊음). stocks.json 미등록 종목도 가능.\n');
|
||
return;
|
||
}
|
||
chunk(view, `\n🔍 **${symbol} 심층 분석** — Naver 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`);
|
||
|
||
// 1. Naver 펀더멘털
|
||
const fundsMap = await fetchAllFundamentals([symbol]);
|
||
const f = fundsMap.get(symbol);
|
||
if (!f) {
|
||
chunk(view, '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n');
|
||
return;
|
||
}
|
||
chunk(view, ' · Naver 펀더멘털 OK\n');
|
||
|
||
// 2. Yahoo 1년 시세 + 기술 지표
|
||
const history = await fetchYahooHistory(symbol);
|
||
const ma224 = history ? evalMa224Recovery(history) : null;
|
||
const drop = history ? evalDropRecovery(history) : null;
|
||
const align = history ? evalMaAlignment(history) : null;
|
||
const rsi = history ? evalRsi14(history) : null;
|
||
chunk(view, history
|
||
? ` · Yahoo 1년 시세 OK (${history.closes.length} 거래일)\n`
|
||
: ' ⚠️ Yahoo 시세 fetch 실패 — 펀더멘털만으로 분석\n');
|
||
|
||
// 2-b. 우선주(symbol 끝자리 5/7/9) 면 보통주 현재가도 가져와 할인율 계산.
|
||
// 배당 데이터까지는 아직 미수집 — 추후 Naver crawl 확장 필요.
|
||
let preferred: PreferredStockInfo | null = null;
|
||
const commonSymbol = deriveCommonStockSymbol(symbol);
|
||
if (commonSymbol && f.currentPrice !== undefined && f.currentPrice > 0) {
|
||
chunk(view, ` · 우선주 감지 — 보통주 ${commonSymbol} 현재가 fetch 중...\n`);
|
||
const commonPrice = await fetchYahooPrice(commonSymbol);
|
||
if (typeof commonPrice === 'number' && commonPrice > 0) {
|
||
preferred = {
|
||
commonSymbol,
|
||
commonPrice,
|
||
preferredPrice: f.currentPrice,
|
||
discountPct: (commonPrice - f.currentPrice) / commonPrice * 100,
|
||
};
|
||
chunk(view, ` · 보통주 ${commonSymbol} 현재가 ${commonPrice.toLocaleString()}원 (할인율 ${preferred.discountPct.toFixed(1)}%)\n`);
|
||
} else {
|
||
chunk(view, ` ⚠️ 보통주 ${commonSymbol} 가격 fetch 실패 — 할인율 계산 skip\n`);
|
||
}
|
||
}
|
||
|
||
// 3. 데이터 요약 표시 (모델에 보내는 것과 동일 — 투명성)
|
||
const summary = buildAnalysisContext(symbol, f, ma224, drop, align, rsi, preferred);
|
||
chunk(view, `\n\`\`\`\n${summary}\n\`\`\`\n`);
|
||
|
||
// 4. LLM 종합 분석
|
||
chunk(view, '\n🤖 LLM 종합 분석 중...\n');
|
||
const ai = new AIService();
|
||
try {
|
||
const result = await ai.chat({
|
||
system: ANALYSIS_SYSTEM_PROMPT,
|
||
user: summary + '\n\n위 데이터로 종합 평가, 매수 의견, 리스크를 출력 형식 그대로 작성하시오.',
|
||
});
|
||
if (result.empty || !result.content.trim()) {
|
||
chunk(view, '\n❌ LLM 빈 응답.\n');
|
||
return;
|
||
}
|
||
chunk(view, `\n${result.content.trim()}\n`);
|
||
logInfo('Stocks analysis 완료.', { symbol, model: result.model });
|
||
} catch (e: any) {
|
||
chunk(view, `\n❌ LLM 호출 실패: ${e?.message ?? String(e)}\n`);
|
||
}
|
||
}
|
||
|
||
// ─── /stocks position [심볼] <총자산> <리스크%> <손절%> — 포지션 사이징 ───
|
||
// 공식: 권장 투자금 = 총자산 × (리스크%/100) ÷ (손절%/100)
|
||
// 심볼을 주면 Yahoo 현재가로 매수 가능 주수까지 계산.
|
||
|
||
async function cmdPosition(arg: string, view: Webview | undefined): Promise<void> {
|
||
const parts = arg.trim().split(/\s+/).filter(Boolean);
|
||
if (parts.length < 3) {
|
||
chunk(view, [
|
||
'\n사용법:',
|
||
' `/stocks position <총자산> <리스크%> <손절%>` — 단순 계산',
|
||
' `/stocks position <심볼> <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수',
|
||
'',
|
||
'예: `/stocks position 50000000 2 5` → 50M × 2% ÷ 5% = 20M',
|
||
'예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주',
|
||
'',
|
||
].join('\n'));
|
||
return;
|
||
}
|
||
// 첫 인자가 6자리 숫자면 심볼, 아니면 곧바로 숫자 인자.
|
||
let symbol: string | undefined;
|
||
let nums: string[];
|
||
if (/^[0-9]{6}$/.test(parts[0])) {
|
||
symbol = parts[0];
|
||
nums = parts.slice(1);
|
||
} else {
|
||
nums = parts;
|
||
}
|
||
if (nums.length < 3) {
|
||
chunk(view, '\n❌ 인자 부족 — 총자산, 리스크%, 손절% 3개 필요.\n');
|
||
return;
|
||
}
|
||
const total = Number(nums[0]);
|
||
const riskPct = Number(nums[1]);
|
||
const stopPct = Number(nums[2]);
|
||
if (!Number.isFinite(total) || !Number.isFinite(riskPct) || !Number.isFinite(stopPct)
|
||
|| total <= 0 || riskPct <= 0 || stopPct <= 0) {
|
||
chunk(view, '\n❌ 잘못된 입력 — 모두 양수여야 합니다.\n');
|
||
return;
|
||
}
|
||
if (riskPct > 100 || stopPct > 100) {
|
||
chunk(view, '\n❌ %는 100 이하로 입력하세요 (예: 2 = 2%).\n');
|
||
return;
|
||
}
|
||
const positionWon = total * (riskPct / 100) / (stopPct / 100);
|
||
const maxLoss = positionWon * (stopPct / 100);
|
||
const positionRatio = positionWon / total * 100;
|
||
|
||
chunk(view, '\n💰 **포지션 사이징 계산**\n');
|
||
chunk(view, ` · 총 자산: ${total.toLocaleString()}원\n`);
|
||
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${Math.round(maxLoss).toLocaleString()}원)\n`);
|
||
chunk(view, ` · 손절폭: ${stopPct}%\n`);
|
||
chunk(view, `\n → **권장 투자금: ${Math.round(positionWon).toLocaleString()}원** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
|
||
|
||
if (positionRatio > 50) {
|
||
chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n');
|
||
}
|
||
|
||
if (symbol) {
|
||
chunk(view, `\n📈 ${symbol} 현재가 fetch 중...\n`);
|
||
const price = await fetchYahooPrice(symbol);
|
||
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`);
|
||
} else {
|
||
chunk(view, ' ⚠️ Yahoo 현재가 fetch 실패 — 주수 계산 skip\n');
|
||
}
|
||
}
|
||
chunk(view, '\n');
|
||
}
|
||
|
||
async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||
chunk(view, '\n📨 텔레그램 보고서 발송 중...\n');
|
||
const r = await sendStocksReport(context);
|
||
chunk(view, r.ok ? '\n✅ 발송 완료.\n' : `\n❌ 발송 실패: ${r.reason}\n`);
|
||
chunk(view, `\n*Preview:*\n${buildReportText()}\n`);
|
||
}
|
||
|
||
async function cmdRun(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||
chunk(view, '\n⚡ Watcher 1회 즉시 실행 (현재가 + Sheets + 텔레그램)...\n');
|
||
await runOnceNow(context);
|
||
chunk(view, '\n✅ 완료.\n');
|
||
}
|
||
|
||
async function cmdDiscover(rest: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
|
||
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
|
||
const parts = rest.split(/\s+/).filter(Boolean);
|
||
const minCap = parts[0] ? parseInt(parts[0], 10) : 1000;
|
||
const maxCap = parts[1] ? parseInt(parts[1], 10) : 5000;
|
||
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
|
||
chunk(view, '\n사용법: `/stocks discover [min] [max]` (억 단위, 예: `/stocks discover 1000 5000`)\n');
|
||
return;
|
||
}
|
||
|
||
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}억)\n`);
|
||
|
||
const candidates = await discoverStocks({
|
||
minMarketCapEok: minCap,
|
||
maxMarketCapEok: maxCap,
|
||
onProgress: (msg) => chunk(view, msg + '\n'),
|
||
});
|
||
|
||
if (candidates.length === 0) {
|
||
chunk(view, '\n결과 없음.\n');
|
||
return;
|
||
}
|
||
|
||
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`);
|
||
for (const c of candidates) {
|
||
const price = c.fundamentals.currentPrice;
|
||
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
|
||
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]\n`);
|
||
chunk(view, ` 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`);
|
||
chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`);
|
||
}
|
||
|
||
// ── LLM 매력도 분석 + 텔레그램 전송 (자동 chain) ──
|
||
// 사용자 의도: 발굴 목록이 나오면 *항상* 분석 + 텔레그램. 별도 명령 trigger 불필요.
|
||
// 실패해도 (LLM timeout / 텔레그램 미설정) 위 발굴 목록은 화면에 그대로 남아 있음.
|
||
chunk(view, '\n');
|
||
const topFive = await analyzeTopCandidates(candidates, (msg) => chunk(view, msg + '\n'));
|
||
chunk(view, renderTopFiveForChat(topFive));
|
||
|
||
if (context) {
|
||
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}억`;
|
||
const tgText = renderTopFiveForTelegram(topFive, rangeLabel);
|
||
const tgResult = await sendTopFiveToTelegram(context, tgText);
|
||
chunk(view, tgResult.ok
|
||
? '\n📨 텔레그램 발송 완료.\n'
|
||
: `\n⚠️ 텔레그램 발송 skip: ${tgResult.reason}\n`);
|
||
} else {
|
||
chunk(view, '\n⚠️ 텔레그램 발송 skip: ExtensionContext 없음.\n');
|
||
}
|
||
|
||
chunk(view, '\n💡 종목을 stocks.json 에 추가하려면 `/stocks add <심볼> <이름>` 사용.\n');
|
||
}
|
||
|
||
function cmdPath(view: Webview | undefined): void {
|
||
const p = getStocksFilePath();
|
||
chunk(view, p ? `\n📂 stocks.json: \`${p}\`\n` : '\n⚠️ 워크스페이스 폴더 없음 — stocks 모듈 사용 불가.\n');
|
||
}
|
||
|
||
function cmdHelp(view: Webview | undefined): void {
|
||
chunk(view, [
|
||
'\n📈 **Stocks 명령**',
|
||
'',
|
||
' `/stocks list` — 종목 + 신호',
|
||
' `/stocks check` — 현재가 갱신',
|
||
' `/stocks signal` — 매수사정권 종목만',
|
||
' `/stocks sync` — Google Sheets 동기화',
|
||
' `/stocks add <심볼> <이름>` — 종목 추가',
|
||
' `/stocks remove <심볼>` — 종목 제거',
|
||
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
||
' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합)',
|
||
' `/stocks position [심볼] <총자산> <리스크%> <손절%>` — 포지션 사이징 (적정 투자금)',
|
||
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
|
||
' `/stocks report` — 텔레그램 보고서 즉시 발송',
|
||
' `/stocks run` — Watcher 1회 즉시 실행',
|
||
' `/stocks path` — stocks.json 경로 표시',
|
||
'',
|
||
'자동 실행: VS Code 시작 시 활성화. KST 09:00 / 15:00 매일 자동.',
|
||
'',
|
||
].join('\n'));
|
||
}
|
||
|
||
/** slashRouter 가 `/stocks` 로 들어오는 모든 입력을 이 함수 한 곳으로 위임. */
|
||
export async function handleStocksCommand(
|
||
arg: string,
|
||
view: Webview | undefined,
|
||
context?: vscode.ExtensionContext,
|
||
): Promise<boolean> {
|
||
const parts = arg.trim().split(/\s+/);
|
||
const sub = (parts[0] || '').toLowerCase();
|
||
const rest = parts.slice(1).join(' ').trim();
|
||
|
||
logInfo(`Stocks slash: sub=${sub} rest="${rest.slice(0, 40)}"`);
|
||
|
||
try {
|
||
switch (sub) {
|
||
case '': cmdHelp(view); return true;
|
||
case 'list': await cmdList(view); return true;
|
||
case 'check': await cmdCheck(view); return true;
|
||
case 'signal': await cmdSignal(view); return true;
|
||
case 'sync':
|
||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
|
||
await cmdSync(view, context); return true;
|
||
case 'add': await cmdAdd(rest, view); return true;
|
||
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
||
case 'judge': await cmdJudge(rest, view); return true;
|
||
case 'analysis': case 'analyze': await cmdAnalysis(rest, view); return true;
|
||
case 'position': case 'size': await cmdPosition(rest, view); return true;
|
||
case 'discover': await cmdDiscover(rest, view, context); return true;
|
||
case 'report':
|
||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
|
||
await cmdReport(view, context); return true;
|
||
case 'run':
|
||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
|
||
await cmdRun(view, context); return true;
|
||
case 'path': cmdPath(view); return true;
|
||
default:
|
||
chunk(view, `\n❌ 알 수 없는 sub-command: \`${sub}\`. \`/stocks\` 로 도움말 보기.\n`);
|
||
return true;
|
||
}
|
||
} catch (e: any) {
|
||
chunk(view, `\n❌ 에러: ${e?.message ?? String(e)}\n`);
|
||
return true;
|
||
}
|
||
}
|