2174504b59
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>
354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
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;
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// 1년 일봉 시세 — 224일 이동평균 + 회복 패턴 판정에 사용.
|
|
// 영상(주식단테 "224일선 안착 = 추세 전환") 신호를 펀더멘털 발굴에 보조 기준으로 얹기 위한 입력.
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
export interface YahooHistory {
|
|
/** 종가 배열 (오래된→최신 순). null 종가는 제거됨. */
|
|
closes: number[];
|
|
/** 거래량 배열 (closes와 같은 인덱스 정렬, null은 0으로 채움). */
|
|
volumes: number[];
|
|
}
|
|
|
|
export async function fetchYahooHistory(symbol: string, timeoutMs = 10000): Promise<YahooHistory | 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}?range=1y&interval=1d`;
|
|
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 q = data?.chart?.result?.[0]?.indicators?.quote?.[0];
|
|
const closesRaw: any[] = q?.close ?? [];
|
|
const volumesRaw: any[] = q?.volume ?? [];
|
|
// closes·volumes를 같은 인덱스로 정렬해 유지 — null close는 양쪽 모두 제거,
|
|
// null volume은 0으로 보전(낙폭과대/거래량 평균 계산이 흔들리지 않게).
|
|
const closes: number[] = [];
|
|
const volumes: number[] = [];
|
|
for (let i = 0; i < closesRaw.length; i++) {
|
|
const c = closesRaw[i];
|
|
if (typeof c !== 'number' || !Number.isFinite(c)) continue;
|
|
closes.push(c);
|
|
const v = volumesRaw[i];
|
|
volumes.push(typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
}
|
|
if (closes.length === 0) continue;
|
|
return { closes, volumes };
|
|
} catch (e: any) {
|
|
if (yahooSymbol === candidates[candidates.length - 1]) {
|
|
logError('Yahoo Finance 1년 시세 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function fetchAllHistories(
|
|
symbols: string[],
|
|
onProgress?: (symbol: string, ok: boolean, i: number, total: number) => void,
|
|
): Promise<Map<string, YahooHistory | null>> {
|
|
const out = new Map<string, YahooHistory | null>();
|
|
for (let i = 0; i < symbols.length; i++) {
|
|
const symbol = symbols[i];
|
|
const h = await fetchYahooHistory(symbol);
|
|
out.set(symbol, h);
|
|
onProgress?.(symbol, h !== null, i + 1, symbols.length);
|
|
await new Promise(r => setTimeout(r, 1000)); // Yahoo rate limit 보호
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** window 길이 단순 이동평균 (오래된→최신 순 입력, 출력 길이 = arr.length - window + 1). */
|
|
function rollingMean(arr: number[], window: number): number[] {
|
|
const out: number[] = [];
|
|
if (arr.length < window) return out;
|
|
let sum = 0;
|
|
for (let i = 0; i < window; i++) sum += arr[i];
|
|
out.push(sum / window);
|
|
for (let i = window; i < arr.length; i++) {
|
|
sum += arr[i] - arr[i - window];
|
|
out.push(sum / window);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface Ma224RecoveryResult {
|
|
/** 회복 패턴 + 거래량 확인을 모두 통과했는지. */
|
|
passed: boolean;
|
|
/**
|
|
* 평가 자체가 부적용인 상태 (현재가가 MA224 보다 +20% 이상 위 → 추세 이미 확립).
|
|
* `passed:false`와 의미가 다름 — passed:false 는 "회복 시도했으나 미달", N/A는 "회복할 게 없음".
|
|
* UI/LLM 은 이를 "음성 신호" 가 아니라 "무관" 으로 해석해야 함.
|
|
*/
|
|
notApplicable?: boolean;
|
|
notApplicableReason?: string;
|
|
/** 오늘 시점 MA224 값. */
|
|
ma224Today?: number;
|
|
/** 최근 30거래일 중 종가가 그 시점 MA224 아래였던 일수. */
|
|
daysBelowLast30?: number;
|
|
/** 현재 종가. */
|
|
currentPrice?: number;
|
|
/** 거래량 확인 통과 여부 (최근 5일 평균 ≥ 60일 평균 × 1.2). 거래량 데이터 부족 시 true. */
|
|
volumeConfirmed?: boolean;
|
|
/** 최근 5일 평균 거래량. */
|
|
vol5dAvg?: number;
|
|
/** 최근 60일 평균 거래량. */
|
|
vol60dAvg?: number;
|
|
}
|
|
|
|
/**
|
|
* 224일선(약 1년 이평) 회복 패턴 판정.
|
|
*
|
|
* 영상의 "주가가 224일선 아래 머물다 → 강한 거래량으로 돌파 → 안착 = 추세 전환" 신호를
|
|
* 정량화한 단순 버전:
|
|
* - 현재 종가 ≥ 오늘 시점 MA224 (현재 위에 있음)
|
|
* - 최근 30거래일 중 5일 이상 그 시점 MA224 아래에 머문 적 있음 (직전까지 저평가 영역)
|
|
*
|
|
* 데이터가 부족(시세 224일 미만)하면 null — 호출자가 신호 미적용.
|
|
*/
|
|
export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult | null {
|
|
const closes = history.closes;
|
|
if (closes.length < 224) return null;
|
|
const ma = rollingMean(closes, 224);
|
|
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 };
|
|
}
|
|
const lookback = Math.min(30, ma.length);
|
|
let daysBelow = 0;
|
|
for (let i = ma.length - lookback; i < ma.length; i++) {
|
|
const priceAtI = closes[i + 224 - 1]; // ma[i] = mean of closes[i..i+223]
|
|
if (priceAtI < ma[i]) daysBelow++;
|
|
}
|
|
const priceCondition = daysBelow >= 5;
|
|
// 거래량 확인 — 영상(주식단테 "강한 거래량으로 돌파") 의 정량화. 거짓 돌파(일시적
|
|
// 가격 튐) 필터. 데이터 부족(거래량 60일 미만)이면 가격 조건만으로 판정한다.
|
|
let volumeConfirmed = true;
|
|
let vol5dAvg: number | undefined;
|
|
let vol60dAvg: number | undefined;
|
|
if (history.volumes && history.volumes.length >= 60) {
|
|
const v5 = history.volumes.slice(-5);
|
|
const v60 = history.volumes.slice(-60);
|
|
vol5dAvg = v5.reduce((a, b) => a + b, 0) / 5;
|
|
vol60dAvg = v60.reduce((a, b) => a + b, 0) / 60;
|
|
volumeConfirmed = vol60dAvg > 0 && vol5dAvg >= vol60dAvg * 1.2;
|
|
}
|
|
return {
|
|
passed: priceCondition && volumeConfirmed,
|
|
ma224Today, daysBelowLast30: daysBelow, currentPrice,
|
|
volumeConfirmed, vol5dAvg, vol60dAvg,
|
|
};
|
|
}
|
|
|
|
export interface DropRecoveryResult {
|
|
/** 낙폭과대 + 반등 초입 패턴 통과 여부. */
|
|
passed: boolean;
|
|
/** 실패 사유 (둘 중 어느 조건이 어긋났는지 명시) — passed:false 일 때만. */
|
|
failReason?: string;
|
|
/** 1년 최고가 (history 전 구간 max). */
|
|
high1y?: number;
|
|
/** 60거래일 최저가. */
|
|
low60d?: number;
|
|
/** 현재 종가. */
|
|
currentPrice?: number;
|
|
/** 1년 최고가 대비 현재가 비율 (0~1+, 낮을수록 많이 빠짐). */
|
|
fromHighRatio?: number;
|
|
/** 60일 저점 대비 현재가 비율 (1+, 높을수록 많이 반등). */
|
|
fromLowRatio?: number;
|
|
}
|
|
|
|
/**
|
|
* "낙폭과대 + 반등 초입" 패턴 판정. 영상(주식단테 "이미 빠진 종목 + 바닥 찍고 회복")의
|
|
* 정량화. 224회복(추세 전환)과는 다른 각도 — 안전마진 + 회복 초입.
|
|
*
|
|
* - 현재가 ≤ 1년 최고가 × 0.75 (= 25% 이상 하락 — 안전마진)
|
|
* - AND 현재가 ≥ 60일 최저가 × 1.10 (= 최근 저점에서 10% 이상 반등 — 회복 초입)
|
|
*
|
|
* 데이터 부족(시세 60일 미만)이면 null — 호출자가 신호 미적용.
|
|
*/
|
|
export function evalDropRecovery(history: YahooHistory): DropRecoveryResult | null {
|
|
const closes = history.closes;
|
|
if (closes.length < 60) return null;
|
|
const currentPrice = closes[closes.length - 1];
|
|
const high1y = Math.max(...closes);
|
|
const low60d = Math.min(...closes.slice(-60));
|
|
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, 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 };
|
|
}
|