feat: v2.2.162-168 — /stocks analysis 6차원 확장 + position + /youtube info 재설계

v2.2.162-163: 신규 /stocks analysis <심볼> (펀더멘털 + 1년 차트 + LLM 종합).
  - 6차원: 가치/수익성/안정성(부채비율)/추세(MA 정배열+224회복)/안전마진/RSI 진입 타이밍
  - 신규 /stocks position [심볼] <총자산> <리스크%> <손절%> — 포지션 사이징 계산기

v2.2.164-165: /youtube info 3-tier 재설계 (사용자 피드백: 중복·이모지·표 깨짐).
  - 9개 섹션 → 4개 ## 섹션 (30초 요약 / 핵심 개념 / 깊이 분석 / 정리자 노트)
  - 헤더 이모지 전면 제거, 표 → bullet, 한 줄 요약 중복 제거

v2.2.166: /stocks analysis 매매 타점 신규 섹션 (사용자 매매 규칙 raw 데이터 적응).
  - 매수 진입(3순위 시나리오) / 손절 / 익절 / 관망 해제 트리거
  - LLM이 실제 가격(MA값, 1년 고가, 60일 저점) 자동 채움

v2.2.167: /stocks analysis 분석 로직 정밀화 (사용자 피드백 5건).
  - MA224 3-state (passed/failed/notApplicable) — 추세 확립 종목  오해 차단
  - 낙폭과대 failReason 명시 — 인과 거꾸로 해석 차단
  - 우선주(끝자리 5/7/9) 자동 감지 → 보통주 현재가 fetch → 할인율 계산
  - 프롬프트 판단 절제 규칙 4건 (PBR 절대값 단정/거래량 미세변동/우선주 특이/오탈자)

v2.2.168: 재패키징 (별개 Datacollect bridge 수정과 함께 깨끗한 설치본).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:59:34 +09:00
parent d206293a19
commit 2174504b59
14 changed files with 655 additions and 71 deletions
@@ -1,5 +1,5 @@
{ {
"result": "직답 결과 — single-pass mock 응답입니다.", "result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1779770051307, "createdAt": 1779860682873,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "---\nid: wiki_on\ndate: 2026-05-26T04:34:11.309Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (24ms)\n", "result": "---\nid: wiki_on\ndate: 2026-05-27T05:44:42.874Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (20ms)\n",
"createdAt": 1779770051309, "createdAt": 1779860682875,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "wiki_on", "missionId": "wiki_on",
"status": "completed", "status": "completed",
"startTime": "2026-05-26T04:34:11.282Z", "startTime": "2026-05-27T05:44:42.851Z",
"totalElapsedMs": 28, "totalElapsedMs": 25,
"results": { "results": {
"direct": "직답 결과 — single-pass mock 응답입니다." "direct": "직답 결과 — single-pass mock 응답입니다."
}, },
@@ -12,16 +12,16 @@
{ {
"from": "idle", "from": "idle",
"to": "direct", "to": "direct",
"durationMs": 24, "durationMs": 20,
"message": "답변 작성 중... (단일 호출 fast-path)", "message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-05-26T04:34:11.306Z" "ts": "2026-05-27T05:44:42.871Z"
}, },
{ {
"from": "direct", "from": "direct",
"to": "completed", "to": "completed",
"durationMs": 4, "durationMs": 5,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-05-26T04:34:11.310Z" "ts": "2026-05-27T05:44:42.876Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779770057843, "createdAt": 1779860690104,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779770057842, "createdAt": 1779860690103,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1779770057838, "createdAt": 1779860690099,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1779770057840, "createdAt": 1779860690101,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "stress_conflict_1779770057821", "missionId": "stress_conflict_1779860690078",
"status": "completed", "status": "completed",
"startTime": "2026-05-26T04:34:17.821Z", "startTime": "2026-05-27T05:44:50.078Z",
"totalElapsedMs": 23, "totalElapsedMs": 27,
"results": { "results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -14,30 +14,30 @@
{ {
"from": "idle", "from": "idle",
"to": "outline", "to": "outline",
"durationMs": 16, "durationMs": 20,
"message": "답변 구조 잡는 중...", "message": "답변 구조 잡는 중...",
"ts": "2026-05-26T04:34:17.837Z" "ts": "2026-05-27T05:44:50.098Z"
}, },
{ {
"from": "outline", "from": "outline",
"to": "section", "to": "section",
"durationMs": 2, "durationMs": 2,
"message": "본문 작성 중...", "message": "본문 작성 중...",
"ts": "2026-05-26T04:34:17.839Z" "ts": "2026-05-27T05:44:50.100Z"
}, },
{ {
"from": "section", "from": "section",
"to": "polish", "to": "polish",
"durationMs": 2, "durationMs": 2,
"message": "최종 다듬기 중...", "message": "최종 다듬기 중...",
"ts": "2026-05-26T04:34:17.841Z" "ts": "2026-05-27T05:44:50.102Z"
}, },
{ {
"from": "polish", "from": "polish",
"to": "completed", "to": "completed",
"durationMs": 2, "durationMs": 2,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-05-26T04:34:17.843Z" "ts": "2026-05-27T05:44:50.104Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+116
View File
@@ -1,5 +1,121 @@
# Astra Patch Notes # Astra Patch Notes
## v2.2.168 (2026-05-27)
### 📦 재패키징 (소스 변경 없음)
- ASTRA 소스 변경 없음. 별개로 적용한 **Datacollect Bridge `/api/lm` 복원력 수정**(LM 서버가 raw text 응답해도 OpenAI shape 으로 wrap → ASTRA `/youtube info` 등이 깨진 에러 벽 대신 정상 결과를 받음)에 맞춰 깨끗한 설치본 제공.
- **신규 패키징:** `astra-2.2.168.vsix`.
---
## v2.2.167 (2026-05-27)
### 🔬 /stocks analysis — 분석 로직 정밀화 (사용자 피드백 5건 반영)
**로직 수정 (yahooClient.ts):**
- **MA224 3-state 도입** — passed / failed / **`notApplicable`** (N/A). 현재가가 MA224 보다 +20% 이상 위면 추세 이미 확립 상태로 보고 N/A 처리. ❌ 미통과로 표기하던 오해 차단(예: 삼성전자우 +93% 위에 있는데도 ❌ 표시되던 문제).
- **낙폭과대 `failReason` 명시** — 두 조건(고점 -25% / 저점에서 +10%) 중 어느 게 어긋났는지 구체 사유 반환. LLM 이 인과 거꾸로 해석("64% 반등이라서 미통과") 하던 문제 차단.
**데이터 보강 (slashStocks.ts):**
- **우선주 자동 감지** (symbol 끝자리 5/7/9 → 보통주 자동 도출, 예: 005935→005930). 보통주 현재가 자동 fetch 후 **할인율** 계산. 할인 범위 가이드 동봉 (10~30% 정상, <10% 프리미엄, ≥30% 확대).
**프롬프트 강화 (ANALYSIS_SYSTEM_PROMPT):**
- **판단 절제 규칙 4건 신설**:
- (a) PBR/PER 절대값으로 "고평가/저평가" 단정 금지 — 업종 평균 부재 시 절대값만 제시
- (b) 거래량 ±20% 미만은 무의미 — "상승 동력 약화" 같은 강한 해석 금지(예: -9% 변동이 부정 신호로 잡히던 문제)
- (c) 우선주 특이항목 반영 필수 — `[우선주 정보]` 블록 있으면 할인율을 매수 의견 근거에 반영
- (d) 오탈자 자기 점검 — "순위→순칭", "근거→근례" 같은 한국어 토큰 깨짐 방지
- MA224 N/A 상태 / 낙폭과대 failReason 을 LLM 이 인지하도록 6차원 평가 가이드 갱신.
**보류** (별도 작업 필요): 배당수익률 fetch (Naver crawl 확장), 섹터 자동 보강.
**신규 패키징:** `astra-2.2.167.vsix`.
---
## v2.2.166 (2026-05-27)
### 💹 /stocks analysis — 매매 타점(진입·손절·익절·관망 해제) 신규 섹션
- **신규 `## 매매 타점` 섹션** ([ANALYSIS_SYSTEM_PROMPT](src/features/stocks/slashStocks.ts)) — 매수 의견 바로 아래, 리스크 위.
- **4개 sub-section** (사용자 제공 매매 규칙을 raw 데이터 기반으로 적응):
1. **매수 진입 타점** — 3순위 시나리오(MA20 지지 / MA60 눌림 / 낙폭과대+RSI≤30) 중 현재 기술 상태에 부합하는 것만 출력. 회피 권장도면 sub-section 생략.
2. **손절 기준** — 종가 기준 이탈 (장중 터치 미적용). 매수 시나리오별 (MA20/MA60/60일저점) 가격 + 최대 허용 손실%.
3. **익절 타점** — 1차(1년 최고가×0.98) → 2차(라운드 넘버) → 3차(업종 PER 비교, 데이터 미수집 명시).
4. **관망 해제 트리거** — 권장도가 "관망"일 때만 출력. 4개 트리거 중 2개+ 충족 시 진입.
- **LLM이 raw 데이터로 실제 가격 채우도록** — `__원` placeholder 금지, 실제 MA값·1년 고가·60일 저점으로 구체화. 권장도/기술 상태에 따라 적응적으로 출력(부적합 시나리오는 생략).
- **신규 패키징:** `astra-2.2.166.vsix`.
---
## v2.2.165 (2026-05-27)
### 📋 /youtube info — 3-tier 재설계 (중복 제거 + 깊이 단순화 + 표 제거)
- **앞 턴(2.2.164)의 "대시보드 위에 추가" 방식이 중복을 만들었다는 사용자 피드백 수용.** 9개 섹션 → **4개 ## 섹션 3-tier 구조**로 재설계.
- **새 구조** ([youtubePrompts.ts:buildInfoExtractionPrompt](src/features/datacollect/prompts/youtubePrompts.ts)):
1. **30초 요약** (skim) — 한 줄 + 핵심 포인트 3개 + 화자 한 줄 비유. 바쁜 사람은 여기서 멈춤.
2. **핵심 개념 설명** (이해) — 영상이 사용한 주요 개념·용어 2-5개 정의 + 등장 맥락.
3. **깊이 분석** (deep dive) — 타임라인 / 결정적 발언 / 더 파고들 질문 / 구체 수치.
4. **정리자 노트** (선택) — `[정리자 추론]` 라벨 전용.
- **중복 제거**: "한 줄 요약"은 30초 요약에 한 번만. 기존엔 대시보드·TL;DR·핵심 주장에 분산.
- **표 → bullet**: "사실·데이터" 표가 깨진 셀(`way` 같은 LLM artifact)을 노출하던 문제 → 표 금지, bullet로만, 완성된 항목만.
- **헤더 이모지 제거**: 스캔할 때 시선이 분산되어 내용을 가린다는 피드백 수용. 모든 ## · ### 헤더에서 이모지 제거.
- **신규 시스템 원칙 3건 추가**: 헤더 이모지 금지 / 표 사용 신중 / 중복 금지.
- **신규 패키징:** `astra-2.2.165.vsix`.
---
## v2.2.164 (2026-05-27)
### 📋 /youtube info — 상단 "구조화된 요약" 대시보드 추가
- **신규 `## 📋 구조화된 요약` 섹션**을 메타데이터 바로 아래에 추가 ([youtubePrompts.ts:buildInfoExtractionPrompt](src/features/datacollect/prompts/youtubePrompts.ts)).
- 하위 3개 sub-section:
- **🎯 핵심 3요소**: 주제 / 핵심 주장 / 근거·사례
- **⏱️ 타임라인 기반 요약**: 30분 이상 영상은 4-7개 구간 압축, 미만은 생략(`🧭 구조 요약` 참조)
- **📝 한 줄 요약 + 기억할 포인트 3~5개**
- 기존 8개 섹션(`🎯 한 줄 요약`·`💡 화자 한 줄 비유`·`📌 핵심 주장`·`📊 사실·데이터·인용`·`🧭 구조 요약`·`🔗 인용용 카드`·`❓ 더 파고들 질문`·`🧩 정리자 노트`)은 그대로 유지 — 대시보드는 *압축 버전*, 아래 섹션들은 *깊이 버전*.
- 약간의 redundancy(한 줄 요약 두 번 등장)는 의도 — 대시보드에서 빠르게 잡고, 필요하면 깊이 섹션에서 풀어 읽는 dual-layer.
- **신규 패키징:** `astra-2.2.164.vsix`.
---
## v2.2.163 (2026-05-27)
### 📊 /stocks analysis 6차원 확장 + 신규 /stocks position
- **`/stocks analysis` 6차원으로 확장** — 가이드(주식 분석 6단계) 흡수:
- 부채비율 추가 (재무 안정성 차원 강화 — 200% 이하 안전, 100% 이하 우량)
- **MA 정배열/역배열** (5/20/60/120일 이평선 배열 판정 — 추세 차원에 추가)
- **RSI(14)** (Wilder's smoothing, 과열≥70 / 침체≤30 / 중립 — 진입 타이밍 신규 차원)
- LLM 시스템 프롬프트도 6차원 + 각 지표 해석 가이드로 갱신
- 추가 API 호출 없음 — 이미 fetch한 1년 시세에서 모두 계산
- **신규 `/stocks position [심볼] <총자산> <리스크%> <손절%>`** — 포지션 사이징 계산기:
- 공식: `권장 투자금 = 총자산 × 리스크% ÷ 손절%` (가이드 5단계)
- 심볼 주면 Yahoo 현재가로 **매수 가능 주수**까지 자동 계산
- 권장 투자금이 총자산의 50% 초과 시 입력값 재검토 경고
- 별칭: `/stocks size`
- **보류한 가이드 항목** (현실적 한계): PEG(이익 성장률 데이터 부재), 업종 평균 PER / 5년 역사적 PER(별도 데이터소스 필요), 사업 해자(LLM 환각 위험), MACD(우선순위 낮음).
- 새 helper ([yahooClient.ts](src/features/stocks/yahooClient.ts)): `evalMaAlignment`, `evalRsi14`.
- **신규 패키징:** `astra-2.2.163.vsix`.
---
## v2.2.162 (2026-05-27)
### 🔎 /stocks analysis &lt;심볼&gt; — 단일 종목 심층 분석
- **신규 sub-command** ([slashStocks.ts](src/features/stocks/slashStocks.ts)): 한 종목에 대해 Naver 펀더멘털 fresh fetch + Yahoo 1년 시세 기반 기술 지표(224일선 회복, 낙폭과대) + LLM 종합 평가까지 한번에. `stocks.json` 미등록 종목도 가능.
- **차이점** vs 기존 `/stocks judge`: judge는 stocks.json 저장된 펀더멘털만 사용한 4-criteria 평가. analysis는 fresh fetch + 차트 패턴 + LLM 매수권/관망/회피 권장도.
- **출력 형식 (LLM 강제):** `## 종합 평가` 2-3문장 / `## 매수 의견` 권장도 + 근거(수치 인용) / `## 리스크` 1-2줄.
- **데이터 투명성:** LLM에 보내는 raw 데이터 요약을 사용자에게도 코드블록으로 먼저 표시. 모델 응답과 비교 검증 가능.
- 별칭: `/stocks analyze <심볼>` 도 동일하게 동작.
- **신규 패키징:** `astra-2.2.162.vsix`.
---
## v2.2.161 (2026-05-22) ## v2.2.161 (2026-05-22)
### 📈 /stocks discover — 낙폭과대 키워드 + 224회복 거래량 확인 ### 📈 /stocks discover — 낙폭과대 키워드 + 224회복 거래량 확인
- **신규 `낙폭과대` 키워드** ([evalDropRecovery](src/features/stocks/yahooClient.ts)): 영상(주식단테 "이미 빠진 종목 + 바닥 찍고 회복")의 정량화. 현재가 ≤ 1년 최고가 × 0.75(25%↓) AND 현재가 ≥ 60일 최저가 × 1.10(저점에서 10%↑) → `낙폭과대` +1. 224회복(추세 전환)과는 다른 각도 — 안전마진 + 반등 초입. - **신규 `낙폭과대` 키워드** ([evalDropRecovery](src/features/stocks/yahooClient.ts)): 영상(주식단테 "이미 빠진 종목 + 바닥 찍고 회복")의 정량화. 현재가 ≤ 1년 최고가 × 0.75(25%↓) AND 현재가 ≥ 60일 최저가 × 1.10(저점에서 10%↑) → `낙폭과대` +1. 224회복(추세 전환)과는 다른 각도 — 안전마진 + 반등 초입.
+1 -1
View File
@@ -6,7 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "astra", "name": "astra",
"version": "2.2.161", "version": "2.2.168",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lmstudio/sdk": "^1.5.0", "@lmstudio/sdk": "^1.5.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "Astra", "displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.161", "version": "2.2.168",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -127,7 +127,9 @@ export function buildInfoExtractionPrompt(video: any, userContent: string): stri
대응 관계도 마찬가지 — 본문 그대로. 대응 관계도 마찬가지 — 본문 그대로.
6. **순서·단계 발명 금지** — 화자가 "A → B → C 순서로" 라고 명시하지 *않았으면* "단계적 6. **순서·단계 발명 금지** — 화자가 "A → B → C 순서로" 라고 명시하지 *않았으면* "단계적
학습 순서" 같은 흐름을 정리자가 만들지 말 것. 굳이 필요하면 정리자 노트로. 학습 순서" 같은 흐름을 정리자가 만들지 말 것. 굳이 필요하면 정리자 노트로.
7. 한국어 마크다운. 표·불릿 자유롭게. 7. 한국어 마크다운. **헤더에 이모지 금지** — 스캔할 때 시선이 분산되어 내용이 가려진다.
8. **표 사용 신중** — 모든 셀이 명확한 값으로 채워질 확신이 없으면 표 만들지 말고 bullet로. 깨진 셀(\`way\` 같은 LLM artifact)·빈 셀·"…" placeholder 절대 노출 금지.
9. **중복 금지** — 같은 내용은 한 곳에만. "한 줄 요약"이 여러 섹션에 반복되면 안 됨. 30초 요약에 한 번, 끝.
[영상 메타데이터] [영상 메타데이터]
\`\`\`json \`\`\`json
@@ -137,66 +139,80 @@ ${JSON.stringify(slim, null, 2)}
[자막 본문] [자막 본문]
${trimmed}${userBlock} ${trimmed}${userBlock}
[필수 출력 형식 — 정확히 이 구조. 아래 8개 섹션 외 추가 금지] [필수 출력 형식 — 정확히 이 구조. 아래 4개 ## 섹션 외 추가 금지. 3-tier 깊이: ① 30초 요약(skim) → ② 핵심 개념(이해) → ③ 깊이 분석(deep dive). 읽는 사람이 목적에 따라 멈출 수 있게.]
# ${slim.title || video.title} — 정보 추출 카드 # ${slim.title || video.title} — 정보 추출 카드
> **영상 URL**: ${slim.url} · **분석 일자**: ${today} · **길이**: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · **채널**: ${slim.channel || '?'} > **영상 URL**: ${slim.url} · **분석 일자**: ${today} · **길이**: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · **채널**: ${slim.channel || '?'}
## 🎯 한 줄 요약 (TL;DR) > 신뢰도 라벨: \`[근거 명시]\` 본문 출처/수치 · \`[화자 주장]\` 출처 없는 단정 · \`[가정]\` 조건부 · \`[정리자 추론]\` 정리자 노트 전용
(영상의 핵심 메시지 한 문장. "무엇이 누구에게 왜 중요한가" 를 압축. 제목 그대로 베끼지
말고 본문 기준으로 다시 쓸 것. 정리자의 해석은 금지 — 화자의 말 그대로 압축)
## 💡 화자 한 줄 비유 (Anchor Metaphor) ---
영상에서 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 가 있으면 그대로 따옴표로
보존. 영상 마무리부에 자주 등장. 예: "Hugging Face = 자료실, Reddit = 공부방,
유튜브 = 복습실" 같은 식. 없으면 "본문에 명시된 한 줄 비유 없음".
## 📌 핵심 주장 3~5개 ## 30초 요약
영상이 *명시한* 주요 결론·주장만. 정리자 추론은 여기 들어오면 안 됨 (그건 🧩 섹션).
각 항목 한 줄 + 신뢰도 라벨 + 본문 인용 (mm:ss).
- **[근거 명시]** "주장 한 줄" — 본문 인용 (mm:ss)
- **[화자 주장]** "주장 한 줄" — 본문 인용 (mm:ss)
- …
## 📊 사실·데이터·인용 *바쁜 사람은 여기서 멈춰도 영상을 안 본 것보다 낫게.*
영상에 등장한 *구체적 수치·날짜·출처·고유명사·전문 용어 정의*. 가공 없이 그대로.
표로 정리:
| 항목 | 값 / 정의 | 출처 (영상 내) | 타임스탬프 | - **한 줄**: 영상 전체를 한 문장으로 — "무엇이 누구에게 왜 중요한가". 화자 표현 기준, 정리자 의역 금지.
| --- | --- | --- | --- | - **핵심 포인트 3개**: 영상이 *명시한* 결론·주장만 (정리자 추론 금지). 각 항목 신뢰도 라벨 + 한 줄 + 타임스탬프.
| … | … | 화자/자료 화면/외부 출처 | mm:ss | 1. **[근거 명시]** ... — (mm:ss)
2. **[화자 주장]** ... — (mm:ss)
3. ... — (mm:ss)
- **화자 한 줄 비유** (있는 경우만): 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 그대로 따옴표.
⚠️ 방향 보존 필수 — "Hugging Face = 자료실, Reddit = 공부방" 같으면 짝과 순서를 본문 그대로. 정리자가 단어 위치를 바꾸거나 의역하면 안 됨. 고유명사·수치·대응관계도 본문 그대로.
예: "..." — (mm:ss). 본문에 비유 없으면 이 bullet 통째로 생략(line drop).
데이터가 없는 영상이면 "본문에 명시된 구체 수치·출처 없음" 한 줄. ---
## 🧭 구조 요약 (Sectioned Summary) ## 핵심 개념 설명
영상을 chapters (메타데이터에 있으면 그것 사용) 또는 30초 버킷으로 구간 나눠 각 구간의
*내용 요약*. 1~2문장씩. 각 항목 끝에 타임스탬프 범위 필수.
- **[00:0002:30]** 도입부에서 다룬 내용 한 문장 요약 (mm:ssmm:ss)
- **[02:3005:00]** 본론 첫 부분… (mm:ssmm:ss)
- …
## 🔗 인용용 한 줄 카드 (Citation Snippets) *개념을 이해하고 싶은 사람을 위해.*
영상의 *결정적 발언* 을 그대로 따옴표로 보존. 사장님이 글·발표·메모에 인용할 때 복붙용.
3~5개. 길이는 한 문장. 타임스탬프 필수.
- "직접 인용 한 문장" — ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
- …
## ❓ 더 파고들 질문 (Open Questions) 영상이 다룬 *주요 개념·용어* 2-5개. 각각 정의 + 영상에서의 등장 맥락. 영상이 개념 위주가 아니라 사례·잡담·일화 위주면 이 ## 섹션 통째로 한 줄로 갈음: "이 영상은 개념보다 사례·주장 위주 — 핵심은 30초 요약 참조."
영상이 답하지 않았거나 추가 검증 필요한 사항 2~4개. 사장님이 다음 자료를 찾을 때
바로 검색어로 쓸 수 있게 구체적으로. ### {개념 이름 1}
- **정의**: 화자가 어떻게 정의했는지 (또는 일반 정의 + 화자 사용 방식).
- **영상에서**: "직접 인용" — (mm:ss). 등장 맥락 1-2문장.
### {개념 이름 2}
- **정의**: ...
- **영상에서**: ...
---
## 깊이 분석
*깊게 보고 싶은 사람을 위한 추가 자료.*
### 타임라인
영상 30분 이상이면 chapters(메타데이터에 있으면 사용) 또는 흐름 단위로 4-7개 구간 압축. 30분 미만이면 이 sub-section 통째로 한 줄: "영상이 짧아 생략."
- **[mm:ssmm:ss]** 구간 핵심 한 문장
- ...
### 결정적 발언 (인용용)
글·발표·메모에 그대로 복붙 가능한 *한 문장 인용* 3-5개. 타임스탬프 필수. 위 30초 요약의 핵심 포인트와 중복되는 인용은 빼고, 보조 발언 위주로.
- "직접 인용 한 문장" — ${slim.channel || '?'} (mm:ss)
- ...
### 더 파고들 질문
영상이 답하지 않았거나 추가 검증 필요한 사항 2-4개. 사장님이 다음 자료를 찾을 때 검색어로 쓸 수 있게 구체적으로.
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것" - "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
- - ...
## 🧩 정리자 노트 (원본 보강) — 선택 ### 구체 수치·데이터
*본문에 없지만* 정리자가로 짚고 싶은 맥락·해석·연결·경고. 위 6개 핵심 섹션과 **표 만들지 말 것** — bullet로, *완성된 정보만*. 본문에 모호하거나 정리자 추측이 들어가야 할 데이터는 통째로 생략. 항목: \`{이름}: {값} (mm:ss)\` 형태.
구조적으로 격리되어, 독자가 "이건 화자가 말한 게 아니라 LLM 이 추론한 거" 라고 - ...
명확히 인지하도록. 모든 항목은 \`[정리자 추론]\` 라벨로 시작. (본문에 명시된 구체 수치 없으면 이 sub-section 통째로 한 줄: "본문에 명시된 구체 수치 없음.")
- **[정리자 추론]** 화자가 "여러 채널을 동시 시청" 하라 했지만, 입문자 페이스를 고려하면
먼저 한 채널을 깊게 따라가는 것도 한 가지 시작점이 될 수 있음.
- …
특별히 보강할 게 없으면 이 섹션 통째로 "정리자 추가 노트 없음 — 본문 그대로가 명확함" 한 줄.`; ---
## 정리자 노트 (선택)
*본문에 없지만* 정리자가 추가로 짚고 싶은 맥락·해석·연결·경고. 모두 \`[정리자 추론]\` 라벨로 시작 — 독자가 "이건 화자가 말한 게 아니라 LLM이 추론한 것"으로 즉시 식별.
- **[정리자 추론]** ...
- ...
보강할 게 없으면 이 ## 섹션 통째로 한 줄: "정리자 추가 노트 없음 — 본문 그대로가 명확함."`;
} }
/** /**
+344 -2
View File
@@ -1,7 +1,9 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { logInfo } from '../../utils'; import { logInfo } from '../../utils';
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore'; import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
import { fetchAllPrices } from './yahooClient'; 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 { classifyAll } from './signalClassifier';
import { writeStocksStore } from './stocksStore'; import { writeStocksStore } from './stocksStore';
import { syncToSheets } from './sheetsSync'; import { syncToSheets } from './sheetsSync';
@@ -144,6 +146,342 @@ async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`); if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
} }
// ─── /stocks analysis <심볼> — 단일 종목 심층 분석 (judge 보다 깊음) ───
// judge: 4-criteria 펀더멘털 평가만 (stocks.json 에 저장된 데이터 사용)
// analysis: Naver 펀더멘털 fresh fetch + Yahoo 1년 시세 기반 기술 지표(224회복/낙폭과대)
// + LLM 종합 평가 (권장도/근거/리스크). stocks.json 에 종목이 없어도 동작.
const ANALYSIS_SYSTEM_PROMPT = [
'당신은 한국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *Naver 펀더멘털*',
'+ *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.',
'외부 정보 추측 금지 — 주어진 데이터에서만 판단.',
'',
'**평가 6차원:**',
' 1. 가치 — PBR / PER (저평가 여부)',
' 2. 수익성 — ROE / 영업이익률 절대 수준',
' 3. 재무 안정성 — 유보율, 시가총액, **부채비율** (200% 이하 안전, 100% 이하 우량)',
' 4. 추세 (기술) — 224일선 회복 + **MA 정배열/역배열** (5/20/60/120일)',
' · 정배열 = 추세 매수 안전',
' · 역배열 = 펀더 좋아도 대기 권장',
' · MA224 "N/A — 추세 확립" 상태는 **부정 신호 아님** (현재가가 MA224 한참 위라 회복 패턴 평가 무관). passed:false 와 혼동 금지.',
' 5. 안전마진 — 낙폭과대 (1년 고점 대비 하락 + 최근 저점에서 반등). raw 데이터의 `failReason` 가 명시되면 그것을 그대로 인용 — 인과 거꾸로(예: "반등이 커서 미통과" 같은 오해석) 금지.',
' 6. 진입 타이밍 — **RSI(14)**',
' · 과열 (≥70) = 매수 자제',
' · 침체 (≤30) = 단기 반등 여지',
' · 중립 (30~70) = 정상',
'',
'**판단 절제 규칙 — 과잉 해석 차단:**',
' a. **PBR/PER 절대값으로 "고평가/저평가" 단정 금지.** 업종 평균 데이터가 컨텍스트에 없으면 "PBR X.X (업종 평균 데이터 부재 — 절대값만 제시)" 식으로만. "부담" / "고평가" 같은 강한 단어는 업종 비교나 5년 역사적 평균이 있을 때만 사용.',
' b. **거래량 ±20% 미만 변동은 무의미.** 5일/60일 평균 차이가 약 -10% 정도면 "유의미한 변화 아님"으로 처리하고 "상승 동력 약화" 같은 강한 해석으로 끌고 가지 말 것. 진짜 약화 신호는 -20% 이상 또는 추세적 감소.',
' c. **우선주 (symbol 끝자리 5/7/9) 는 특이 항목 반영 필수.** 컨텍스트에 `[우선주 정보]` 블록이 있으면 보통주 대비 할인율을 매수 의견 근거에 한 줄 언급. 우선주는 일반적으로 보통주 대비 10~30% 할인이 정상 — 그 범위 내면 "정상", 좁으면 "프리미엄", 넓으면 "확대" 로 평가. 배당수익률 데이터는 현재 미수집이므로 추측 금지.',
' d. **오탈자 자기 점검.** 출력 직전 한국어 토큰 깨짐(예: "순위" → "순칭", "근거" → "근례")이 없는지 확인. 의심스러우면 다시 쓸 것.',
'',
'**출력 형식 (정확히 이대로, 다른 헤더 추가 금지):**',
'## 종합 평가',
'2-3 문장 핵심 의견.',
'',
'## 매수 의견',
'- **권장도**: 매수권 / 관망 / 회피 중 하나',
'- **근거**: 위 6차원 중 핵심 2-3개를 *수치 인용* 으로',
'',
'## 매매 타점',
'*가격·% 모두 raw 데이터(MA값, 1년 최고가, 60일 저점, RSI, 부채비율 등)에서 직접 도출. 추측·외부 정보 금지. `__원` 같은 placeholder가 아니라 실제 수치로 채울 것.*',
'',
'### 매수 진입 타점',
'권장도가 **회피**면 이 sub-section 통째로 한 줄: "회피 권장 — 매수 타점 산정 생략."',
'권장도가 매수권/관망이면 *현재 기술 상태에 부합하는 시나리오만* 1-2개:',
'- **1순위 — MA20 지지**: MA 정배열일 때만. MA20 ±1% 영역(가격 범위 X원~Y원). 거래량 감소 후 반등 캔들 확인.',
'- **2순위 — MA60 눌림**: 단기 조정 심화 시. MA60 부근(Z원). 낙폭과대 동시 충족 시 적극.',
'- **3순위 — 낙폭과대 + RSI ≤30**: RSI 30 이하 + 60일 저점(W원) 근접. 부채비율·유보율 이상 없을 것.',
'현재 데이터로 어떤 시나리오도 부합 안 하면 한 줄: "현재 매수 타점 조건 부적합 — 추가 조정 대기."',
'',
'### 손절 기준',
'**종가 기준 이탈**만 적용 (장중 터치는 손절 미해당). *위 매수 시나리오에 출력한 항목에 한해서만:*',
'- MA20 진입 시 → MA20(X원) 종가 이탈, 최대 허용 약 -5%',
'- MA60 진입 시 → MA60(X원) 종가 이탈, 최대 허용 약 -8%',
'- 낙폭과대 진입 시 → 60일 저점(X원) 종가 이탈, 최대 허용 약 -10%',
'',
'### 익절 타점',
'- **1차**: 1년 최고가 × 0.98 (가격 X원), 보유 비중 30~50% 축소',
'- **2차**: 다음 심리적 라운드 넘버(현재가 위 가장 가까운 10,000/50,000/100,000원 단위, X원), 잔량 추가 축소',
'- **3차**: 동종 업종 평균 PER 대비 20% 초과 시 전량 익절 고려. *업종 평균 PER 데이터는 현재 미수집 — 사용자가 수동 비교 필요.*',
'',
'### 관망 해제 트리거',
'권장도가 **관망**일 때만 작성. 매수권/회피면 이 sub-section 통째로 생략(line drop).',
'아래 *2개 이상 동시 충족* 시 진입 전환:',
'1. MA20 또는 MA60 종가 지지 확인',
'2. 거래량 60일 평균 (현재 약 X) 이하 감소 후 반등',
'3. RSI 50 이하로 눌린 후 재반등 (현재 RSI Y)',
'4. 1년 최고가 대비 -10% 이상 추가 조정',
'',
'## 리스크',
'1-2줄, 주의해야 할 점.',
].join('\n');
/**
* 한국 우선주 → 보통주 심볼 도출. 우선주 마지막 자리는 5/7/9 (1종/2종/3종 우선주).
* 보통주는 마지막 자리 0. 예: 005935 → 005930, 005385 → 005380.
* 패턴이 아니면 null (보통주거나 다른 형식).
*/
function deriveCommonStockSymbol(symbol: string): string | null {
if (!/^[0-9]{6}$/.test(symbol)) return null;
const last = symbol[symbol.length - 1];
if (last === '5' || last === '7' || last === '9') {
return symbol.slice(0, -1) + '0';
}
return null;
}
export interface PreferredStockInfo {
/** 우선주 → 보통주로 환산한 6자리 심볼. */
commonSymbol: string;
/** Yahoo 에서 가져온 보통주 현재가. */
commonPrice: number;
/** 우선주 현재가. */
preferredPrice: number;
/** (보통주 - 우선주) / 보통주 × 100. 양수 = 우선주가 더 쌈(할인), 음수 = 프리미엄. */
discountPct: number;
}
function buildAnalysisContext(
symbol: string,
f: Fundamentals,
ma224: Ma224RecoveryResult | null,
drop: DropRecoveryResult | null,
align: MaAlignmentResult | null,
rsi: Rsi14Result | null,
preferred: PreferredStockInfo | null,
): string {
const lines: string[] = [
`종목 심볼: ${symbol}`,
`섹터: ${f.sectorHint ?? '-'}`,
'',
'── Naver 펀더멘털 ──',
`시가총액: ${f.marketCapEok !== undefined ? Math.round(f.marketCapEok).toLocaleString() + '억' : '-'}`,
`ROE: ${f.roe !== undefined ? f.roe.toFixed(2) + '%' : '-'}`,
`영업이익률: ${f.operatingMargin !== undefined ? f.operatingMargin.toFixed(1) + '%' : '-'}`,
`유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`,
`부채비율: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`,
`PER: ${f.per !== undefined ? f.per.toFixed(1) : '-'}`,
`PBR: ${f.pbr !== undefined ? f.pbr.toFixed(2) : '-'}`,
`현재가: ${f.currentPrice !== undefined ? f.currentPrice.toLocaleString() + '원' : '-'}`,
'',
'── Yahoo 1년 기술 지표 ──',
];
if (ma224) {
const ma = ma224.ma224Today !== undefined ? Math.round(ma224.ma224Today).toLocaleString() : '-';
const cp = ma224.currentPrice !== undefined ? ma224.currentPrice.toLocaleString() : '-';
if (ma224.notApplicable) {
// N/A 상태 — passed:false 와 시각적으로 구분 (⚪ vs ❌). LLM 도 이걸 보고 음성 신호 아님으로 인지.
lines.push(
`224일선(MA224) 회복: ⚪ N/A — ${ma224.notApplicableReason ?? '추세 확립, 회복 패턴 평가 무관'}`,
` · 현재가 ${cp} vs MA224 ${ma}`,
' · ⚠️ 부정 신호가 아닙니다 — 회복 패턴은 *장기 하락 후 상향 돌파* 신호이므로, 이미 한참 위에 있는 종목엔 적용 무관.',
);
} else {
lines.push(
`224일선(MA224) 회복: ${ma224.passed ? '✅ 통과' : '❌ 미통과'}`,
` · 현재가 ${cp} vs MA224 ${ma}`,
` · 최근 30일 중 MA224 아래 일수: ${ma224.daysBelowLast30 ?? '-'}`,
` · 거래량 확인: 5일평균 ${ma224.vol5dAvg !== undefined ? Math.round(ma224.vol5dAvg).toLocaleString() : '-'} vs 60일평균 ${ma224.vol60dAvg !== undefined ? Math.round(ma224.vol60dAvg).toLocaleString() : '-'}${ma224.volumeConfirmed ? '✅' : '❌'}`,
);
}
} else {
lines.push('224일선 분석: 시세 데이터 부족 (224일 미만)');
}
if (drop) {
const cp = drop.currentPrice !== undefined ? drop.currentPrice.toLocaleString() : '-';
const hi = drop.high1y !== undefined ? drop.high1y.toLocaleString() : '-';
const lo = drop.low60d !== undefined ? drop.low60d.toLocaleString() : '-';
const fromHigh = drop.fromHighRatio !== undefined ? `${(drop.fromHighRatio * 100).toFixed(1)}% 수준` : '-';
const fromLow = drop.fromLowRatio !== undefined ? `+${((drop.fromLowRatio - 1) * 100).toFixed(1)}% 반등` : '-';
const verdict = drop.passed
? '✅ 통과'
: `❌ 미통과${drop.failReason ? `${drop.failReason}` : ''}`;
lines.push(
`낙폭과대: ${verdict}`,
` · 1년 최고가 ${hi} → 현재가 ${cp} (${fromHigh})`,
` · 60일 저점 ${lo} → 현재가 ${cp} (${fromLow})`,
);
} else {
lines.push('낙폭과대 분석: 시세 데이터 부족 (60일 미만)');
}
if (align) {
const fmt = (n?: number) => n !== undefined ? Math.round(n).toLocaleString() : '-';
lines.push(
`MA 배열: ${align.alignment === '정배열' ? '✅ 정배열' : align.alignment === '역배열' ? '❌ 역배열' : '⚠️ 혼조'}`,
` · MA5 ${fmt(align.ma5)} / MA20 ${fmt(align.ma20)} / MA60 ${fmt(align.ma60)} / MA120 ${fmt(align.ma120)}`,
);
} else {
lines.push('MA 배열: 시세 데이터 부족 (120일 미만)');
}
if (rsi) {
const tag = rsi.classification === '과열' ? '🔥 과열' : rsi.classification === '침체' ? '🧊 침체' : '🟢 중립';
lines.push(`RSI(14): ${rsi.rsi.toFixed(1)} (${tag})`);
} else {
lines.push('RSI: 시세 데이터 부족 (15일 미만)');
}
if (preferred) {
const sign = preferred.discountPct > 0 ? '할인' : '프리미엄';
// 우선주 할인 범위 가이드: 통상 10~30% 정상, 좁으면 프리미엄, 넓으면 확대.
const rangeNote = preferred.discountPct >= 30 ? '확대 (보통주 대비 매우 저평가)'
: preferred.discountPct >= 10 ? '정상 범위 (10~30%)'
: preferred.discountPct > 0 ? '좁음 (10% 미만 — 보통주 대비 프리미엄 수준)'
: '역전 (우선주가 보통주보다 비쌈 — 극히 이례적)';
lines.push(
'',
'── 우선주 특이정보 ──',
`보통주 ${preferred.commonSymbol} 현재가: ${preferred.commonPrice.toLocaleString()}`,
`우선주 ${symbol} 현재가: ${preferred.preferredPrice.toLocaleString()}`,
`보통주 대비 ${sign}율: ${Math.abs(preferred.discountPct).toFixed(1)}% — ${rangeNote}`,
'※ 우선주는 배당수익률 + 보통주 대비 할인율이 핵심 투자 포인트. 배당 데이터는 미수집(사용자 확인 필요).',
);
}
return lines.join('\n');
}
async function cmdAnalysis(arg: string, view: Webview | undefined): Promise<void> {
const symbol = (arg.trim().split(/\s+/)[0] || '').trim();
if (!symbol) {
chunk(view, '\n사용법: `/stocks analysis <심볼>` — 펀더멘털 + 1년 차트 패턴 종합 분석 (judge보다 깊음). stocks.json 미등록 종목도 가능.\n');
return;
}
chunk(view, `\n🔍 **${symbol} 심층 분석** — Naver 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`);
// 1. Naver 펀더멘털
const fundsMap = await fetchAllFundamentals([symbol]);
const f = fundsMap.get(symbol);
if (!f) {
chunk(view, '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n');
return;
}
chunk(view, ' · Naver 펀더멘털 OK\n');
// 2. Yahoo 1년 시세 + 기술 지표
const history = await fetchYahooHistory(symbol);
const ma224 = history ? evalMa224Recovery(history) : null;
const drop = history ? evalDropRecovery(history) : null;
const align = history ? evalMaAlignment(history) : null;
const rsi = history ? evalRsi14(history) : null;
chunk(view, history
? ` · Yahoo 1년 시세 OK (${history.closes.length} 거래일)\n`
: ' ⚠️ Yahoo 시세 fetch 실패 — 펀더멘털만으로 분석\n');
// 2-b. 우선주(symbol 끝자리 5/7/9) 면 보통주 현재가도 가져와 할인율 계산.
// 배당 데이터까지는 아직 미수집 — 추후 Naver crawl 확장 필요.
let preferred: PreferredStockInfo | null = null;
const commonSymbol = deriveCommonStockSymbol(symbol);
if (commonSymbol && f.currentPrice !== undefined && f.currentPrice > 0) {
chunk(view, ` · 우선주 감지 — 보통주 ${commonSymbol} 현재가 fetch 중...\n`);
const commonPrice = await fetchYahooPrice(commonSymbol);
if (typeof commonPrice === 'number' && commonPrice > 0) {
preferred = {
commonSymbol,
commonPrice,
preferredPrice: f.currentPrice,
discountPct: (commonPrice - f.currentPrice) / commonPrice * 100,
};
chunk(view, ` · 보통주 ${commonSymbol} 현재가 ${commonPrice.toLocaleString()}원 (할인율 ${preferred.discountPct.toFixed(1)}%)\n`);
} else {
chunk(view, ` ⚠️ 보통주 ${commonSymbol} 가격 fetch 실패 — 할인율 계산 skip\n`);
}
}
// 3. 데이터 요약 표시 (모델에 보내는 것과 동일 — 투명성)
const summary = buildAnalysisContext(symbol, f, ma224, drop, align, rsi, preferred);
chunk(view, `\n\`\`\`\n${summary}\n\`\`\`\n`);
// 4. LLM 종합 분석
chunk(view, '\n🤖 LLM 종합 분석 중...\n');
const ai = new AIService();
try {
const result = await ai.chat({
system: ANALYSIS_SYSTEM_PROMPT,
user: summary + '\n\n위 데이터로 종합 평가, 매수 의견, 리스크를 출력 형식 그대로 작성하시오.',
});
if (result.empty || !result.content.trim()) {
chunk(view, '\n❌ LLM 빈 응답.\n');
return;
}
chunk(view, `\n${result.content.trim()}\n`);
logInfo('Stocks analysis 완료.', { symbol, model: result.model });
} catch (e: any) {
chunk(view, `\n❌ LLM 호출 실패: ${e?.message ?? String(e)}\n`);
}
}
// ─── /stocks position [심볼] <총자산> <리스크%> <손절%> — 포지션 사이징 ───
// 공식: 권장 투자금 = 총자산 × (리스크%/100) ÷ (손절%/100)
// 심볼을 주면 Yahoo 현재가로 매수 가능 주수까지 계산.
async function cmdPosition(arg: string, view: Webview | undefined): Promise<void> {
const parts = arg.trim().split(/\s+/).filter(Boolean);
if (parts.length < 3) {
chunk(view, [
'\n사용법:',
' `/stocks position <총자산> <리스크%> <손절%>` — 단순 계산',
' `/stocks position <심볼> <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수',
'',
'예: `/stocks position 50000000 2 5` → 50M × 2% ÷ 5% = 20M',
'예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주',
'',
].join('\n'));
return;
}
// 첫 인자가 6자리 숫자면 심볼, 아니면 곧바로 숫자 인자.
let symbol: string | undefined;
let nums: string[];
if (/^[0-9]{6}$/.test(parts[0])) {
symbol = parts[0];
nums = parts.slice(1);
} else {
nums = parts;
}
if (nums.length < 3) {
chunk(view, '\n❌ 인자 부족 — 총자산, 리스크%, 손절% 3개 필요.\n');
return;
}
const total = Number(nums[0]);
const riskPct = Number(nums[1]);
const stopPct = Number(nums[2]);
if (!Number.isFinite(total) || !Number.isFinite(riskPct) || !Number.isFinite(stopPct)
|| total <= 0 || riskPct <= 0 || stopPct <= 0) {
chunk(view, '\n❌ 잘못된 입력 — 모두 양수여야 합니다.\n');
return;
}
if (riskPct > 100 || stopPct > 100) {
chunk(view, '\n❌ %는 100 이하로 입력하세요 (예: 2 = 2%).\n');
return;
}
const positionWon = total * (riskPct / 100) / (stopPct / 100);
const maxLoss = positionWon * (stopPct / 100);
const positionRatio = positionWon / total * 100;
chunk(view, '\n💰 **포지션 사이징 계산**\n');
chunk(view, ` · 총 자산: ${total.toLocaleString()}\n`);
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${Math.round(maxLoss).toLocaleString()}원)\n`);
chunk(view, ` · 손절폭: ${stopPct}%\n`);
chunk(view, `\n → **권장 투자금: ${Math.round(positionWon).toLocaleString()}원** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
if (positionRatio > 50) {
chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n');
}
if (symbol) {
chunk(view, `\n📈 ${symbol} 현재가 fetch 중...\n`);
const price = await fetchYahooPrice(symbol);
if (typeof price === 'number') {
const shares = Math.floor(positionWon / price);
const actualWon = shares * price;
chunk(view, ` · 현재가: ${price.toLocaleString()}\n`);
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${actualWon.toLocaleString()}원)\n`);
} else {
chunk(view, ' ⚠️ Yahoo 현재가 fetch 실패 — 주수 계산 skip\n');
}
}
chunk(view, '\n');
}
async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> { async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n📨 텔레그램 보고서 발송 중...\n'); chunk(view, '\n📨 텔레그램 보고서 발송 중...\n');
const r = await sendStocksReport(context); const r = await sendStocksReport(context);
@@ -225,7 +563,9 @@ function cmdHelp(view: Webview | undefined): void {
' `/stocks sync` — Google Sheets 동기화', ' `/stocks sync` — Google Sheets 동기화',
' `/stocks add <심볼> <이름>` — 종목 추가', ' `/stocks add <심볼> <이름>` — 종목 추가',
' `/stocks remove <심볼>` — 종목 제거', ' `/stocks remove <심볼>` — 종목 제거',
' `/stocks judge <심볼>` — LLM 4-criteria 평가', ' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합)',
' `/stocks position [심볼] <총자산> <리스크%> <손절%>` — 포지션 사이징 (적정 투자금)',
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)', ' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
' `/stocks report` — 텔레그램 보고서 즉시 발송', ' `/stocks report` — 텔레그램 보고서 즉시 발송',
' `/stocks run` — Watcher 1회 즉시 실행', ' `/stocks run` — Watcher 1회 즉시 실행',
@@ -260,6 +600,8 @@ export async function handleStocksCommand(
case 'add': await cmdAdd(rest, view); return true; case 'add': await cmdAdd(rest, view); return true;
case 'remove': case 'rm': await cmdRemove(rest, view); return true; case 'remove': case 'rm': await cmdRemove(rest, view); return true;
case 'judge': await cmdJudge(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 'discover': await cmdDiscover(rest, view, context); return true;
case 'report': case 'report':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; } if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
+111 -1
View File
@@ -143,6 +143,13 @@ function rollingMean(arr: number[], window: number): number[] {
export interface Ma224RecoveryResult { export interface Ma224RecoveryResult {
/** 회복 패턴 + 거래량 확인을 모두 통과했는지. */ /** 회복 패턴 + 거래량 확인을 모두 통과했는지. */
passed: boolean; passed: boolean;
/**
* 평가 자체가 부적용인 상태 (현재가가 MA224 보다 +20% 이상 위 → 추세 이미 확립).
* `passed:false`와 의미가 다름 — passed:false 는 "회복 시도했으나 미달", N/A는 "회복할 게 없음".
* UI/LLM 은 이를 "음성 신호" 가 아니라 "무관" 으로 해석해야 함.
*/
notApplicable?: boolean;
notApplicableReason?: string;
/** 오늘 시점 MA224 값. */ /** 오늘 시점 MA224 값. */
ma224Today?: number; ma224Today?: number;
/** 최근 30거래일 중 종가가 그 시점 MA224 아래였던 일수. */ /** 최근 30거래일 중 종가가 그 시점 MA224 아래였던 일수. */
@@ -174,6 +181,17 @@ export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult |
const ma224Today = ma[ma.length - 1]; const ma224Today = ma[ma.length - 1];
const currentPrice = closes[closes.length - 1]; const currentPrice = closes[closes.length - 1];
if (!Number.isFinite(ma224Today) || !Number.isFinite(currentPrice)) return null; if (!Number.isFinite(ma224Today) || !Number.isFinite(currentPrice)) return null;
// 현재가가 MA224 한참 위(+20% 초과) → 추세 이미 확립. 회복 패턴 평가가 무관(N/A).
// 이 상태를 passed:false 로 보고하면 사용자/LLM 이 "음성 신호" 로 오해함 (실제로는 정상).
if (currentPrice / ma224Today > 1.20) {
const pctAbove = ((currentPrice / ma224Today - 1) * 100).toFixed(0);
return {
passed: false,
notApplicable: true,
notApplicableReason: `현재가가 MA224 보다 +${pctAbove}% 위 — 추세 이미 확립, 회복 패턴 평가 무관(부정 신호 아님)`,
ma224Today, currentPrice,
};
}
if (currentPrice < ma224Today) { if (currentPrice < ma224Today) {
return { passed: false, ma224Today, daysBelowLast30: 0, currentPrice }; return { passed: false, ma224Today, daysBelowLast30: 0, currentPrice };
} }
@@ -206,6 +224,8 @@ export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult |
export interface DropRecoveryResult { export interface DropRecoveryResult {
/** 낙폭과대 + 반등 초입 패턴 통과 여부. */ /** 낙폭과대 + 반등 초입 패턴 통과 여부. */
passed: boolean; passed: boolean;
/** 실패 사유 (둘 중 어느 조건이 어긋났는지 명시) — passed:false 일 때만. */
failReason?: string;
/** 1년 최고가 (history 전 구간 max). */ /** 1년 최고가 (history 전 구간 max). */
high1y?: number; high1y?: number;
/** 60거래일 최저가. */ /** 60거래일 최저가. */
@@ -236,8 +256,98 @@ export function evalDropRecovery(history: YahooHistory): DropRecoveryResult | nu
if (!Number.isFinite(high1y) || !Number.isFinite(low60d) || high1y <= 0 || low60d <= 0) return null; if (!Number.isFinite(high1y) || !Number.isFinite(low60d) || high1y <= 0 || low60d <= 0) return null;
const fromHighRatio = currentPrice / high1y; const fromHighRatio = currentPrice / high1y;
const fromLowRatio = currentPrice / low60d; const fromLowRatio = currentPrice / low60d;
const passed = fromHighRatio <= 0.75 && fromLowRatio >= 1.10;
let failReason: string | undefined;
if (!passed) {
// 어느 조건이 *실제로* 어긋났는지를 명시 — LLM 이 인과를 거꾸로 설명("64% 반등이라서 미통과"
// 같은 잘못된 해석) 하는 것을 차단. 두 조건 모두 fail 이면 더 의미 있는 쪽(고점 근접)을 우선.
if (fromHighRatio > 0.75) {
const dropPct = ((1 - fromHighRatio) * 100).toFixed(1);
failReason = `1년 고점 대비 ${dropPct}%만 하락 — 안전마진 부족 (조건: ≥25% 하락)`;
} else if (fromLowRatio < 1.10) {
const reboundPct = ((fromLowRatio - 1) * 100).toFixed(1);
failReason = `60일 저점에서 ${reboundPct}%만 반등 — 회복 초입 신호 부족 (조건: ≥10% 반등)`;
}
}
return { return {
passed: fromHighRatio <= 0.75 && fromLowRatio >= 1.10, passed, failReason,
high1y, low60d, currentPrice, fromHighRatio, fromLowRatio, high1y, low60d, currentPrice, fromHighRatio, fromLowRatio,
}; };
} }
export interface MaAlignmentResult {
/** 5/20/60/120일 이평선의 상대 위치 분류. */
alignment: '정배열' | '역배열' | '혼조';
ma5?: number;
ma20?: number;
ma60?: number;
ma120?: number;
currentPrice?: number;
}
/**
* 이동평균선 배열 판정 — 가이드 4단계 "정배열/역배열".
* - 정배열 (강세): MA5 > MA20 > MA60 > MA120 — 추세 매수 안전
* - 역배열 (약세): MA5 < MA20 < MA60 < MA120 — 펀더 좋아도 대기 권장
* - 혼조: 그 외
*
* 데이터 부족(120일 미만)이면 null.
*/
export function evalMaAlignment(history: YahooHistory): MaAlignmentResult | null {
const closes = history.closes;
if (closes.length < 120) return null;
const meanLast = (n: number) => closes.slice(-n).reduce((a, b) => a + b, 0) / n;
const ma5 = meanLast(5);
const ma20 = meanLast(20);
const ma60 = meanLast(60);
const ma120 = meanLast(120);
const currentPrice = closes[closes.length - 1];
let alignment: '정배열' | '역배열' | '혼조' = '혼조';
if (ma5 > ma20 && ma20 > ma60 && ma60 > ma120) alignment = '정배열';
else if (ma5 < ma20 && ma20 < ma60 && ma60 < ma120) alignment = '역배열';
return { alignment, ma5, ma20, ma60, ma120, currentPrice };
}
export interface Rsi14Result {
/** 14일 RSI 값 (0~100). */
rsi: number;
/** 과열 (≥70) / 침체 (≤30) / 중립 (그 외). */
classification: '과열' | '중립' | '침체';
}
/**
* RSI(14) — 가이드 4단계 "과매수/과매도" 지표. Wilder's smoothing.
* - 과열 (≥70): 매수 자제
* - 침체 (≤30): 단기 반등 여지
* - 중립 (30~70): 정상
*
* 데이터 부족(15일 미만)이면 null.
*/
export function evalRsi14(history: YahooHistory): Rsi14Result | null {
const closes = history.closes;
if (closes.length < 15) return null;
// 첫 14개 변동분 — 단순 평균.
let avgGain = 0;
let avgLoss = 0;
for (let i = 1; i <= 14; i++) {
const diff = closes[i] - closes[i - 1];
if (diff > 0) avgGain += diff;
else avgLoss += -diff;
}
avgGain /= 14;
avgLoss /= 14;
// Wilder's smoothing — 이후 모든 일자.
for (let i = 15; i < closes.length; i++) {
const diff = closes[i] - closes[i - 1];
const gain = diff > 0 ? diff : 0;
const loss = diff < 0 ? -diff : 0;
avgGain = (avgGain * 13 + gain) / 14;
avgLoss = (avgLoss * 13 + loss) / 14;
}
if (avgLoss === 0) return { rsi: 100, classification: '과열' };
const rs = avgGain / avgLoss;
const rsi = 100 - 100 / (1 + rs);
const classification: '과열' | '중립' | '침체' =
rsi >= 70 ? '과열' : rsi <= 30 ? '침체' : '중립';
return { rsi, classification };
}