Files
koriweb a52bf6ee85 feat: /stocks 판정 결정론화 + /meet 정확도 파이프라인 (v2.2.211)
/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>
2026-06-10 18:51:54 +09:00

630 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}