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
+111 -1
View File
@@ -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 };
}