From 2174504b595a7e1f623868e142b9cd888a53f8c1 Mon Sep 17 00:00:00 2001 From: g1nation Date: Wed, 27 May 2026 14:59:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v2.2.162-168=20=E2=80=94=20/stocks=20an?= =?UTF-8?q?alysis=206=EC=B0=A8=EC=9B=90=20=ED=99=95=EC=9E=A5=20+=20positio?= =?UTF-8?q?n=20+=20/youtube=20info=20=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...cde86955f34dda22a6e02b95c9adc0a456927.json | 2 +- ...c10d377a9fef641dd359504b8d53aecd0a4c3.json | 4 +- .../tests/engine/.astra/missions/wiki_on.json | 12 +- ...b3d9d44f32b0e4cd024b2e055db3a0d34417e.json | 2 +- ...973124fb64ba505f767c53a783833bbc3fa6a.json | 2 +- ...0e6575e54853929e991e579e318f2f5a19030.json | 2 +- ...b73b3a5a01af5d82391ec29a25bd72b8239a5.json | 2 +- ...son => stress_conflict_1779860690078.json} | 16 +- PATCHNOTES.md | 116 ++++++ package-lock.json | 2 +- package.json | 2 +- .../datacollect/prompts/youtubePrompts.ts | 106 +++--- src/features/stocks/slashStocks.ts | 346 +++++++++++++++++- src/features/stocks/yahooClient.ts | 112 +++++- 14 files changed, 655 insertions(+), 71 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1779770057821.json => stress_conflict_1779860690078.json} (78%) diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index bde8853..d9b1dac 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1779770051307, + "createdAt": 1779860682873, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index 6a96279..a23c096 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -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", - "createdAt": 1779770051309, + "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": 1779860682875, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index a484d2d..45a5c18 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,8 +1,8 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-05-26T04:34:11.282Z", - "totalElapsedMs": 28, + "startTime": "2026-05-27T05:44:42.851Z", + "totalElapsedMs": 25, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 24, + "durationMs": 20, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-05-26T04:34:11.306Z" + "ts": "2026-05-27T05:44:42.871Z" }, { "from": "direct", "to": "completed", - "durationMs": 4, + "durationMs": 5, "message": "미션 완료", - "ts": "2026-05-26T04:34:11.310Z" + "ts": "2026-05-27T05:44:42.876Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index ec4a10d..1060b24 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1779770057843, + "createdAt": 1779860690104, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index 4fe20a9..4b569fb 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1779770057842, + "createdAt": 1779860690103, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index f0deedd..fdbf73d 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1779770057838, + "createdAt": 1779860690099, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index 47e5b93..0e52128 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1779770057840, + "createdAt": 1779860690101, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1779770057821.json b/.astra/tests/stress/.astra/missions/stress_conflict_1779860690078.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1779770057821.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1779860690078.json index 3a17d30..17d700e 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1779770057821.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1779860690078.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1779770057821", + "missionId": "stress_conflict_1779860690078", "status": "completed", - "startTime": "2026-05-26T04:34:17.821Z", - "totalElapsedMs": 23, + "startTime": "2026-05-27T05:44:50.078Z", + "totalElapsedMs": 27, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 16, + "durationMs": 20, "message": "답변 구조 잡는 중...", - "ts": "2026-05-26T04:34:17.837Z" + "ts": "2026-05-27T05:44:50.098Z" }, { "from": "outline", "to": "section", "durationMs": 2, "message": "본문 작성 중...", - "ts": "2026-05-26T04:34:17.839Z" + "ts": "2026-05-27T05:44:50.100Z" }, { "from": "section", "to": "polish", "durationMs": 2, "message": "최종 다듬기 중...", - "ts": "2026-05-26T04:34:17.841Z" + "ts": "2026-05-27T05:44:50.102Z" }, { "from": "polish", "to": "completed", "durationMs": 2, "message": "미션 완료", - "ts": "2026-05-26T04:34:17.843Z" + "ts": "2026-05-27T05:44:50.104Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 213fa2f..34693ec 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,121 @@ # 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 <심볼> — 단일 종목 심층 분석 +- **신규 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) ### 📈 /stocks discover — 낙폭과대 키워드 + 224회복 거래량 확인 - **신규 `낙폭과대` 키워드** ([evalDropRecovery](src/features/stocks/yahooClient.ts)): 영상(주식단테 "이미 빠진 종목 + 바닥 찍고 회복")의 정량화. 현재가 ≤ 1년 최고가 × 0.75(25%↓) AND 현재가 ≥ 60일 최저가 × 1.10(저점에서 10%↑) → `낙폭과대` +1. 224회복(추세 전환)과는 다른 각도 — 안전마진 + 반등 초입. diff --git a/package-lock.json b/package-lock.json index 0ab519f..042c338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "astra", - "version": "2.2.161", + "version": "2.2.168", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index 64f2427..ba3bca8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "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.", - "version": "2.2.161", + "version": "2.2.168", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/datacollect/prompts/youtubePrompts.ts b/src/features/datacollect/prompts/youtubePrompts.ts index 4fb55f6..66b7234 100644 --- a/src/features/datacollect/prompts/youtubePrompts.ts +++ b/src/features/datacollect/prompts/youtubePrompts.ts @@ -127,7 +127,9 @@ export function buildInfoExtractionPrompt(video: any, userContent: string): stri 대응 관계도 마찬가지 — 본문 그대로. 6. **순서·단계 발명 금지** — 화자가 "A → B → C 순서로" 라고 명시하지 *않았으면* "단계적 학습 순서" 같은 흐름을 정리자가 만들지 말 것. 굳이 필요하면 정리자 노트로. -7. 한국어 마크다운. 표·불릿 자유롭게. +7. 한국어 마크다운. **헤더에 이모지 금지** — 스캔할 때 시선이 분산되어 내용이 가려진다. +8. **표 사용 신중** — 모든 셀이 명확한 값으로 채워질 확신이 없으면 표 만들지 말고 bullet로. 깨진 셀(\`way\` 같은 LLM artifact)·빈 셀·"…" placeholder 절대 노출 금지. +9. **중복 금지** — 같은 내용은 한 곳에만. "한 줄 요약"이 여러 섹션에 반복되면 안 됨. 30초 요약에 한 번, 끝. [영상 메타데이터] \`\`\`json @@ -137,66 +139,80 @@ ${JSON.stringify(slim, null, 2)} [자막 본문] ${trimmed}${userBlock} -[필수 출력 형식 — 정확히 이 구조. 아래 8개 섹션 외 추가 금지] +[필수 출력 형식 — 정확히 이 구조. 아래 4개 ## 섹션 외 추가 금지. 3-tier 깊이: ① 30초 요약(skim) → ② 핵심 개념(이해) → ③ 깊이 분석(deep dive). 읽는 사람이 목적에 따라 멈출 수 있게.] # ${slim.title || video.title} — 정보 추출 카드 > **영상 URL**: ${slim.url} · **분석 일자**: ${today} · **길이**: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · **채널**: ${slim.channel || '?'} -## 🎯 한 줄 요약 (TL;DR) -(영상의 핵심 메시지 한 문장. "무엇이 누구에게 왜 중요한가" 를 압축. 제목 그대로 베끼지 -말고 본문 기준으로 다시 쓸 것. 정리자의 해석은 금지 — 화자의 말 그대로 압축) +> 신뢰도 라벨: \`[근거 명시]\` 본문 출처/수치 · \`[화자 주장]\` 출처 없는 단정 · \`[가정]\` 조건부 · \`[정리자 추론]\` 정리자 노트 전용 -## 💡 화자 한 줄 비유 (Anchor Metaphor) -영상에서 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 가 있으면 그대로 따옴표로 -보존. 영상 마무리부에 자주 등장. 예: "Hugging Face = 자료실, Reddit = 공부방, -유튜브 = 복습실" 같은 식. 없으면 "본문에 명시된 한 줄 비유 없음". +--- -## 📌 핵심 주장 3~5개 -영상이 *명시한* 주요 결론·주장만. 정리자 추론은 여기 들어오면 안 됨 (그건 🧩 섹션). -각 항목 한 줄 + 신뢰도 라벨 + 본문 인용 (mm:ss). -- **[근거 명시]** "주장 한 줄" — 본문 인용 (mm:ss) -- **[화자 주장]** "주장 한 줄" — 본문 인용 (mm:ss) -- … +## 30초 요약 -## 📊 사실·데이터·인용 -영상에 등장한 *구체적 수치·날짜·출처·고유명사·전문 용어 정의*. 가공 없이 그대로. -표로 정리: +*바쁜 사람은 여기서 멈춰도 영상을 안 본 것보다 낫게.* -| 항목 | 값 / 정의 | 출처 (영상 내) | 타임스탬프 | -| --- | --- | --- | --- | -| … | … | 화자/자료 화면/외부 출처 | mm:ss | +- **한 줄**: 영상 전체를 한 문장으로 — "무엇이 누구에게 왜 중요한가". 화자 표현 기준, 정리자 의역 금지. +- **핵심 포인트 3개**: 영상이 *명시한* 결론·주장만 (정리자 추론 금지). 각 항목 신뢰도 라벨 + 한 줄 + 타임스탬프. + 1. **[근거 명시]** ... — (mm:ss) + 2. **[화자 주장]** ... — (mm:ss) + 3. ... — (mm:ss) +- **화자 한 줄 비유** (있는 경우만): 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 그대로 따옴표. + ⚠️ 방향 보존 필수 — "Hugging Face = 자료실, Reddit = 공부방" 같으면 짝과 순서를 본문 그대로. 정리자가 단어 위치를 바꾸거나 의역하면 안 됨. 고유명사·수치·대응관계도 본문 그대로. + 예: "..." — (mm:ss). 본문에 비유 없으면 이 bullet 통째로 생략(line drop). -데이터가 없는 영상이면 "본문에 명시된 구체 수치·출처 없음" 한 줄. +--- -## 🧭 구조 요약 (Sectioned Summary) -영상을 chapters (메타데이터에 있으면 그것 사용) 또는 30초 버킷으로 구간 나눠 각 구간의 -*내용 요약*. 1~2문장씩. 각 항목 끝에 타임스탬프 범위 필수. -- **[00:00–02:30]** 도입부에서 다룬 내용 한 문장 요약 (mm:ss–mm:ss) -- **[02:30–05:00]** 본론 첫 부분… (mm:ss–mm:ss) -- … +## 핵심 개념 설명 -## 🔗 인용용 한 줄 카드 (Citation Snippets) -영상의 *결정적 발언* 을 그대로 따옴표로 보존. 사장님이 글·발표·메모에 인용할 때 복붙용. -3~5개. 길이는 한 문장. 타임스탬프 필수. -- "직접 인용 한 문장" — ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss) -- … +*개념을 이해하고 싶은 사람을 위해.* -## ❓ 더 파고들 질문 (Open Questions) -영상이 답하지 않았거나 추가 검증 필요한 사항 2~4개. 사장님이 다음 자료를 찾을 때 -바로 검색어로 쓸 수 있게 구체적으로. +영상이 다룬 *주요 개념·용어* 2-5개. 각각 정의 + 영상에서의 등장 맥락. 영상이 개념 위주가 아니라 사례·잡담·일화 위주면 이 ## 섹션 통째로 한 줄로 갈음: "이 영상은 개념보다 사례·주장 위주 — 핵심은 30초 요약 참조." + +### {개념 이름 1} +- **정의**: 화자가 어떻게 정의했는지 (또는 일반 정의 + 화자 사용 방식). +- **영상에서**: "직접 인용" — (mm:ss). 등장 맥락 1-2문장. + +### {개념 이름 2} +- **정의**: ... +- **영상에서**: ... + +--- + +## 깊이 분석 + +*깊게 보고 싶은 사람을 위한 추가 자료.* + +### 타임라인 +영상 30분 이상이면 chapters(메타데이터에 있으면 사용) 또는 흐름 단위로 4-7개 구간 압축. 30분 미만이면 이 sub-section 통째로 한 줄: "영상이 짧아 생략." +- **[mm:ss–mm:ss]** 구간 핵심 한 문장 +- ... + +### 결정적 발언 (인용용) +글·발표·메모에 그대로 복붙 가능한 *한 문장 인용* 3-5개. 타임스탬프 필수. 위 30초 요약의 핵심 포인트와 중복되는 인용은 빼고, 보조 발언 위주로. +- "직접 인용 한 문장" — ${slim.channel || '?'} (mm:ss) +- ... + +### 더 파고들 질문 +영상이 답하지 않았거나 추가 검증 필요한 사항 2-4개. 사장님이 다음 자료를 찾을 때 검색어로 쓸 수 있게 구체적으로. - "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것" -- … +- ... -## 🧩 정리자 노트 (원본 보강) — 선택 -*본문에 없지만* 정리자가 추가로 짚고 싶은 맥락·해석·연결·경고. 위 6개 핵심 섹션과 -구조적으로 격리되어, 독자가 "이건 화자가 말한 게 아니라 LLM 이 추론한 거" 라고 -명확히 인지하도록. 모든 항목은 \`[정리자 추론]\` 라벨로 시작. -- **[정리자 추론]** 화자가 "여러 채널을 동시 시청" 하라 했지만, 입문자 페이스를 고려하면 - 먼저 한 채널을 깊게 따라가는 것도 한 가지 시작점이 될 수 있음. -- … +### 구체 수치·데이터 +**표 만들지 말 것** — bullet로, *완성된 정보만*. 본문에 모호하거나 정리자 추측이 들어가야 할 데이터는 통째로 생략. 항목: \`{이름}: {값} (mm:ss)\` 형태. +- ... +(본문에 명시된 구체 수치 없으면 이 sub-section 통째로 한 줄: "본문에 명시된 구체 수치 없음.") -특별히 보강할 게 없으면 이 섹션 통째로 "정리자 추가 노트 없음 — 본문 그대로가 명확함" 한 줄.`; +--- + +## 정리자 노트 (선택) + +*본문에 없지만* 정리자가 추가로 짚고 싶은 맥락·해석·연결·경고. 모두 \`[정리자 추론]\` 라벨로 시작 — 독자가 "이건 화자가 말한 게 아니라 LLM이 추론한 것"으로 즉시 식별. +- **[정리자 추론]** ... +- ... + +보강할 게 없으면 이 ## 섹션 통째로 한 줄: "정리자 추가 노트 없음 — 본문 그대로가 명확함."`; } /** diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index 7153e04..2742ee1 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -1,7 +1,9 @@ import * as vscode from 'vscode'; import { logInfo } from '../../utils'; 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 { writeStocksStore } from './stocksStore'; import { syncToSheets } from './sheetsSync'; @@ -144,6 +146,342 @@ async function cmdJudge(arg: string, view: Webview | undefined): Promise { if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`); } +// ─── /stocks analysis <심볼> — 단일 종목 심층 분석 (judge 보다 깊음) ─── +// judge: 4-criteria 펀더멘털 평가만 (stocks.json 에 저장된 데이터 사용) +// analysis: Naver 펀더멘털 fresh fetch + Yahoo 1년 시세 기반 기술 지표(224회복/낙폭과대) +// + LLM 종합 평가 (권장도/근거/리스크). stocks.json 에 종목이 없어도 동작. + +const ANALYSIS_SYSTEM_PROMPT = [ + '당신은 한국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *Naver 펀더멘털*', + '+ *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.', + '외부 정보 추측 금지 — 주어진 데이터에서만 판단.', + '', + '**평가 6차원:**', + ' 1. 가치 — PBR / PER (저평가 여부)', + ' 2. 수익성 — ROE / 영업이익률 절대 수준', + ' 3. 재무 안정성 — 유보율, 시가총액, **부채비율** (200% 이하 안전, 100% 이하 우량)', + ' 4. 추세 (기술) — 224일선 회복 + **MA 정배열/역배열** (5/20/60/120일)', + ' · 정배열 = 추세 매수 안전', + ' · 역배열 = 펀더 좋아도 대기 권장', + ' · MA224 "N/A — 추세 확립" 상태는 **부정 신호 아님** (현재가가 MA224 한참 위라 회복 패턴 평가 무관). passed:false 와 혼동 금지.', + ' 5. 안전마진 — 낙폭과대 (1년 고점 대비 하락 + 최근 저점에서 반등). raw 데이터의 `failReason` 가 명시되면 그것을 그대로 인용 — 인과 거꾸로(예: "반등이 커서 미통과" 같은 오해석) 금지.', + ' 6. 진입 타이밍 — **RSI(14)**', + ' · 과열 (≥70) = 매수 자제', + ' · 침체 (≤30) = 단기 반등 여지', + ' · 중립 (30~70) = 정상', + '', + '**판단 절제 규칙 — 과잉 해석 차단:**', + ' a. **PBR/PER 절대값으로 "고평가/저평가" 단정 금지.** 업종 평균 데이터가 컨텍스트에 없으면 "PBR X.X (업종 평균 데이터 부재 — 절대값만 제시)" 식으로만. "부담" / "고평가" 같은 강한 단어는 업종 비교나 5년 역사적 평균이 있을 때만 사용.', + ' b. **거래량 ±20% 미만 변동은 무의미.** 5일/60일 평균 차이가 약 -10% 정도면 "유의미한 변화 아님"으로 처리하고 "상승 동력 약화" 같은 강한 해석으로 끌고 가지 말 것. 진짜 약화 신호는 -20% 이상 또는 추세적 감소.', + ' c. **우선주 (symbol 끝자리 5/7/9) 는 특이 항목 반영 필수.** 컨텍스트에 `[우선주 정보]` 블록이 있으면 보통주 대비 할인율을 매수 의견 근거에 한 줄 언급. 우선주는 일반적으로 보통주 대비 10~30% 할인이 정상 — 그 범위 내면 "정상", 좁으면 "프리미엄", 넓으면 "확대" 로 평가. 배당수익률 데이터는 현재 미수집이므로 추측 금지.', + ' d. **오탈자 자기 점검.** 출력 직전 한국어 토큰 깨짐(예: "순위" → "순칭", "근거" → "근례")이 없는지 확인. 의심스러우면 다시 쓸 것.', + '', + '**출력 형식 (정확히 이대로, 다른 헤더 추가 금지):**', + '## 종합 평가', + '2-3 문장 핵심 의견.', + '', + '## 매수 의견', + '- **권장도**: 매수권 / 관망 / 회피 중 하나', + '- **근거**: 위 6차원 중 핵심 2-3개를 *수치 인용* 으로', + '', + '## 매매 타점', + '*가격·% 모두 raw 데이터(MA값, 1년 최고가, 60일 저점, RSI, 부채비율 등)에서 직접 도출. 추측·외부 정보 금지. `__원` 같은 placeholder가 아니라 실제 수치로 채울 것.*', + '', + '### 매수 진입 타점', + '권장도가 **회피**면 이 sub-section 통째로 한 줄: "회피 권장 — 매수 타점 산정 생략."', + '권장도가 매수권/관망이면 *현재 기술 상태에 부합하는 시나리오만* 1-2개:', + '- **1순위 — MA20 지지**: MA 정배열일 때만. MA20 ±1% 영역(가격 범위 X원~Y원). 거래량 감소 후 반등 캔들 확인.', + '- **2순위 — MA60 눌림**: 단기 조정 심화 시. MA60 부근(Z원). 낙폭과대 동시 충족 시 적극.', + '- **3순위 — 낙폭과대 + RSI ≤30**: RSI 30 이하 + 60일 저점(W원) 근접. 부채비율·유보율 이상 없을 것.', + '현재 데이터로 어떤 시나리오도 부합 안 하면 한 줄: "현재 매수 타점 조건 부적합 — 추가 조정 대기."', + '', + '### 손절 기준', + '**종가 기준 이탈**만 적용 (장중 터치는 손절 미해당). *위 매수 시나리오에 출력한 항목에 한해서만:*', + '- MA20 진입 시 → MA20(X원) 종가 이탈, 최대 허용 약 -5%', + '- MA60 진입 시 → MA60(X원) 종가 이탈, 최대 허용 약 -8%', + '- 낙폭과대 진입 시 → 60일 저점(X원) 종가 이탈, 최대 허용 약 -10%', + '', + '### 익절 타점', + '- **1차**: 1년 최고가 × 0.98 (가격 X원), 보유 비중 30~50% 축소', + '- **2차**: 다음 심리적 라운드 넘버(현재가 위 가장 가까운 10,000/50,000/100,000원 단위, X원), 잔량 추가 축소', + '- **3차**: 동종 업종 평균 PER 대비 20% 초과 시 전량 익절 고려. *업종 평균 PER 데이터는 현재 미수집 — 사용자가 수동 비교 필요.*', + '', + '### 관망 해제 트리거', + '권장도가 **관망**일 때만 작성. 매수권/회피면 이 sub-section 통째로 생략(line drop).', + '아래 *2개 이상 동시 충족* 시 진입 전환:', + '1. MA20 또는 MA60 종가 지지 확인', + '2. 거래량 60일 평균 (현재 약 X) 이하 감소 후 반등', + '3. RSI 50 이하로 눌린 후 재반등 (현재 RSI Y)', + '4. 1년 최고가 대비 -10% 이상 추가 조정', + '', + '## 리스크', + '1-2줄, 주의해야 할 점.', +].join('\n'); + +/** + * 한국 우선주 → 보통주 심볼 도출. 우선주 마지막 자리는 5/7/9 (1종/2종/3종 우선주). + * 보통주는 마지막 자리 0. 예: 005935 → 005930, 005385 → 005380. + * 패턴이 아니면 null (보통주거나 다른 형식). + */ +function deriveCommonStockSymbol(symbol: string): string | null { + if (!/^[0-9]{6}$/.test(symbol)) return null; + const last = symbol[symbol.length - 1]; + if (last === '5' || last === '7' || last === '9') { + return symbol.slice(0, -1) + '0'; + } + return null; +} + +export interface PreferredStockInfo { + /** 우선주 → 보통주로 환산한 6자리 심볼. */ + commonSymbol: string; + /** Yahoo 에서 가져온 보통주 현재가. */ + commonPrice: number; + /** 우선주 현재가. */ + preferredPrice: number; + /** (보통주 - 우선주) / 보통주 × 100. 양수 = 우선주가 더 쌈(할인), 음수 = 프리미엄. */ + discountPct: number; +} + +function buildAnalysisContext( + symbol: string, + f: Fundamentals, + ma224: Ma224RecoveryResult | null, + drop: DropRecoveryResult | null, + align: MaAlignmentResult | null, + rsi: Rsi14Result | null, + preferred: PreferredStockInfo | null, +): string { + const lines: string[] = [ + `종목 심볼: ${symbol}`, + `섹터: ${f.sectorHint ?? '-'}`, + '', + '── Naver 펀더멘털 ──', + `시가총액: ${f.marketCapEok !== undefined ? Math.round(f.marketCapEok).toLocaleString() + '억' : '-'}`, + `ROE: ${f.roe !== undefined ? f.roe.toFixed(2) + '%' : '-'}`, + `영업이익률: ${f.operatingMargin !== undefined ? f.operatingMargin.toFixed(1) + '%' : '-'}`, + `유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`, + `부채비율: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`, + `PER: ${f.per !== undefined ? f.per.toFixed(1) : '-'}`, + `PBR: ${f.pbr !== undefined ? f.pbr.toFixed(2) : '-'}`, + `현재가: ${f.currentPrice !== undefined ? f.currentPrice.toLocaleString() + '원' : '-'}`, + '', + '── Yahoo 1년 기술 지표 ──', + ]; + if (ma224) { + const ma = ma224.ma224Today !== undefined ? Math.round(ma224.ma224Today).toLocaleString() : '-'; + const cp = ma224.currentPrice !== undefined ? ma224.currentPrice.toLocaleString() : '-'; + if (ma224.notApplicable) { + // N/A 상태 — passed:false 와 시각적으로 구분 (⚪ vs ❌). LLM 도 이걸 보고 음성 신호 아님으로 인지. + lines.push( + `224일선(MA224) 회복: ⚪ N/A — ${ma224.notApplicableReason ?? '추세 확립, 회복 패턴 평가 무관'}`, + ` · 현재가 ${cp} vs MA224 ${ma}`, + ' · ⚠️ 부정 신호가 아닙니다 — 회복 패턴은 *장기 하락 후 상향 돌파* 신호이므로, 이미 한참 위에 있는 종목엔 적용 무관.', + ); + } else { + lines.push( + `224일선(MA224) 회복: ${ma224.passed ? '✅ 통과' : '❌ 미통과'}`, + ` · 현재가 ${cp} vs MA224 ${ma}`, + ` · 최근 30일 중 MA224 아래 일수: ${ma224.daysBelowLast30 ?? '-'}`, + ` · 거래량 확인: 5일평균 ${ma224.vol5dAvg !== undefined ? Math.round(ma224.vol5dAvg).toLocaleString() : '-'} vs 60일평균 ${ma224.vol60dAvg !== undefined ? Math.round(ma224.vol60dAvg).toLocaleString() : '-'} → ${ma224.volumeConfirmed ? '✅' : '❌'}`, + ); + } + } else { + lines.push('224일선 분석: 시세 데이터 부족 (224일 미만)'); + } + if (drop) { + const cp = drop.currentPrice !== undefined ? drop.currentPrice.toLocaleString() : '-'; + const hi = drop.high1y !== undefined ? drop.high1y.toLocaleString() : '-'; + const lo = drop.low60d !== undefined ? drop.low60d.toLocaleString() : '-'; + const fromHigh = drop.fromHighRatio !== undefined ? `${(drop.fromHighRatio * 100).toFixed(1)}% 수준` : '-'; + const fromLow = drop.fromLowRatio !== undefined ? `+${((drop.fromLowRatio - 1) * 100).toFixed(1)}% 반등` : '-'; + const verdict = drop.passed + ? '✅ 통과' + : `❌ 미통과${drop.failReason ? ` — ${drop.failReason}` : ''}`; + lines.push( + `낙폭과대: ${verdict}`, + ` · 1년 최고가 ${hi} → 현재가 ${cp} (${fromHigh})`, + ` · 60일 저점 ${lo} → 현재가 ${cp} (${fromLow})`, + ); + } else { + lines.push('낙폭과대 분석: 시세 데이터 부족 (60일 미만)'); + } + if (align) { + const fmt = (n?: number) => n !== undefined ? Math.round(n).toLocaleString() : '-'; + lines.push( + `MA 배열: ${align.alignment === '정배열' ? '✅ 정배열' : align.alignment === '역배열' ? '❌ 역배열' : '⚠️ 혼조'}`, + ` · MA5 ${fmt(align.ma5)} / MA20 ${fmt(align.ma20)} / MA60 ${fmt(align.ma60)} / MA120 ${fmt(align.ma120)}`, + ); + } else { + lines.push('MA 배열: 시세 데이터 부족 (120일 미만)'); + } + if (rsi) { + const tag = rsi.classification === '과열' ? '🔥 과열' : rsi.classification === '침체' ? '🧊 침체' : '🟢 중립'; + lines.push(`RSI(14): ${rsi.rsi.toFixed(1)} (${tag})`); + } else { + lines.push('RSI: 시세 데이터 부족 (15일 미만)'); + } + if (preferred) { + const sign = preferred.discountPct > 0 ? '할인' : '프리미엄'; + // 우선주 할인 범위 가이드: 통상 10~30% 정상, 좁으면 프리미엄, 넓으면 확대. + const rangeNote = preferred.discountPct >= 30 ? '확대 (보통주 대비 매우 저평가)' + : preferred.discountPct >= 10 ? '정상 범위 (10~30%)' + : preferred.discountPct > 0 ? '좁음 (10% 미만 — 보통주 대비 프리미엄 수준)' + : '역전 (우선주가 보통주보다 비쌈 — 극히 이례적)'; + lines.push( + '', + '── 우선주 특이정보 ──', + `보통주 ${preferred.commonSymbol} 현재가: ${preferred.commonPrice.toLocaleString()}원`, + `우선주 ${symbol} 현재가: ${preferred.preferredPrice.toLocaleString()}원`, + `보통주 대비 ${sign}율: ${Math.abs(preferred.discountPct).toFixed(1)}% — ${rangeNote}`, + '※ 우선주는 배당수익률 + 보통주 대비 할인율이 핵심 투자 포인트. 배당 데이터는 미수집(사용자 확인 필요).', + ); + } + return lines.join('\n'); +} + +async function cmdAnalysis(arg: string, view: Webview | undefined): Promise { + const symbol = (arg.trim().split(/\s+/)[0] || '').trim(); + if (!symbol) { + chunk(view, '\n사용법: `/stocks analysis <심볼>` — 펀더멘털 + 1년 차트 패턴 종합 분석 (judge보다 깊음). stocks.json 미등록 종목도 가능.\n'); + return; + } + chunk(view, `\n🔍 **${symbol} 심층 분석** — Naver 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`); + + // 1. Naver 펀더멘털 + const fundsMap = await fetchAllFundamentals([symbol]); + const f = fundsMap.get(symbol); + if (!f) { + chunk(view, '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n'); + return; + } + chunk(view, ' · Naver 펀더멘털 OK\n'); + + // 2. Yahoo 1년 시세 + 기술 지표 + const history = await fetchYahooHistory(symbol); + const ma224 = history ? evalMa224Recovery(history) : null; + const drop = history ? evalDropRecovery(history) : null; + const align = history ? evalMaAlignment(history) : null; + const rsi = history ? evalRsi14(history) : null; + chunk(view, history + ? ` · Yahoo 1년 시세 OK (${history.closes.length} 거래일)\n` + : ' ⚠️ Yahoo 시세 fetch 실패 — 펀더멘털만으로 분석\n'); + + // 2-b. 우선주(symbol 끝자리 5/7/9) 면 보통주 현재가도 가져와 할인율 계산. + // 배당 데이터까지는 아직 미수집 — 추후 Naver crawl 확장 필요. + let preferred: PreferredStockInfo | null = null; + const commonSymbol = deriveCommonStockSymbol(symbol); + if (commonSymbol && f.currentPrice !== undefined && f.currentPrice > 0) { + chunk(view, ` · 우선주 감지 — 보통주 ${commonSymbol} 현재가 fetch 중...\n`); + const commonPrice = await fetchYahooPrice(commonSymbol); + if (typeof commonPrice === 'number' && commonPrice > 0) { + preferred = { + commonSymbol, + commonPrice, + preferredPrice: f.currentPrice, + discountPct: (commonPrice - f.currentPrice) / commonPrice * 100, + }; + chunk(view, ` · 보통주 ${commonSymbol} 현재가 ${commonPrice.toLocaleString()}원 (할인율 ${preferred.discountPct.toFixed(1)}%)\n`); + } else { + chunk(view, ` ⚠️ 보통주 ${commonSymbol} 가격 fetch 실패 — 할인율 계산 skip\n`); + } + } + + // 3. 데이터 요약 표시 (모델에 보내는 것과 동일 — 투명성) + const summary = buildAnalysisContext(symbol, f, ma224, drop, align, rsi, preferred); + chunk(view, `\n\`\`\`\n${summary}\n\`\`\`\n`); + + // 4. LLM 종합 분석 + chunk(view, '\n🤖 LLM 종합 분석 중...\n'); + const ai = new AIService(); + try { + const result = await ai.chat({ + system: ANALYSIS_SYSTEM_PROMPT, + user: summary + '\n\n위 데이터로 종합 평가, 매수 의견, 리스크를 출력 형식 그대로 작성하시오.', + }); + if (result.empty || !result.content.trim()) { + chunk(view, '\n❌ LLM 빈 응답.\n'); + return; + } + chunk(view, `\n${result.content.trim()}\n`); + logInfo('Stocks analysis 완료.', { symbol, model: result.model }); + } catch (e: any) { + chunk(view, `\n❌ LLM 호출 실패: ${e?.message ?? String(e)}\n`); + } +} + +// ─── /stocks position [심볼] <총자산> <리스크%> <손절%> — 포지션 사이징 ─── +// 공식: 권장 투자금 = 총자산 × (리스크%/100) ÷ (손절%/100) +// 심볼을 주면 Yahoo 현재가로 매수 가능 주수까지 계산. + +async function cmdPosition(arg: string, view: Webview | undefined): Promise { + const parts = arg.trim().split(/\s+/).filter(Boolean); + if (parts.length < 3) { + chunk(view, [ + '\n사용법:', + ' `/stocks position <총자산> <리스크%> <손절%>` — 단순 계산', + ' `/stocks position <심볼> <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수', + '', + '예: `/stocks position 50000000 2 5` → 50M × 2% ÷ 5% = 20M', + '예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주', + '', + ].join('\n')); + return; + } + // 첫 인자가 6자리 숫자면 심볼, 아니면 곧바로 숫자 인자. + let symbol: string | undefined; + let nums: string[]; + if (/^[0-9]{6}$/.test(parts[0])) { + symbol = parts[0]; + nums = parts.slice(1); + } else { + nums = parts; + } + if (nums.length < 3) { + chunk(view, '\n❌ 인자 부족 — 총자산, 리스크%, 손절% 3개 필요.\n'); + return; + } + const total = Number(nums[0]); + const riskPct = Number(nums[1]); + const stopPct = Number(nums[2]); + if (!Number.isFinite(total) || !Number.isFinite(riskPct) || !Number.isFinite(stopPct) + || total <= 0 || riskPct <= 0 || stopPct <= 0) { + chunk(view, '\n❌ 잘못된 입력 — 모두 양수여야 합니다.\n'); + return; + } + if (riskPct > 100 || stopPct > 100) { + chunk(view, '\n❌ %는 100 이하로 입력하세요 (예: 2 = 2%).\n'); + return; + } + const positionWon = total * (riskPct / 100) / (stopPct / 100); + const maxLoss = positionWon * (stopPct / 100); + const positionRatio = positionWon / total * 100; + + chunk(view, '\n💰 **포지션 사이징 계산**\n'); + chunk(view, ` · 총 자산: ${total.toLocaleString()}원\n`); + chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${Math.round(maxLoss).toLocaleString()}원)\n`); + chunk(view, ` · 손절폭: ${stopPct}%\n`); + chunk(view, `\n → **권장 투자금: ${Math.round(positionWon).toLocaleString()}원** (총자산의 ${positionRatio.toFixed(1)}%)\n`); + + if (positionRatio > 50) { + chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n'); + } + + if (symbol) { + chunk(view, `\n📈 ${symbol} 현재가 fetch 중...\n`); + const price = await fetchYahooPrice(symbol); + if (typeof price === 'number') { + const shares = Math.floor(positionWon / price); + const actualWon = shares * price; + chunk(view, ` · 현재가: ${price.toLocaleString()}원\n`); + chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${actualWon.toLocaleString()}원)\n`); + } else { + chunk(view, ' ⚠️ Yahoo 현재가 fetch 실패 — 주수 계산 skip\n'); + } + } + chunk(view, '\n'); +} + async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise { chunk(view, '\n📨 텔레그램 보고서 발송 중...\n'); const r = await sendStocksReport(context); @@ -225,7 +563,9 @@ function cmdHelp(view: Webview | undefined): void { ' `/stocks sync` — Google Sheets 동기화', ' `/stocks add <심볼> <이름>` — 종목 추가', ' `/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 report` — 텔레그램 보고서 즉시 발송', ' `/stocks run` — Watcher 1회 즉시 실행', @@ -260,6 +600,8 @@ export async function handleStocksCommand( case 'add': await cmdAdd(rest, view); return true; case 'remove': case 'rm': await cmdRemove(rest, view); return true; case 'judge': await cmdJudge(rest, view); return true; + case 'analysis': case 'analyze': await cmdAnalysis(rest, view); return true; + case 'position': case 'size': await cmdPosition(rest, view); return true; case 'discover': await cmdDiscover(rest, view, context); return true; case 'report': if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; } diff --git a/src/features/stocks/yahooClient.ts b/src/features/stocks/yahooClient.ts index 4bfb6c6..3ae758d 100644 --- a/src/features/stocks/yahooClient.ts +++ b/src/features/stocks/yahooClient.ts @@ -143,6 +143,13 @@ function rollingMean(arr: number[], window: number): number[] { export interface Ma224RecoveryResult { /** 회복 패턴 + 거래량 확인을 모두 통과했는지. */ passed: boolean; + /** + * 평가 자체가 부적용인 상태 (현재가가 MA224 보다 +20% 이상 위 → 추세 이미 확립). + * `passed:false`와 의미가 다름 — passed:false 는 "회복 시도했으나 미달", N/A는 "회복할 게 없음". + * UI/LLM 은 이를 "음성 신호" 가 아니라 "무관" 으로 해석해야 함. + */ + notApplicable?: boolean; + notApplicableReason?: string; /** 오늘 시점 MA224 값. */ ma224Today?: number; /** 최근 30거래일 중 종가가 그 시점 MA224 아래였던 일수. */ @@ -174,6 +181,17 @@ export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult | const ma224Today = ma[ma.length - 1]; const currentPrice = closes[closes.length - 1]; 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) { return { passed: false, ma224Today, daysBelowLast30: 0, currentPrice }; } @@ -206,6 +224,8 @@ export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult | export interface DropRecoveryResult { /** 낙폭과대 + 반등 초입 패턴 통과 여부. */ passed: boolean; + /** 실패 사유 (둘 중 어느 조건이 어긋났는지 명시) — passed:false 일 때만. */ + failReason?: string; /** 1년 최고가 (history 전 구간 max). */ high1y?: number; /** 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; const fromHighRatio = currentPrice / high1y; 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 { - passed: fromHighRatio <= 0.75 && fromLowRatio >= 1.10, + passed, failReason, 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 }; +}