"use client"; // AI 종합 점수 카드 — 토스 "투자자 분석" 비슷한 톤. // chart payload (ohlcv + sentiment + trading_value) 에서 클라이언트가 직접 합성. // 백엔드 호출 없음. // // 4개 서브 점수 (각 0~100, 50 = 중립): // - 모멘텀 : SMA5 vs SMA20 격차 // - 수급 : 외국인+기관 5일 누적 net (vs |대각| 정규화) // - 감성 : 뉴스 weighted_score 7일 평균 (-1~+1 → 0~100) // - 추세 : 종가 20일 선형회귀 기울기 부호+강도 // 종합 = 평균. 50 미만 = 약세(파랑), 50 초과 = 강세(빨강). import type { ChartPayload } from "../lib/api"; import { CardHeader } from "./CardHeader"; type Scores = { momentum: number | null; flow: number | null; sentiment: number | null; trend: number | null; composite: number | null; }; function computeScores(chart: ChartPayload): Scores { // 모멘텀: SMA5 vs SMA20 const closes = chart.ohlcv .map((p) => p.close) .filter((c): c is number => c != null); const sma = (arr: number[], n: number) => { if (arr.length < n) return null; const slice = arr.slice(-n); return slice.reduce((a, b) => a + b, 0) / n; }; const sma5 = sma(closes, 5); const sma20 = sma(closes, 20); const momentum = sma5 != null && sma20 != null && sma20 > 0 ? clamp01(((sma5 - sma20) / sma20) * 10) // ±10% → 0~1 : null; // 수급: 외국인+기관 5일 net / 평균거래대금 (조잡한 정규화) const tv = chart.trading_value.slice(-5); const flowSum = tv.reduce( (a, r) => a + (r.foreign_net ?? 0) + (r.institution_net ?? 0), 0, ); const flowAbsMean = tv.reduce( (a, r) => a + Math.abs(r.foreign_net ?? 0) + Math.abs(r.institution_net ?? 0), 0, ) / Math.max(1, tv.length); const flow = tv.length >= 3 && flowAbsMean > 0 ? clamp01(flowSum / (flowAbsMean * 5)) : null; // 감성: weighted_score 평균 (최근 7개 점 기준) const sents = chart.sentiment .slice(-7) .map((p) => p.weighted_score) .filter((v): v is number => v != null); const sentMean = sents.length > 0 ? sents.reduce((a, b) => a + b, 0) / sents.length : null; const sentiment = sentMean != null ? clamp01(sentMean) : null; // 추세: 최근 20 종가 선형회귀 기울기 / 평균값 (정규화) const last20 = closes.slice(-20); let trend: number | null = null; if (last20.length >= 10) { const n = last20.length; const xs = Array.from({ length: n }, (_, i) => i); const xMean = (n - 1) / 2; const yMean = last20.reduce((a, b) => a + b, 0) / n; let num = 0; let den = 0; for (let i = 0; i < n; i++) { num += (xs[i] - xMean) * (last20[i] - yMean); den += (xs[i] - xMean) ** 2; } const slope = den > 0 ? num / den : 0; // 하루당 yMean 의 0.5% 변동 → 점수 만점/최저 const norm = yMean > 0 ? slope / (yMean * 0.005) : 0; trend = clamp01(norm); } const parts = [momentum, flow, sentiment, trend].filter( (v): v is number => v != null, ); const composite = parts.length > 0 ? parts.reduce((a, b) => a + b, 0) / parts.length : null; return { momentum: pct(momentum), flow: pct(flow), sentiment: pct(sentiment), trend: pct(trend), composite: pct(composite), }; } // -1~+1 입력 → 0~1 출력 (중립 0.5). function clamp01(x: number): number { return Math.max(0, Math.min(1, 0.5 + x / 2)); } function pct(v: number | null): number | null { return v == null ? null : Math.round(v * 100); } export function CompositeScoreCard({ chart }: { chart: ChartPayload }) { const s = computeScores(chart); return (
점수는 보조지표일 뿐 매매 추천이 아닙니다. 50 = 중립, 100 = 매우 강세.
); } function ScoreDial({ value }: { value: number | null }) { // CSS conic-gradient 반원 게이지. 0~100 → 0~180°. const v = value ?? 50; const angle = (v / 100) * 180; const tone = value == null ? "text-zinc-400" : value >= 65 ? "text-rose-400" : value <= 35 ? "text-sky-400" : "text-amber-300"; const ringColor = value == null ? "#52525b" : value >= 65 ? "#ef4444" : value <= 35 ? "#3b82f6" : "#f59e0b"; return (
{value == null ? "—" : value}
/ 100
); } function SubScore({ label, value, hint, }: { label: string; value: number | null; hint: string; }) { const tone = value == null ? "text-zinc-400" : value >= 65 ? "text-rose-400" : value <= 35 ? "text-sky-400" : "text-zinc-200"; const barColor = value == null ? "bg-zinc-700" : value >= 65 ? "bg-rose-500/70" : value <= 35 ? "bg-sky-500/70" : "bg-amber-500/60"; return (
{label} {value == null ? "—" : value}
{hint}
); }