feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성

R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules)
  · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등)
  · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks)
  · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등)

R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리)

Stocks feature 신규: /stocks slash command (v2.2.152~158)
  · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신
  · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR)
  · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴
  · LLM Top 5 매력도 분석 + Telegram 자동 보고서
  · KST 09:00/15:00 watcher 자동 모니터링

대화 연속성 (v2.2.150~157):
  · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor
  · thin follow-up 분류 → boilerplate 헤더 suppression
  · slash 명령 결과 chatHistory mirror (capture wrapper)
  · echo/parrot 금지 system prompt rule

기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+237
View File
@@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import type { DiscoveredCandidate } from './stockDiscovery';
/**
* Discover 결과를 LLM 에게 던져 *매력도 Top 5* 추출 + 채팅/텔레그램 발송.
*
* 호출 흐름 (slashStocks 의 cmdDiscover 끝에 자동 chain):
* 1) buildAnalysisPrompt(candidates) — 20개 후보 데이터를 LLM 입력 형식으로 직렬화
* 2) AIService.chat(...) — Astra 의 기본 모델로 평가
* 3) parseTopFive(output) — LLM 응답을 구조화된 5개 항목으로 파싱 (관대)
* 4) renderForChat / renderForTelegram — 사용자에게 보일 메시지 두 가지 형식
* 5) sendToTelegram(...) — 텔레그램 chatId 로 발송 (실패 silent)
*
* 작은 모델 (gemma 4B 등) 이 형식을 흩뜨려도 깨지지 않게 — parseTopFive 는 *느슨한*
* 번호 매칭으로 5개 라인만 추출, 실패 시 raw text 그대로 fallback.
*/
export interface TopFiveItem {
rank: number;
name: string;
symbol: string;
/** LLM 이 한 줄로 정리한 매력 포인트. */
pitch: string;
/** 원본 후보 (선택적으로 ROE/영업이익률 등 인용용). */
candidate?: DiscoveredCandidate;
}
export interface TopFiveResult {
items: TopFiveItem[];
/** LLM 의 종합 코멘트 (1-2 문장). 없을 수 있음. */
summary?: string;
/** LLM 응답 원문 (디버그 / fallback 출력용). */
raw: string;
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.',
'제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.',
'',
'**평가 기준 (우선순위 순):**',
' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)',
' 2. ROE 절대 수치 — 15% 이상 강하게 가산',
' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)',
' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산',
' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check',
'',
'**출력 형식 (반드시 이대로):**',
'🎯 매력도 Top 5',
'',
'1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>',
' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>',
'2. ...',
'...',
'5. ...',
'',
'종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>',
'',
'*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.',
].join('\n');
function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string {
const lines: string[] = [
`발굴 후보 ${candidates.length}개. 매력도 Top 5 골라라.`,
'',
];
for (const c of candidates) {
const f = c.fundamentals;
lines.push(
`· ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]`,
` ROE ${f.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}% · 부채비율 ${f.debtRatio?.toFixed(1) ?? '-'}% · PER ${f.per?.toFixed(1) ?? '-'} · PBR ${f.pbr?.toFixed(1) ?? '-'}`,
` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`,
'',
);
}
return lines.join('\n');
}
/**
* LLM 응답을 5개 항목으로 파싱. 작은 모델이 형식 흩뜨려도 잡힐 수 있게 관대하게:
* - "1. <이름> (<6자리>) — <문구>" / "1) <이름> ..." / "1: ..." 모두 받음
* - 종목 매핑은 6자리 심볼 정규식으로 cross-check
*/
function parseTopFive(raw: string, candidates: DiscoveredCandidate[]): TopFiveResult {
const items: TopFiveItem[] = [];
const lines = raw.split('\n');
const symbolMap = new Map(candidates.map(c => [c.symbol, c]));
const itemRe = /^\s*([1-5])[\.\)\:]\s+(.+?)\s*[\(\[](\d{6})[\)\]]\s*[—\-:]\s*(.+)$/;
for (const line of lines) {
const m = line.match(itemRe);
if (!m) continue;
const rank = parseInt(m[1], 10);
if (items.find(i => i.rank === rank)) continue;
items.push({
rank,
name: m[2].trim(),
symbol: m[3],
pitch: m[4].trim(),
candidate: symbolMap.get(m[3]),
});
}
items.sort((a, b) => a.rank - b.rank);
// 종합 코멘트 추출 — "종합:" 또는 "총평:" 라인.
const summaryMatch = raw.match(/(?:|)\s*[:]\s*(.+?)(?:\n\n|$)/s);
const summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined;
return { items, summary, raw };
}
export async function analyzeTopCandidates(
candidates: DiscoveredCandidate[],
onProgress?: (msg: string) => void,
): Promise<TopFiveResult> {
if (candidates.length === 0) {
return { items: [], raw: '' };
}
onProgress?.('🤖 LLM 분석 시작 — 후보 ' + candidates.length + '개 평가 중...');
const ai = new AIService();
try {
const result = await ai.chat({
system: SYSTEM_PROMPT,
user: buildAnalysisPrompt(candidates),
timeoutMs: 90_000,
});
if (result.empty || !result.content.trim()) {
logError('Discovery analyzer: LLM 빈 응답.');
return { items: [], raw: '' };
}
const parsed = parseTopFive(result.content, candidates);
logInfo('Discovery analyzer: parsed Top 5.', {
parsedCount: parsed.items.length,
model: result.model,
});
return parsed;
} catch (e: any) {
logError('Discovery analyzer: LLM 호출 실패.', { error: e?.message ?? String(e) });
return { items: [], raw: '' };
}
}
/** 채팅 webview 용 — markdown 친화적 멀티라인. */
export function renderTopFiveForChat(result: TopFiveResult): string {
if (result.items.length === 0) {
return result.raw
? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n`
: '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\n';
}
const lines: string[] = ['\n🎯 **Astra 매력도 Top 5**\n'];
for (const it of result.items) {
lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`);
if (it.candidate) {
const f = it.candidate.fundamentals;
lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`);
}
}
if (result.summary) {
lines.push('', `💬 ${result.summary}`);
}
return lines.join('\n') + '\n';
}
/** 텔레그램용 — Markdown V1, 짧고 깔끔. 4096자 제한 안에서. */
function pickChatIdForReport(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) {
return allowed[0];
}
const fallback = cfg.get<number>('stocks.telegramChatId', 0);
return fallback && Number.isFinite(fallback) ? fallback : null;
}
function formatKstNow(): string {
const fmt = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
});
const parts = fmt.formatToParts(new Date());
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
}
export function renderTopFiveForTelegram(
result: TopFiveResult,
rangeLabel: string,
): string {
if (result.items.length === 0) {
return `🎯 *Astra 발굴 Top 5* (${rangeLabel})\n${formatKstNow()}\n\n⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.`;
}
const lines: string[] = [
`🎯 *Astra 발굴 Top 5* (${rangeLabel})`,
formatKstNow(),
'',
];
for (const it of result.items) {
lines.push(`${it.rank}\\. *${it.name}* (\`${it.symbol}\`)`);
lines.push(` ${it.pitch}`);
if (it.candidate) {
const f = it.candidate.fundamentals;
lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`);
}
lines.push('');
}
if (result.summary) {
lines.push(`💬 ${result.summary}`);
}
const joined = lines.join('\n');
return joined.length > 3800 ? joined.slice(0, 3800) + '\n…(잘림)' : joined;
}
export async function sendTopFiveToTelegram(
context: vscode.ExtensionContext,
text: string,
): Promise<{ ok: boolean; reason?: string }> {
const chatId = pickChatIdForReport();
if (chatId === null) {
return { ok: false, reason: '텔레그램 chatId 미설정 (allowedChatIds 또는 stocks.telegramChatId)' };
}
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
return { ok: false, reason: '텔레그램 봇 토큰 없음' };
}
const client = new TelegramHttpClient({ getToken: () => token });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Top 5 텔레그램 발송 완료.', { chatId, chars: text.length });
return { ok: true };
} catch (e: any) {
logError('Top 5 텔레그램 발송 실패.', { chatId, error: e?.message ?? String(e) });
return { ok: false, reason: e?.message ?? String(e) };
}
}
+2
View File
@@ -0,0 +1,2 @@
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
export { handleStocksCommand } from './slashStocks';
+127
View File
@@ -0,0 +1,127 @@
import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { readStocksStore, updateStock } from './stocksStore';
import type { Stock } from './types';
/**
* `/stocks judge <심볼>` 의 코어 — 종목의 재무 데이터를 LLM 에게 던져
* "3/4 필터" 4-criteria (ROE / 성장성 / 유동성 / 수익성) 평가를 받고
* stocks.json 의 `"3/4 필터"` 필드를 업데이트.
*
* LLM 신뢰도 이슈는 사용자가 인지한 trade-off — 자동화 결과를 *제안* 으로 보고
* 결과 텍스트에 "자동 평가" prefix 를 박아 수동 판정과 구분 가능하게.
*
* 출력 형식 (LLM 에게 강제):
* 첫 줄: "충족 (ROE, 성장성, 유동성)" 또는 "미충족"
* 둘째 줄 ~ : (선택) 이유 한두 줄 — 호출자가 webview 에 같이 보여줌
*
* `.includes("충족")` 매칭은 unchanged → signalClassifier 가 자동으로 인식.
*/
export interface JudgeResult {
ok: boolean;
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
filterText?: string;
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */
rationale?: string;
error?: string;
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 가치 평가 보조 도구다. 사용자가 제공한 종목의 *재무 지표만* 보고',
'필터 평가를 내린다. 외부 정보 추측 금지 — 주어진 숫자에서만 판단.',
'',
'**사용 가능한 8개 평가 키워드** (사용자의 실제 분류 패턴):',
' - ROE: ROE(25E) ≥ 10% 이면 통과. 15% 이상은 우수 (통과 강하게).',
' - 성장성: 영업이익률(25E) ≥ 15% 또는 상장일이 3년 이내 신규성장주.',
' - 유동성: 유보율 ≥ 1,000% 이면 통과 (자기자본 충분).',
' - 수익성: 영업이익률(25E) ≥ 10% 이면 통과. 20% 이상이면 "수익성 개선" 표기 가능.',
' - 영업효율: 영업이익률(25E) ≥ 15% + ROE ≥ 8% 동시 만족 (효율성 시그널).',
' - 기술력: "최대 먹거리" 가 AI / 반도체 / 배터리 / 바이오 / 기술 영역이고 PBR ≥ 2 (시장이 기술 premium 인정).',
' - 안정성: 유보율 ≥ 3,000% + 시가총액 ≥ 5,000억 (대형주 안전).',
' - PBR: PBR ≤ 1.5 이면 통과 (저평가 시그널, 저평가우량주에서 주로 사용).',
'',
'**투자성향별 우선 적용 키워드:**',
' - 스윙/중기: ROE / 성장성 / 유동성 / 수익성 위주.',
' - 장기투자: 성장성 / 유동성 / 기술력 / 영업효율 위주.',
' - 저평가우량주: PBR / ROE 필수 + (성장성 또는 수익성 또는 안정성) 중 1개.',
'',
'**판정 규칙:**',
' - 위 8개 키워드 중 *3개 이상* 통과 → "충족 (통과한 항목들 콤마 구분)" 형식.',
' 예: "충족 (ROE, 성장성, 유동성)" / "충족 (PBR, ROE, 안정성)" / "충족 (성장성, 유동성, 수익성 개선)"',
' - 3개 미만 → "미충족 (사유: 핵심 약점 한 줄)"',
'',
'**키워드 선택 가이드:**',
' - 한 종목에서 통과한 키워드 *모두* 나열하지 말고 *그 종목 성격을 가장 잘 보여주는 3개* 만 선택.',
' - 투자성향별 우선 키워드를 먼저 포함시키고, 빠진 부분은 다른 키워드로 메움.',
'',
'**실제 패턴 예시** (학습용 — 사용자가 이미 분류한 종목들):',
' - 마녀공장 (ROE 15.6%, 영업이익률 18.0%, 유보율 5,800%, 스윙/중기) → "충족 (ROE, 성장성, 유동성)"',
' - 기가비스 (ROE 7.23%, 영업이익률 25.7%, 유보율 4,250%, 신규 상장 2년차, 스윙/중기) → "충족 (성장성, 유동성, 수익성 개선)" (ROE 가 10% 미만이라 빠짐, 대신 수익성 강함)',
' - 엔켐 (ROE 12.4%, 영업이익률 8.5%, 유보율 1,250%, 스윙/중기) → "충족 (성장성, 유동성)" (영업이익률이 10% 미만이라 수익성 빠짐)',
'',
'**출력 형식 (반드시 이대로):**',
' 1번째 줄: 위 형식의 판정 문구만 (다른 텍스트 절대 금지)',
' 2번째 줄: 빈 줄',
' 3번째 줄 이후: 평가 근거 2-3 문장 — 어떤 지표가 어떤 threshold 를 통과/미통과했는지 *구체 수치 인용*.',
].join('\n');
function buildUserPrompt(s: Stock): string {
const lines = [
`종목: ${s.} (${s.})`,
`상장일: ${s. ?? '미상'}`,
`투자성향: ${s. ?? '미분류'}`,
`유보율: ${s. ?? '-'}`,
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
`PBR: ${s.PBR ?? '-'}`,
`시가총액: ${s. ?? '-'}`,
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
`특이사항: ${s. ?? '-'}`,
'',
'위 데이터로 4-criteria 필터 판정.',
];
return lines.join('\n');
}
export async function judgeStock(symbol: string): Promise<JudgeResult> {
const store = readStocksStore();
const stock = store.find(s => s. === symbol);
if (!stock) {
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` };
}
const ai = new AIService();
try {
const result = await ai.chat({
system: SYSTEM_PROMPT,
user: buildUserPrompt(stock),
});
if (result.empty || !result.content.trim()) {
return { ok: false, error: 'LLM 이 빈 응답 반환' };
}
const lines = result.content.split('\n');
const firstLine = (lines[0] || '').trim();
const rationale = lines.slice(2).join('\n').trim() || undefined;
if (!firstLine) return { ok: false, error: 'LLM 응답 형식 오류 (첫 줄 비어있음)' };
// 텍스트 형식 검증 — "충족" 또는 "미충족" 으로 시작해야 signalClassifier 가 인식.
if (!/^(충족|미충족)/.test(firstLine)) {
return { ok: false, error: `LLM 응답 형식 오류 (충족/미충족 prefix 없음): ${firstLine.slice(0, 80)}` };
}
// "자동 평가" prefix 박아서 수동/자동 구분 가능하게.
const filterText = `[자동 평가] ${firstLine}`;
const wrote = updateStock(symbol, { '3/4 필터': filterText });
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
logInfo('Stocks LLM judge 완료.', { symbol, filterText, model: result.model });
return { ok: true, filterText, rationale };
} catch (e: any) {
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) });
return { ok: false, error: e?.message ?? String(e) };
}
}
+161
View File
@@ -0,0 +1,161 @@
import { logError, logInfo } from '../../utils';
/**
* Naver Finance 비공식 JSON API 로 개별 종목 펀더멘털 fetch.
*
* 두 endpoint 합성:
* - `/api/stock/<code>/integration` — 시총 텍스트 / PER / PBR / EPS / 현재가
* - `/api/stock/<code>/finance/annual` — ROE / 영업이익률 / 유보율 / 부채비율
* (rowList 안의 row.title 매칭, 최근 연도 컬럼 값 사용)
*
* JSON 응답이라 selector 깨질 일이 없고, label 매칭도 정확 (rowList[i].title === 'ROE').
* 사용자가 별도 인증 / API 키 필요 없음 — Naver Finance 모바일 페이지가 쓰는 그대로.
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const INTEGRATION_URL = 'https://m.stock.naver.com/api/stock';
export interface Fundamentals {
symbol: string;
/** 연간 재무제표 (최근 *확정* 연도). */
roe?: number; // %
operatingMargin?: number; // % (영업이익률)
retentionRatio?: number; // % (유보율)
debtRatio?: number; // % (부채비율)
/** integration API 의 현재가 + 평가지표. */
per?: number;
pbr?: number;
eps?: number;
marketCapEok?: number; // 억 단위
currentPrice?: number;
/** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */
sectorHint?: string;
}
interface NaverIntegrationResponse {
stockName?: string;
totalInfos?: Array<{ code: string; key: string; value: string }>;
/** 종목 분류 / 업종 정보가 들어있을 수 있음 (옵션). */
industryInfo?: { name?: string };
}
interface NaverFinanceAnnualResponse {
financeInfo?: {
trTitleList?: Array<{ isConsensus: 'Y' | 'N'; title: string; key: string }>;
rowList?: Array<{ title: string; columns: Record<string, { value: string }> }>;
};
}
/** "12,090" / "23.64배" / "5,800%" / "1,710조 365억" / "-" 텍스트에서 숫자 추출. */
function parseNumber(raw: string | undefined): number | undefined {
if (!raw) return undefined;
const cleaned = raw.replace(/,/g, '').replace(/배|%|원|억|조/g, '').trim();
if (!cleaned || cleaned === '-' || cleaned === 'N/A') return undefined;
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : undefined;
}
/** "1,710조 365억" / "2,787억" / "5조" → 억 단위 정수. */
function parseMarketCapText(text: string | undefined): number | undefined {
if (!text) return undefined;
const cleaned = text.replace(/원|\s/g, '');
const joMatch = cleaned.match(/([\d,]+)조/);
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
const total = jo * 10000 + eok;
return total > 0 ? total : undefined;
}
/** trTitleList 에서 *최근 확정* (isConsensus = 'N') 컬럼 키 선택. */
function pickLatestConfirmedKey(titles?: Array<{ isConsensus: 'Y' | 'N'; key: string }>): string | null {
if (!titles || titles.length === 0) return null;
// 'N' 만 필터 → key 내림차순 → 첫 번째.
const confirmed = titles.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse();
return confirmed[0] ?? null;
}
async function fetchIntegration(symbol: string, timeoutMs: number): Promise<NaverIntegrationResponse | null> {
try {
const res = await fetch(`${INTEGRATION_URL}/${symbol}/integration`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json() as NaverIntegrationResponse;
} catch (e: any) {
logError('Naver integration fetch 실패.', { symbol, error: e?.message ?? String(e) });
return null;
}
}
async function fetchFinanceAnnual(symbol: string, timeoutMs: number): Promise<NaverFinanceAnnualResponse | null> {
try {
const res = await fetch(`${INTEGRATION_URL}/${symbol}/finance/annual`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json() as NaverFinanceAnnualResponse;
} catch (e: any) {
logError('Naver finance annual fetch 실패.', { symbol, error: e?.message ?? String(e) });
return null;
}
}
export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Promise<Fundamentals | null> {
const [integ, fin] = await Promise.all([
fetchIntegration(symbol, timeoutMs),
fetchFinanceAnnual(symbol, timeoutMs),
]);
if (!integ && !fin) return null;
const out: Fundamentals = { symbol };
// integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적).
if (integ?.totalInfos) {
const map = new Map(integ.totalInfos.map(i => [i.code, i.value]));
out.per = parseNumber(map.get('per'));
out.pbr = parseNumber(map.get('pbr'));
out.eps = parseNumber(map.get('eps'));
out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice'));
out.marketCapEok = parseMarketCapText(map.get('marketValue'));
}
if (integ?.industryInfo?.name) out.sectorHint = integ.industryInfo.name;
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList);
if (latestKey) {
const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r]));
const valueOf = (title: string): number | undefined => {
const row = rowByTitle.get(title);
if (!row) return undefined;
return parseNumber(row.columns[latestKey]?.value);
};
out.roe = valueOf('ROE');
out.operatingMargin = valueOf('영업이익률');
out.retentionRatio = valueOf('유보율');
out.debtRatio = valueOf('부채비율');
}
}
return out;
}
/** 일괄 fetch — throttle 300ms. JSON API 가 가벼우니 HTML 크롤 500ms 보다 빠르게. */
export async function fetchAllFundamentals(
symbols: string[],
onProgress?: (symbol: string, fund: Fundamentals | null, i: number, total: number) => void,
): Promise<Map<string, Fundamentals>> {
const out = new Map<string, Fundamentals>();
let i = 0;
for (const symbol of symbols) {
i++;
const fund = await fetchFundamentals(symbol);
if (fund) out.set(symbol, fund);
onProgress?.(symbol, fund, i, symbols.length);
await new Promise(r => setTimeout(r, 300));
}
logInfo(`Naver fundamentals 일괄 fetch: ${out.size}/${symbols.length} 성공.`);
return out;
}
+151
View File
@@ -0,0 +1,151 @@
import { logError, logInfo } from '../../utils';
/**
* Naver Finance *비공식 JSON API* 로 시가총액 순위 fetch.
*
* - 코스피: `https://m.stock.naver.com/api/stocks/marketValue/KOSPI?page=N&pageSize=50`
* - 코스닥: `https://m.stock.naver.com/api/stocks/marketValue/KOSDAQ?page=N&pageSize=50`
*
* 응답:
* { stocks: [ { itemCode, stockName, marketValue, marketValueHangeul, closePrice, ... }, ... ] }
*
* `marketValueHangeul` 은 "2,787억" / "1,710조 365억" 같은 사람 친화 텍스트.
* 우리는 이걸 *억 단위 정수* 로 파싱 — 시가총액 범위 필터 (사용자 옵션) 와 일관성.
*
* Why JSON over HTML:
* - 페이지 디자인 변경 무관 (스키마는 더 안정적)
* - EUC-KR 디코딩 불필요 (JSON 은 UTF-8)
* - cheerio 의존성 제거
* - 더 빠름 (HTML 전체 다운로드 X, JSON 만)
*
* Caveat: *비공식* — Naver 가 막을 수 있음. 정식 ToS 보장 X. 개인 학습용 가정.
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const BASE_URL = 'https://m.stock.naver.com/api/stocks/marketValue';
export type Market = 'kospi' | 'kosdaq';
export interface ScreenerEntry {
/** 6자리 종목코드. */
symbol: string;
/** 종목명. */
name: string;
/** 시가총액 (억원 단위 정수). marketValueHangeul 텍스트에서 파싱. */
marketCapEok: number;
/** 종가 (옵션). */
closePrice?: number;
market: Market;
}
/**
* "1,710조 365억" / "2,787억" / "17조" 같은 텍스트를 *억 단위 정수* 로 환산.
* - 조 단위: ×10,000
* - 억 단위: ×1
*/
function parseMarketCapHangeul(text: string | undefined): number {
if (!text) return 0;
const cleaned = text.replace(/원|\s/g, '');
const joMatch = cleaned.match(/([\d,]+)조/);
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
// "1,710조 365억" → jo=1710, eok=365 → 17,103,650 안 됨. 1,710조 365 = 17,103,650 억? 아니다.
// 1,710조 = 17,100,000 억. + 365억 = 17,100,365 억.
return jo * 10000 + eok;
}
interface NaverStockListItem {
itemCode: string;
stockName: string;
marketValue?: string; // 백만원 단위 (단위 모호, 사용 안 함)
marketValueHangeul?: string; // "2,787억원" — 사용
closePrice?: string; // "12,090"
}
interface NaverMarketValueResponse {
stocks: NaverStockListItem[];
totalCount?: number;
pageSize?: number;
page?: number;
}
async function fetchPage(market: Market, page: number): Promise<NaverStockListItem[]> {
const category = market === 'kospi' ? 'KOSPI' : 'KOSDAQ';
const url = `${BASE_URL}/${category}?page=${page}&pageSize=50`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Naver screener HTTP ${res.status} (${market} p${page})`);
const data = await res.json() as NaverMarketValueResponse;
if (!Array.isArray(data.stocks)) {
throw new Error(`Naver screener: stocks 필드 누락 (${market} p${page})`);
}
return data.stocks;
}
function toEntry(item: NaverStockListItem, market: Market): ScreenerEntry {
const marketCapEok = parseMarketCapHangeul(item.marketValueHangeul);
const closePriceNum = item.closePrice
? parseFloat(item.closePrice.replace(/,/g, ''))
: undefined;
return {
symbol: item.itemCode,
name: item.stockName,
marketCapEok,
closePrice: Number.isFinite(closePriceNum as number) ? closePriceNum : undefined,
market,
};
}
/**
* 한 시장 전체 (또는 maxPages) 의 후보 풀 fetch. 시가총액 범위 (억) 로 1차 필터.
* - throttle: 300ms (HTML 크롤 500ms 보다 빠르게 — JSON 이라 가벼움).
* - Naver 가 시총 큰 순으로 정렬해 반환하므로, *시총 maxCap 보다 큰 페이지* 는 일찍 종료 가능.
*/
export async function screenMarket(opts: {
market: Market;
maxPages?: number;
minMarketCapEok?: number;
maxMarketCapEok?: number;
onProgress?: (page: number, totalSoFar: number) => void;
}): Promise<ScreenerEntry[]> {
const maxPages = opts.maxPages ?? 20;
const minCap = opts.minMarketCapEok ?? 0;
const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER;
const collected: ScreenerEntry[] = [];
for (let page = 1; page <= maxPages; page++) {
try {
const items = await fetchPage(opts.market, page);
if (items.length === 0) {
logInfo(`Naver screener p${page} ${opts.market}: 0 rows — 종료.`);
break;
}
const entries = items.map(it => toEntry(it, opts.market));
// 시총 큰 순 → 모든 종목 시총이 maxCap 보다 작아진 페이지부터는 maxCap 위 종목 없음 (정렬 보장).
// minCap 보다 작아진 페이지는 그 뒤 모든 종목이 더 작음 → early exit.
let pageBelowMin = true;
for (const e of entries) {
if (e.marketCapEok > maxCap) continue;
if (e.marketCapEok < minCap) continue;
collected.push(e);
pageBelowMin = false;
}
// 이 페이지의 *모든* 종목이 minCap 미만이면 더 작은 종목들만 남음 → early exit.
if (entries.every(e => e.marketCapEok < minCap)) {
logInfo(`Naver screener ${opts.market}: p${page} 전부 minCap(${minCap}억) 미만 — early exit.`);
opts.onProgress?.(page, collected.length);
break;
}
opts.onProgress?.(page, collected.length);
await new Promise(r => setTimeout(r, 300));
} catch (e: any) {
logError(`Naver screener p${page} ${opts.market} 실패.`, { error: e?.message ?? String(e) });
// partial 결과라도 반환 — 한 페이지 실패해도 continue.
}
}
logInfo(`Naver screener ${opts.market}: ${collected.length}개 (${minCap}억 ~ ${maxCap}억).`);
return collected;
}
+99
View File
@@ -0,0 +1,99 @@
import * as vscode from 'vscode';
import { writeSheetRange, type SheetValues } from '../sheets';
import { logError, logInfo } from '../../utils';
import { classifyAll } from './signalClassifier';
import { readStocksStore } from './stocksStore';
import type { ClassifiedStock } from './types';
/**
* 분류된 종목 리스트 → Google Sheets 동기화.
*
* invest_results/gs_update_api.js 의 시트 레이아웃을 유지:
* - Sheet1!A1:O = 스윙/중기
* - Sheet2!A1:O = 장기투자
* - Sheet3!A1:O = 저평가우량주
* (시트 이름은 spreadsheet 의 1/2/3번째 탭 — 사용자가 본인 spreadsheet 에 미리 만들어 둠.)
*
* spreadsheet ID 는 설정 `g1nation.stocks.spreadsheetId` 에서 읽음. 미설정이면 skip 안내.
* OAuth token 은 calendar 와 공유 (oauth.ts SCOPE 에 spreadsheets 이미 포함).
*/
const HEADER = ['종목명', '심볼', '상장일', '현재가', '적정주가', '매수권장가', '3/4 필터', '매수 신호', 'ROE', 'PBR', '영업이익률', '유보율', 'PER', 'EPS', '특이사항'];
function buildSheetRows(classified: ClassifiedStock[]): SheetValues {
const rows: SheetValues = [HEADER];
for (const s of classified) {
rows.push([
s.,
s.,
s. ?? '',
s. ?? 0,
s. ?? '',
s. ?? '',
s.filterPass ? 'Pass' : 'Fail',
s.signalText,
s['ROE(25E)'] ?? '',
s.PBR ?? '',
s['영업이익률(25E)'] ?? '',
s. ?? '',
s['PER(25E)'] ?? '',
s['EPS(25E)'] ?? '',
s. ?? '',
]);
}
return rows;
}
/**
* 3 시트 일괄 동기화. 결과는 시트별 성공/실패 카운트로 반환 — caller 가 webview 에 표시.
*
* Sheet 이름은 1/2/3번째 탭 인덱스가 아니라 *이름* 으로 range 를 짜야 안전.
* 사용자가 본인 spreadsheet 의 탭 이름을 코드 기본값과 맞추도록 안내:
* 'Sheet1' / 'Sheet2' / 'Sheet3' — 또는 설정으로 override 가능.
*/
export async function syncToSheets(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; errors: string[]; updatedRanges: string[] }> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const spreadsheetId = (cfg.get<string>('stocks.spreadsheetId') || '').trim();
if (!spreadsheetId) {
return {
ok: false,
errors: ['Settings 에 g1nation.stocks.spreadsheetId 가 설정되지 않았습니다.'],
updatedRanges: [],
};
}
const sheetSwing = (cfg.get<string>('stocks.sheetSwing') || 'Sheet1').trim();
const sheetLong = (cfg.get<string>('stocks.sheetLong') || 'Sheet2').trim();
const sheetUltra = (cfg.get<string>('stocks.sheetUltraLow') || 'Sheet3').trim();
const store = readStocksStore();
const groups = classifyAll(store);
const errors: string[] = [];
const updatedRanges: string[] = [];
const tasks: Array<{ tab: string; rows: SheetValues; label: string }> = [
{ tab: sheetSwing, rows: buildSheetRows(groups.swing), label: '스윙/중기' },
{ tab: sheetLong, rows: buildSheetRows(groups.long), label: '장기투자' },
{ tab: sheetUltra, rows: buildSheetRows(groups.ultraLow), label: '저평가우량주' },
];
for (const t of tasks) {
if (t.rows.length <= 1) continue;
const range = `${t.tab}!A1:O${t.rows.length}`;
try {
const r = await writeSheetRange(context, spreadsheetId, range, t.rows);
if (r.ok) {
updatedRanges.push(r.updatedRange);
logInfo(`Stocks sheets sync: ${t.label} OK.`, { range: r.updatedRange, cells: r.updatedCells });
} else {
errors.push(`${t.label}: ${r.error}`);
logError(`Stocks sheets sync: ${t.label} 실패.`, { error: r.error });
}
} catch (e: any) {
errors.push(`${t.label}: ${e?.message ?? String(e)}`);
}
}
return { ok: errors.length === 0, errors, updatedRanges };
}
+69
View File
@@ -0,0 +1,69 @@
import type { Stock, ClassifiedStock, Signal } from './types';
/**
* invest_results/gs_update_api.js 의 분류 로직 포팅.
*
* 1. filterPass = "3/4 필터" 에 "충족" 포함 여부 (정성 필터, 사용자가 사전 평가)
* 2. isPriceInZone = 현재가 > 0 && 매수권장가 > 0 && 현재가 <= 매수권장가
* 3. signal:
* - filterPass + priceInZone → BUY_ZONE (sortScore 2, "🚨 매수사정권!")
* - filterPass + !priceInZone → OVERVALUED (sortScore 1, "⚠️ 고평가! 가격 조정 감시 중")
* - !filterPass → HOLD (sortScore 0, "관망")
*
* 텍스트 문구는 사용자 익숙한 텍스트 그대로 유지 (텔레그램 보고서/Sheets 양쪽에 동일하게 보임).
*/
const SIGNAL_TEXT: Record<Signal, string> = {
BUY_ZONE: '🚨 매수사정권! (바닥 안착 시 매수 개시)',
OVERVALUED: '⚠️ 고평가! 가격 조정 감시 중',
HOLD: '관망',
};
/** "23,760" 같은 콤마 포함 텍스트 → number. 빈 문자열 / 파싱 실패 시 0. */
function parsePrice(raw: string | number | undefined): number {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
if (!raw) return 0;
const cleaned = String(raw).replace(/,/g, '').trim();
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : 0;
}
export function classifyStock(s: Stock): ClassifiedStock {
const curPrice = parsePrice(s.);
const recPrice = parsePrice(s.);
const isPriceInZone = curPrice > 0 && recPrice > 0 && curPrice <= recPrice;
const filterPass = (s['3/4 필터'] || '').toString().includes('충족');
let signal: Signal = 'HOLD';
let sortScore: 0 | 1 | 2 = 0;
if (filterPass) {
if (isPriceInZone) { signal = 'BUY_ZONE'; sortScore = 2; }
else { signal = 'OVERVALUED'; sortScore = 1; }
}
return {
...s,
signal,
sortScore,
filterPass,
signalText: SIGNAL_TEXT[signal],
};
}
/** 전체 분류 + 투자성향별 정렬. 텔레그램 / 시트 동기화 둘 다 이걸 호출. */
export function classifyAll(stocks: Stock[]): {
swing: ClassifiedStock[];
long: ClassifiedStock[];
ultraLow: ClassifiedStock[];
all: ClassifiedStock[];
} {
const classified = stocks.map(classifyStock);
const filterByProfile = (profile: Stock['투자성향']) =>
classified.filter(s => s. === profile).sort((a, b) => b.sortScore - a.sortScore);
return {
swing: filterByProfile('스윙/중기'),
long: filterByProfile('장기투자'),
ultraLow: filterByProfile('저평가우량주'),
all: classified.slice().sort((a, b) => b.sortScore - a.sortScore),
};
}
+279
View File
@@ -0,0 +1,279 @@
import * as vscode from 'vscode';
import { logInfo } from '../../utils';
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
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();
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`);
const r = await judgeStock(symbol);
if (!r.ok) {
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
return;
}
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n`);
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\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 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 '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;
}
}
+159
View File
@@ -0,0 +1,159 @@
import { logInfo } from '../../utils';
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
import type { Stock } from './types';
/**
* `/stocks discover` 파이프라인 — Naver 크롤로 후보 발굴.
*
* 단계:
* 1. 시가총액 순위 페이지 크롤 (코스피 + 코스닥) — 종목코드 + 시총만 (가벼움)
* 2. 시가총액 범위로 1차 필터 (예: 1,000억 ~ 5,000억)
* 3. 통과한 종목 (보통 50-200개) 의 main 페이지 크롤 — 깊은 펀더멘털
* 4. 8 키워드 (llmJudge.ts 와 동일 임계값) 적용 → 3개 이상 통과한 종목만 선별
* 5. ScreenedCandidate[] 반환 → 호출자가 사용자 confirm 후 stocks.json 에 추가
*
* llmJudge 와 *같은 임계값* 사용 — discover 와 judge 가 *같은 기준* 으로 동작해야
* 사용자의 mental model 이 일관됨. 임계값이 두 군데에 박혀 있는 건 단점이지만,
* judge 는 LLM 프롬프트 (자연어) 고 discover 는 코드 (정수 비교) 라 형태가 달라 공유가 까다로움.
* 향후 thresholds 를 단일 module 로 추출하는 게 좋음 (now: TODO).
*/
export interface DiscoverOptions {
/** 시가총액 하한 (억). default 1000 (1천억). */
minMarketCapEok?: number;
/** 시가총액 상한 (억). default 5000 (5천억). */
maxMarketCapEok?: number;
/** 시장 — default ['kospi', 'kosdaq']. */
markets?: Market[];
/** 시장당 크롤 페이지 수 — default 10 (페이지당 50개 = 500개/시장). */
maxPagesPerMarket?: number;
/** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */
limit?: number;
/** 진행률 콜백 (UI 가 사용). */
onProgress?: (msg: string) => void;
}
export interface DiscoveredCandidate {
symbol: string;
name: string;
market: Market;
marketCapEok: number;
/** 통과한 키워드들 (선별 후 최대 3개). */
passedKeywords: string[];
/** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */
asStock: Stock;
fundamentals: Fundamentals;
}
/** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */
function evaluateKeywords(f: Fundamentals): string[] {
const passed: string[] = [];
const roe = f.roe ?? 0;
const om = f.operatingMargin ?? 0;
const retention = f.retentionRatio ?? 0;
const pbr = f.pbr ?? Number.MAX_SAFE_INTEGER;
const mktCap = f.marketCapEok ?? 0;
const sector = (f.sectorHint || '').toLowerCase();
if (roe >= 10) passed.push('ROE');
if (om >= 15) passed.push('성장성');
if (retention >= 1000) passed.push('유동성');
if (om >= 10) {
// 10% 이상이면 "수익성", 20% 이상이면 "수익성 개선" 표기.
passed.push(om >= 20 ? '수익성 개선' : '수익성');
}
if (om >= 15 && roe >= 8) passed.push('영업효율');
const techKeywords = ['ai', '반도체', '배터리', '바이오', '기술', 'semicon', 'battery', 'bio'];
if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력');
if (retention >= 3000 && mktCap >= 5000) passed.push('안정성');
if (pbr > 0 && pbr <= 1.5) passed.push('PBR');
return passed;
}
/** Fundamentals → Stock (stocks.json 호환 형식) 변환. */
function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock {
return {
이름: entry.name,
심볼: entry.symbol,
'유보율': f.retentionRatio !== undefined ? `${Math.round(f.retentionRatio).toLocaleString()}%` : undefined,
'ROE(25E)': f.roe !== undefined ? `${f.roe.toFixed(2)}%` : undefined,
'영업이익률(25E)': f.operatingMargin !== undefined ? `${f.operatingMargin.toFixed(1)}%` : undefined,
'PER(25E)': f.per !== undefined ? f.per.toFixed(1) : undefined,
PBR: f.pbr !== undefined ? f.pbr.toFixed(1) : undefined,
'시가총액': f.marketCapEok !== undefined ? `${Math.round(f.marketCapEok).toLocaleString()}` : undefined,
'3/4 필터': filterText,
현재가: f.currentPrice,
// 시가총액 기준 자동 분류 — 사용자가 나중에 수동으로 바꿀 수 있음.
투자성향: entry.marketCapEok < 3000 ? '저평가우량주' :
entry.marketCapEok < 10000 ? '스윙/중기' : '장기투자',
};
}
export async function discoverStocks(opts: DiscoverOptions = {}): Promise<DiscoveredCandidate[]> {
const minCap = opts.minMarketCapEok ?? 1000;
const maxCap = opts.maxMarketCapEok ?? 5000;
const markets = opts.markets ?? ['kospi', 'kosdaq'];
const maxPages = opts.maxPagesPerMarket ?? 10;
const limit = opts.limit ?? 20;
const progress = opts.onProgress ?? (() => {});
progress(`📡 Naver 시가총액 페이지 크롤 시작 — 시장 ${markets.join('+')}, 시총 ${minCap}-${maxCap}`);
// (1)+(2) 시가총액 페이지 → 1차 필터링.
const allEntries: ScreenerEntry[] = [];
for (const market of markets) {
progress(` · ${market} 스캔 중...`);
const entries = await screenMarket({
market,
maxPages,
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
onProgress: (page, total) => progress(` p${page} → 누적 ${total}`),
});
allEntries.push(...entries);
}
if (allEntries.length === 0) {
progress('⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
return [];
}
progress(`\n✅ 1차 후보 ${allEntries.length}개 — 펀더멘털 크롤 시작 (~${Math.ceil(allEntries.length * 0.5)}초 소요).`);
// (3) 개별 펀더멘털 크롤.
const symbols = allEntries.map(e => e.symbol);
const fundsMap = await fetchAllFundamentals(symbols, (sym, _f, i, total) => {
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
});
// (4) 8 키워드 평가.
const candidates: DiscoveredCandidate[] = [];
for (const entry of allEntries) {
const f = fundsMap.get(entry.symbol);
if (!f) continue;
const passed = evaluateKeywords(f);
if (passed.length < 3) continue;
// 상위 3개만 골라서 텍스트화 (judge 와 동일 패턴).
const top3 = passed.slice(0, 3);
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
candidates.push({
symbol: entry.symbol,
name: entry.name,
market: entry.market,
marketCapEok: entry.marketCapEok,
passedKeywords: passed,
asStock: fundamentalsToStock(entry, f, filterText),
fundamentals: f,
});
}
// sortScore — 통과 키워드 수 내림차순.
candidates.sort((a, b) => b.passedKeywords.length - a.passedKeywords.length);
const limited = candidates.slice(0, limit);
logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`);
progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개가 3 키워드 이상 통과).`);
return limited;
}
+96
View File
@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import type { Stock, StocksStore } from './types';
/**
* 워크스페이스 루트의 `.astra/stocks.json` 을 source of truth 로 사용.
*
* 결정 근거 (q1=A): 사용자가 워크스페이스 단위로 다른 종목 리스트를 가질 수 있게.
* 워크스페이스가 없으면 (= VS Code 가 폴더 열지 않고 시작) 빈 store 반환 — 이 경우
* watcher / slash 명령 모두 silent skip.
*
* Atomic write: tmp 파일에 쓰고 rename — 동시 read 또는 SIGKILL 중간에도 partial JSON
* 으로 안 깨지게.
*/
const STORE_REL_PATH = '.astra/stocks.json';
export function getStocksFilePath(): string | null {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return null;
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
}
/** 파일 없으면 빈 배열 반환. 파일 파싱 실패해도 빈 배열 + 에러 로그. */
export function readStocksStore(): StocksStore {
const filePath = getStocksFilePath();
if (!filePath || !fs.existsSync(filePath)) return [];
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
logError('stocks.json 가 배열이 아닙니다 — 빈 store 반환.', { filePath });
return [];
}
return parsed as StocksStore;
} catch (e: any) {
logError('stocks.json 읽기 실패.', { filePath, error: e?.message ?? String(e) });
return [];
}
}
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */
export function writeStocksStore(store: StocksStore): boolean {
const filePath = getStocksFilePath();
if (!filePath) {
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
return false;
}
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
fs.renameSync(tmp, filePath);
return true;
} catch (e: any) {
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
return false;
}
}
/** 한 종목 추가. 같은 심볼이 이미 있으면 false (caller 가 안내). */
export function addStock(stock: Stock): { ok: boolean; reason?: string } {
const store = readStocksStore();
if (store.some(s => s. === stock.)) {
return { ok: false, reason: `심볼 ${stock.} 이미 존재` };
}
store.push(stock);
const wrote = writeStocksStore(store);
if (!wrote) return { ok: false, reason: '쓰기 실패 (워크스페이스 없음 또는 권한)' };
logInfo('Stocks: 종목 추가.', { symbol: stock., name: stock.이름 });
return { ok: true };
}
/** 한 종목 제거. 못 찾으면 false. */
export function removeStock(symbol: string): { ok: boolean; reason?: string } {
const store = readStocksStore();
const idx = store.findIndex(s => s. === symbol);
if (idx < 0) return { ok: false, reason: `심볼 ${symbol} 못 찾음` };
const removed = store.splice(idx, 1)[0];
const wrote = writeStocksStore(store);
if (!wrote) return { ok: false, reason: '쓰기 실패' };
logInfo('Stocks: 종목 제거.', { symbol, name: removed.이름 });
return { ok: true };
}
/** 한 종목의 필드 patch — 현재가 갱신 / 필터 업데이트 등. */
export function updateStock(symbol: string, patch: Partial<Stock>): boolean {
const store = readStocksStore();
const idx = store.findIndex(s => s. === symbol);
if (idx < 0) return false;
store[idx] = { ...store[idx], ...patch };
return writeStocksStore(store);
}
+163
View File
@@ -0,0 +1,163 @@
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { readStocksStore, writeStocksStore } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
import { sendStocksReport } from './telegramReport';
import { syncToSheets } from './sheetsSync';
/**
* VS Code 시작 시 자동 활성화 — KST 09:00 / 15:00 에:
* 1. Yahoo 로 현재가 일괄 갱신 → stocks.json 업데이트
* 2. (선택) Google Sheets 동기화 — g1nation.stocks.spreadsheetId 설정 시
* 3. 텔레그램 보고서 발송
*
* 구현 노트:
* - setTimeout 단일 chain — 매 firing 후 다음 알람까지 재계산해서 새 setTimeout.
* - VS Code 종료 시 disposable 로 clear.
* - 시간대는 Asia/Seoul 강제 — 사용자 macOS timezone 과 무관하게 같은 시각에 동작.
* - run_kodari_sync.command 의 sleep-loop 구조를 단일 setTimeout 으로 대체.
*
* 트리거 시각 변경 시 SCHEDULE 만 수정 — 분 단위.
*/
const SCHEDULE_HOURS_KST = [9, 15]; // 09:00, 15:00 KST
let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
const now = new Date();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(now);
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
return {
date: now,
hour: get('hour'),
minute: get('minute'),
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
};
}
/**
* 지금부터 다음 firing 까지 milliseconds 계산.
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
*
* 시간대 변환: 사용자 OS 가 KST 가 아닐 수 있으므로 KST 기준 hour/minute 으로 비교 후
* 그 차이를 ms 로 변환 (변환은 단순 산수 — 분 차이 × 60000).
*/
function msUntilNextRun(): number {
const kst = nowInKst();
const todayMinutes = kst.hour * 60 + kst.minute;
let bestTodayMinutes: number | null = null;
for (const h of SCHEDULE_HOURS_KST) {
const targetMinutes = h * 60;
if (targetMinutes > todayMinutes) {
bestTodayMinutes = targetMinutes;
break;
}
}
if (bestTodayMinutes !== null) {
return (bestTodayMinutes - todayMinutes) * 60_000;
}
// 오늘 더 없음 → 내일 첫 시각.
const tomorrowFirstMinutes = SCHEDULE_HOURS_KST[0] * 60;
const remainingTodayMinutes = 24 * 60 - todayMinutes;
return (remainingTodayMinutes + tomorrowFirstMinutes) * 60_000;
}
/**
* 한 번 fire — 가격 갱신 + (선택) Sheets sync + 텔레그램 보고서.
* 각 단계는 독립적으로 try/catch — 한 단계 실패해도 다른 단계는 계속.
*/
async function fireOnce(context: vscode.ExtensionContext): Promise<void> {
const kst = nowInKst();
logInfo('Stocks watcher fire 시작.', { kst: `${kst.hour}:${kst.minute}` });
// (1) Yahoo 가격 갱신
try {
const store = readStocksStore();
const symbols = store.map(s => s.).filter(Boolean);
if (symbols.length === 0) {
logInfo('Stocks watcher: 종목 없음 — skip.');
} else {
const prices = await fetchAllPrices(symbols);
for (const s of store) {
const p = prices.get(s.);
if (typeof p === 'number') s. = p;
}
writeStocksStore(store);
}
} catch (e: any) {
logError('Stocks watcher: 가격 갱신 실패.', { error: e?.message ?? String(e) });
}
// (2) Sheets sync — 선택 (spreadsheetId 설정 시에만)
try {
const cfg = vscode.workspace.getConfiguration('g1nation');
if ((cfg.get<string>('stocks.spreadsheetId') || '').trim()) {
const r = await syncToSheets(context);
if (!r.ok) logError('Stocks watcher: Sheets 동기화 실패.', { errors: r.errors });
}
} catch (e: any) {
logError('Stocks watcher: Sheets 호출 실패.', { error: e?.message ?? String(e) });
}
// (3) Telegram 보고서
try {
const r = await sendStocksReport(context);
if (!r.ok) logInfo(`Stocks watcher: 보고서 skip — ${r.reason}`);
} catch (e: any) {
logError('Stocks watcher: 보고서 호출 실패.', { error: e?.message ?? String(e) });
}
}
function scheduleNext(context: vscode.ExtensionContext): void {
if (_disposed) return;
const ms = msUntilNextRun();
const hours = Math.floor(ms / 3600_000);
const minutes = Math.floor((ms % 3600_000) / 60_000);
logInfo(`Stocks watcher: 다음 firing 까지 ${hours}h ${minutes}m.`);
_timer = setTimeout(async () => {
try {
await fireOnce(context);
} catch (e: any) {
logError('Stocks watcher: fireOnce 예외.', { error: e?.message ?? String(e) });
}
scheduleNext(context);
}, ms);
}
/**
* VS Code 시작 시 호출 (extension.ts activate 의 마지막 단계).
* 설정 `g1nation.stocks.watcherEnabled` 가 false 이면 활성화 skip.
*/
export function startStocksWatcher(context: vscode.ExtensionContext): vscode.Disposable {
const cfg = vscode.workspace.getConfiguration('g1nation');
const enabled = cfg.get<boolean>('stocks.watcherEnabled', true);
if (!enabled) {
logInfo('Stocks watcher: 비활성 (g1nation.stocks.watcherEnabled=false).');
return { dispose: () => {} };
}
_disposed = false;
scheduleNext(context);
logInfo('Stocks watcher: 시작됨.', { schedule: SCHEDULE_HOURS_KST });
return {
dispose: () => {
_disposed = true;
if (_timer) { clearTimeout(_timer); _timer = undefined; }
logInfo('Stocks watcher: dispose.');
},
};
}
/** 명령으로 즉시 한 번 트리거 (`/stocks watch run` 같은 미래 명령 또는 디버깅). */
export async function runOnceNow(context: vscode.ExtensionContext): Promise<void> {
await fireOnce(context);
}
+117
View File
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { logError, logInfo } from '../../utils';
import { readStocksStore } from './stocksStore';
import { classifyAll } from './signalClassifier';
import type { ClassifiedStock } from './types';
/**
* 09:00 / 15:00 KST 정기 보고서 — Telegram 으로 발송.
*
* 사용자 결정 (q2=A): chatId 는 `g1nation.telegram.allowedChatIds[0]` 자동 사용.
* 없으면 fallback 으로 `g1nation.stocks.telegramChatId` 별도 설정. 둘 다 없으면 skip + 로그.
* 토큰은 telegramCommands 의 SecretStorage 키와 공유.
*/
function pickChatId(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0) return allowed[0];
const dedicated = cfg.get<number>('stocks.telegramChatId', 0);
if (dedicated && dedicated !== 0) return dedicated;
return null;
}
/** 한 카테고리 (스윙/장기/저평가) 의 종목 리스트를 텔레그램 Markdown 으로 렌더. */
function renderGroup(label: string, stocks: ClassifiedStock[]): string[] {
const buyZone = stocks.filter(s => s.signal === 'BUY_ZONE');
const overvalued = stocks.filter(s => s.signal === 'OVERVALUED');
const hold = stocks.filter(s => s.signal === 'HOLD');
const lines: string[] = [`*${label}* (총 ${stocks.length}개)`];
if (buyZone.length > 0) {
lines.push(`🚨 매수사정권 (${buyZone.length})`);
for (const s of buyZone) {
const cur = s. ? s..toLocaleString() : '?';
lines.push(` · ${s.} (${s.}): ${cur} / 권장 ${s. ?? '-'}`);
}
}
if (overvalued.length > 0) {
lines.push(`⚠️ 가격 조정 감시 (${overvalued.length})`);
for (const s of overvalued) {
const cur = s. ? s..toLocaleString() : '?';
lines.push(` · ${s.} (${s.}): ${cur} / 권장 ${s. ?? '-'}`);
}
}
if (hold.length > 0 && buyZone.length === 0 && overvalued.length === 0) {
// 모두 관망일 때만 그 카운트만 표시 — 종목 일일이 안 나열 (보고서 길이 절약).
lines.push(`📊 관망 ${hold.length}`);
} else if (hold.length > 0) {
lines.push(`📊 관망 ${hold.length}건 (생략)`);
}
return lines;
}
/** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). */
export function buildReportText(now: Date = new Date()): string {
const store = readStocksStore();
if (store.length === 0) {
return '⚠️ stocks.json 에 종목 없음 — `/stocks add <심볼> <이름>` 으로 추가하세요.';
}
const g = classifyAll(store);
const kstStr = formatKstTimestamp(now);
const out: string[] = [`🦅 *Kodari 정기 보고서* _${kstStr}_`, ''];
out.push(...renderGroup('스윙/중기', g.swing), '');
out.push(...renderGroup('장기투자', g.long), '');
out.push(...renderGroup('저평가우량주', g.ultraLow));
return out.join('\n');
}
/** Date → "2026-05-25 09:00 KST" 형식. */
function formatKstTimestamp(d: Date): string {
const fmt = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
});
const parts = fmt.formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
}
/**
* 보고서를 텔레그램으로 전송. 토큰 없거나 chatId 없으면 silent skip (warn 로그).
*
* 성공/실패 모두 caller (watcher / slash 명령) 가 알 수 있도록 결과 반환.
*/
export async function sendStocksReport(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; reason?: string }> {
const chatId = pickChatId();
if (chatId === null) {
const reason = '텔레그램 chatId 미설정 (g1nation.telegram.allowedChatIds 또는 g1nation.stocks.telegramChatId).';
logInfo(`Stocks report skip: ${reason}`);
return { ok: false, reason };
}
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
const reason = '텔레그램 봇 토큰 없음 — `Astra: Set Telegram Bot Token` 으로 등록.';
logInfo(`Stocks report skip: ${reason}`);
return { ok: false, reason };
}
const text = buildReportText();
const client = new TelegramHttpClient({ getToken: () => token });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Stocks report 발송 완료.', { chatId, chars: text.length });
return { ok: true };
} catch (e: any) {
logError('Stocks report 발송 실패.', { chatId, error: e?.message ?? String(e) });
return { ok: false, reason: e?.message ?? String(e) };
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Stocks 모듈 공유 타입.
*
* invest_results/target_stocks.json 스키마를 그대로 받아서, ConnectAI 의
* `<workspace>/.astra/stocks.json` 으로 옮긴 뒤 같은 필드명을 유지.
* 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌
* 회피 + 사용자가 직접 JSON 편집할 때 friction 최소화.
*/
/** target_stocks.json 의 한 종목 항목. */
export interface Stock {
이름: string;
심볼: string;
/** ISO date — 보통 'YYYY-MM-DD'. */
상장일?: string;
유보율?: string;
'ROE(25E)'?: string;
'영업이익률(25E)'?: string;
'EPS(25E)'?: string;
'PER(25E)'?: string;
PBR?: string;
시가총액?: string;
/** 사람이 계산한 적정주가 (텍스트). */
적정주가?: string;
/** 매수 추천 임계가. signalClassifier 가 현재가와 비교. */
매수권장가?: string;
설립일?: string;
/** 핵심 사업 한 줄. */
'최대 먹거리'?: string;
특이사항?: string;
/** "충족 (ROE, 성장성, 유동성)" 같은 텍스트. signalClassifier 는 `.includes("충족")` 로만 매칭. */
'3/4 필터'?: string;
/** Yahoo Finance 최근 fetch 한 현재가 (정수). 0 또는 누락이면 미수집. */
현재가?: number;
/** 분류 시트 — '스윙/중기' / '장기투자' / '저평가우량주' 중 하나. */
?: '스윙/중기' | '장기투자' | '저평가우량주';
}
export type StocksStore = Stock[];
/** 신호 분류 결과 — UI / 텔레그램 / 시트 동기화 모두 이 모양을 공유. */
export type Signal = 'BUY_ZONE' | 'OVERVALUED' | 'HOLD';
export interface ClassifiedStock extends Stock {
/** 정량 + 정성 필터 결합 결과. */
signal: Signal;
/** 텔레그램·시트 정렬용. 2 = 매수사정권, 1 = 가격 조정 감시, 0 = 관망. */
sortScore: 0 | 1 | 2;
/** `.includes("충족")` 결과 — 정성 필터 통과 여부. */
filterPass: boolean;
/** 사용자에게 표시할 한글 신호 문구. */
signalText: string;
}
+60
View File
@@ -0,0 +1,60 @@
import { logError, logInfo } from '../../utils';
/**
* Yahoo Finance public chart endpoint 로 현재가 fetch. invest_results/quick_check.js
* 의 동일 로직 — symbol 에 suffix 없으면 `.KQ` (코스닥) 우선, 실패 시 `.KS` (코스피) 재시도.
*
* Yahoo 가 한국 종목은 `<6자리>.KQ` 또는 `<6자리>.KS` 형식. US 종목은 그대로.
* symbol 에 이미 `.` 있으면 그대로 사용.
*
* Returns null 이면 두 suffix 다 실패 — 호출자가 skip 처리.
*/
export async function fetchYahooPrice(symbol: string, timeoutMs = 8000): Promise<number | null> {
if (!symbol) return null;
const candidates: string[] = symbol.includes('.')
? [symbol]
: [`${symbol}.KQ`, `${symbol}.KS`];
for (const yahooSymbol of candidates) {
try {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${yahooSymbol}`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) continue;
const data: any = await res.json();
const price = data?.chart?.result?.[0]?.meta?.regularMarketPrice;
if (typeof price === 'number' && Number.isFinite(price)) {
return price;
}
} catch (e: any) {
// suffix 후보가 더 남았으면 계속 시도, 마지막이면 null fallthrough.
if (yahooSymbol === candidates[candidates.length - 1]) {
logError('Yahoo Finance 현재가 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
}
}
}
return null;
}
/**
* 종목 리스트 전체 순회하면서 fetchYahooPrice — 1초 간격으로 throttle (Yahoo rate limit).
* partial 갱신 허용: 실패해도 다른 종목은 계속 진행, 결과 Map 반환.
*
* caller 가 결과를 store 에 일괄 patch.
*/
export async function fetchAllPrices(
symbols: string[],
onProgress?: (symbol: string, price: number | null) => void,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
for (const symbol of symbols) {
const price = await fetchYahooPrice(symbol);
out.set(symbol, price);
onProgress?.(symbol, price);
await new Promise(r => setTimeout(r, 1000));
}
logInfo('Yahoo 일괄 갱신 완료.', { total: symbols.length, success: [...out.values()].filter(p => p !== null).length });
return out;
}