Files
connectai/src/features/stocks/slashStocks.ts
T
koriwebandClaude Fable 5 443488a3b6 v2.2.297: stocks 목록 클라우드 백업 — Sheets _backup 탭 자동 백업 + /stocks backup·restore
로컬 .astra/stocks.json 뿐이던 워치리스트를 spreadsheet _backup 탭에 원본
JSON 그대로 자동 백업(변경 시 30초 디바운스). 새 컴퓨터에서는 /stocks
restore 한 번으로 복원. sheetsApi 에 ensureSheetTab/clearSheetRange 추가,
writeStocksStore 성공 시 변경 리스너 발화로 모든 변경 경로 포착.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:48:59 +09:00

738 lines
41 KiB
TypeScript
Raw 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 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';
import { syncToSheets } from './sheetsSync';
import { backupToSheet, restoreFromSheet } from './backupSync';
import { judgeStock } from './llmJudge';
import { sendStocksReport, buildReportText } from './telegramReport';
import { runOnceNow } from './stocksWatcher';
import { discoverStocks } from './stockDiscovery';
import { resolveSectorQuery, listSectorKeys, type SectorGroup } from './stockSectors';
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 backup — 원본 목록을 Sheets `_backup` 탭에 백업
* /stocks restore — `_backup` 탭에서 목록 복원 (기기 이전용)
* /stocks add <심볼> <이름> [투자성향]
* /stocks remove <심볼>
* /stocks judge <심볼> — LLM 4-criteria 평가
* /stocks discover [min] [max] — Naver 크롤 발굴 (시총 범위 억 단위, default 1000-5000)
* /stocks discover sector <섹터> [min] [max] — 섹터별 발굴 / discover sectors — 섹터 목록
* /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 renderListLine(s: ClassifiedStock): string {
const mkt: StockMarket = s.market ?? detectMarket(s.심볼);
const cur = typeof s.현재가 === 'number' ? formatMoney(s.현재가, mkt) : '-';
const rec = s.매수권장가 ?? '-';
const flag = mkt === 'US' ? '🇺🇸 ' : '';
return ` · ${flag}${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 ? formatMoney(p, detectMarket(sym)) : '조회 실패'}\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 cmdBackup(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n☁️ Google Sheets `_backup` 탭으로 백업 중...\n');
const r = await backupToSheet(context);
chunk(view, r.ok
? `\n✅ 백업 완료: ${r.count}개 종목. 새 컴퓨터에서는 \`/stocks restore\` 로 복원하세요.\n`
: `\n❌ 백업 실패: ${r.reason}\n`);
}
async function cmdRestore(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n☁️ Google Sheets `_backup` 탭에서 복원 중...\n');
const r = await restoreFromSheet(context);
if (!r.ok) {
chunk(view, `\n❌ 복원 실패: ${r.reason}\n`);
return;
}
const when = r.backedUpAt ? ` (백업 시각: ${r.backedUpAt})` : '';
chunk(view, `\n✅ 복원 완료: ${r.count}개 종목${when} — 기존 로컬 ${r.previousCount}개를 교체했습니다.\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 [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, 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> {
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();
const srcName = detectMarket(symbol) === 'US' ? 'Yahoo' : 'Naver';
// 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 실시간 수치를 시도(한국=Naver, 미국=Yahoo).
// 실패하면 stocks.json 저장값으로 폴백(결과에 데이터 출처 표기).
chunk(view, `\n📡 ${srcName} 펀더멘털 갱신 중: ${symbol}...\n`);
let fresh: Fundamentals | undefined;
try {
fresh = (await fetchFundamentalsRouted(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 quoteSummary) + *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.',
'외부 정보 추측 금지 — 주어진 데이터에서만 판단.',
'',
'**통화·시장 규칙:** 컨텍스트 첫 줄의 (시장/통화) 표기를 따른다. 한국=원, 미국=달러($).',
'가격·타점을 쓸 때 그 통화 기호를 그대로 사용하고 단위를 섞지 말 것.',
'미국 종목은 **유보율(retention ratio)을 보고하지 않는다 — "N/A"는 부정 신호가 아니다.**',
'미국의 재무 안정성은 부채비율(D/E)·시가총액으로만 판단하고, 유보율 부재를 약점으로 적지 말 것.',
'',
'**평가 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원 단위 / 미국: $10/$50/$100/$1000 단위), 잔량 추가 축소',
'- **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 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} (시장: ${mkt === 'US' ? '미국 🇺🇸' : '한국'}, 통화: ${f.currency ?? (mkt === 'US' ? 'USD' : 'KRW')})`,
`섹터: ${f.sectorHint ?? '-'}`,
'',
`── ${f.source ?? (mkt === 'US' ? 'Yahoo quoteSummary' : 'Naver')} 펀더멘털 ──`,
`시가총액: ${capText}`,
`ROE: ${f.roe !== undefined ? f.roe.toFixed(2) + '%' : '-'}`,
`영업이익률: ${f.operatingMargin !== undefined ? f.operatingMargin.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) : '-'}`,
`현재가: ${money(f.currentPrice)}`,
'',
'── Yahoo 1년 기술 지표 ──',
];
if (ma224) {
const ma = money(ma224.ma224Today);
const cp = money(ma224.currentPrice);
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 = 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
? '✅ 통과'
: `❌ 미통과${drop.failReason ? ` — ${drop.failReason}` : ''}`;
lines.push(
`낙폭과대: ${verdict}`,
` · 1년 최고가 ${hi} → 현재가 ${cp} (${fromHigh})`,
` · 60일 저점 ${lo} → 현재가 ${cp} (${fromLow})`,
);
} else {
lines.push('낙폭과대 분석: 시세 데이터 부족 (60일 미만)');
}
if (align) {
lines.push(
`MA 배열: ${align.alignment === '정배열' ? '✅ 정배열' : align.alignment === '역배열' ? '❌ 역배열' : '⚠️ 혼조'}`,
` · MA5 ${money(align.ma5)} / MA20 ${money(align.ma20)} / MA60 ${money(align.ma60)} / MA120 ${money(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;
}
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, 미국=Yahoo quoteSummary)
const f = await fetchFundamentalsRouted(symbol);
if (!f) {
chunk(view, mkt === 'US'
? '\n❌ Yahoo 에서 펀더멘털 데이터를 못 가져왔습니다. 티커를 확인해주세요(예: AAPL, MSFT). Yahoo crumb 일시 차단일 수도 있으니 잠시 후 재시도.\n'
: '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n');
return;
}
chunk(view, ` · ${fundSrc} 펀더멘털 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주 (한국)',
'예: `/stocks position AAPL 100000 2 5` → 미국 티커 ($ 표기)',
'',
].join('\n'));
return;
}
// 첫 인자가 종목 심볼이면(6자리 코드 또는 알파벳 티커) 분리, 아니면 곧바로 숫자 인자.
let symbol: string | undefined;
let nums: string[];
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;
}
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, ` · 총 자산: ${formatMoney(total, mkt)}\n`);
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${formatMoney(maxLoss, mkt)})\n`);
chunk(view, ` · 손절폭: ${stopPct}%\n`);
chunk(view, `\n → **권장 투자금: ${formatMoney(positionWon, mkt)}** (총자산의 ${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, ` · 현재가: ${formatMoney(price, mkt)}\n`);
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${formatMoney(actualWon, mkt)})\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> {
const parts = rest.split(/\s+/).filter(Boolean);
// `sectors` — 사용 가능한 섹터 키 목록 안내.
if (parts[0]?.toLowerCase() === 'sectors') {
chunk(view, `\n🏷️ 발굴 가능한 섹터:\n ${listSectorKeys().join(' · ')}\n\n사용법: \`/stocks discover sector <섹터> [min] [max]\`\n`);
return;
}
// `sector <섹터명> [min] [max]` — 섹터 필터 발굴.
let sectorGroup: SectorGroup | undefined;
let argParts = parts;
if (parts[0]?.toLowerCase() === 'sector') {
const sectorName = parts[1];
if (!sectorName) {
chunk(view, `\n섹터명이 필요합니다. \`/stocks discover sector <섹터> [min] [max]\`\n사용 가능: ${listSectorKeys().join(' · ')}\n`);
return;
}
const resolved = resolveSectorQuery(sectorName);
if (!resolved) {
chunk(view, `\n⚠️ '${sectorName}' 섹터를 못 찾았습니다. 사용 가능:\n ${listSectorKeys().join(' · ')}\n`);
return;
}
sectorGroup = resolved;
argParts = parts.slice(2); // min/max 는 sector <name> 뒤에서 파싱
}
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
const minCap = argParts[0] ? parseInt(argParts[0], 10) : 1000;
const maxCap = argParts[1] ? parseInt(argParts[1], 10) : 5000;
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
chunk(view, '\n사용법: `/stocks discover [min] [max]` 또는 `/stocks discover sector <섹터> [min] [max]` (억 단위)\n');
return;
}
const sectorLabel = sectorGroup ? ` · 섹터 '${sectorGroup.key}'` : '';
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}억${sectorLabel})\n`);
const candidates = await discoverStocks({
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
sector: sectorGroup,
onProgress: (msg) => chunk(view, msg + '\n'),
});
if (candidates.length === 0) {
chunk(view, '\n결과 없음.\n');
return;
}
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (멀티팩터 합성점수 내림차순 — Q퀄리티/V밸류/M모멘텀/S안정성)\n\n`);
for (const c of candidates) {
const price = c.fundamentals.currentPrice;
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
const sectorStr = c.fundamentals.sectorHint ? ` · ${c.fundamentals.sectorHint}` : '';
const weakMark = sectorGroup && c.passedKeywords.length < 3 ? ' ⚠️섹터내상대' : '';
const vtMark = c.score.valueTrapGuarded ? ' · ⚠️VT가드' : '';
chunk(view, ` · **${c.score.total}점** ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억${sectorStr}]${weakMark}\n`);
chunk(view, ` ${c.score.breakdown}${c.score.fScore ? ` · miniF ${c.score.fScore}` : ''}${vtMark} · 현재가 ${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`);
}
// ── 워치리스트 자동 편입 (v2.2.268, g1nation.stocks.discoverAutoAdd 기본 ON) ──
// 발굴 후보를 stocks.json 에 자동 추가 — 매수권장가(224일선 기반)까지 자동 설정돼
// 워처가 매수사정권 진입을 감시할 수 있는 상태로 편입. 기존 심볼은 건드리지 않음
// (사용자가 수동으로 다듬은 매수권장가·특이사항 보존).
const autoAdd = vscode.workspace.getConfiguration('g1nation').get<boolean>('stocks.discoverAutoAdd', true);
if (autoAdd) {
const store = readStocksStore();
const existing = new Set(store.map(s => s.심볼));
const fresh = candidates.filter(c => !existing.has(c.symbol));
if (fresh.length > 0) {
store.push(...fresh.map(c => c.asStock));
if (writeStocksStore(store)) {
const withRec = fresh.filter(c => c.asStock.매수권장가).length;
chunk(view, `\n📥 **워치리스트 자동 편입**: 신규 ${fresh.length}개 추가 (기존 ${candidates.length - fresh.length}개 스킵) · 매수권장가 자동 설정 ${withRec}개 (224일선 기반)\n`);
chunk(view, ` → 워처(09:00/15:00 KST)가 매수사정권 진입 시 텔레그램으로 알립니다.\n`);
} else {
chunk(view, `\n⚠️ 자동 편입 실패 — stocks.json 쓰기 불가 (워크스페이스 확인).\n`);
}
} else {
chunk(view, `\n📥 자동 편입: 후보 전원이 이미 워치리스트에 있어 추가 없음.\n`);
}
}
// ── LLM 매력도 분석 + 텔레그램 전송 (자동 chain) ──
// 사용자 의도: 발굴 목록이 나오면 *항상* 분석 + 텔레그램. 별도 명령 trigger 불필요.
// 실패해도 (LLM timeout / 텔레그램 미설정) 위 발굴 목록은 화면에 그대로 남아 있음.
chunk(view, '\n');
const topFive = await analyzeTopCandidates(candidates, (msg) => chunk(view, msg + '\n'));
chunk(view, renderTopFiveForChat(topFive));
const tgTopFiveOn = vscode.workspace.getConfiguration('g1nation').get<boolean>('messenger.discoverTopFive', true) !== false;
if (!tgTopFiveOn) {
// 텔레그램 발송 꺼짐 (Settings → 메신저) — 채팅 표시는 위에서 이미 완료.
} else if (context) {
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}억${sectorGroup ? ` · ${sectorGroup.key}` : ''}`;
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💡 후보는 워치리스트에 자동 편입됨 (`g1nation.stocks.discoverAutoAdd` 로 끄기 가능). 제거는 `/stocks remove <심볼>`.\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 명령** (한국 6자리 코드 + 미국 티커 🇺🇸 지원)',
'',
' `/stocks list` — 종목 + 신호',
' `/stocks check` — 현재가 갱신',
' `/stocks signal` — 매수사정권 종목만',
' `/stocks sync` — Google Sheets 동기화',
' `/stocks backup` — 목록 원본을 Sheets `_backup` 탭에 백업 (변경 시 자동 백업도 됨)',
' `/stocks restore` — `_backup` 탭에서 목록 복원 (새 컴퓨터 이전용)',
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
' `/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 discover sector <섹터> [min] [max]` — 섹터별 발굴 (예: `discover sector 반도체`)',
' `/stocks discover sectors` — 발굴 가능한 섹터 목록',
' `/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 'backup':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (backup 불가).\n'); return true; }
await cmdBackup(view, context); return true;
case 'restore':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (restore 불가).\n'); return true; }
await cmdRestore(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;
}
}