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 [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; } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 인자 파싱 — `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 { 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; } }