From 0b4795b03fbcca1bc30e0c16faa9946c63fd4feb Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 15:02:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20AI=20=EC=A2=85=ED=95=A9=20=EC=A0=90?= =?UTF-8?q?=EC=88=98=20=EC=B9=B4=EB=93=9C=20(=EB=AA=A8=EB=A9=98=ED=85=80?= =?UTF-8?q?=C2=B7=EC=88=98=EA=B8=89=C2=B7=EA=B0=90=EC=84=B1=C2=B7=EC=B6=94?= =?UTF-8?q?=EC=84=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토스 "투자자 분석" 톤의 단일 카드: - 종합 점수 반원 게이지 (conic-gradient + radial mask) - 4개 서브 점수 (0~100, 50=중립) · 모멘텀: SMA5 vs SMA20 격차 · 수급: 외국인+기관 5일 누적 net 정규화 · 감성: 뉴스 weighted_score 7일 평균 · 추세: 20일 종가 선형회귀 기울기 - 백엔드 호출 없음 — chart payload (ohlcv+sentiment+trading_value) 클라이언트 합성 색조: 65↑ 강세=빨강 / 35↓ 약세=파랑 / 그 사이 중립=주황. Co-Authored-By: Claude Opus 4.7 --- web/app/[code]/page.tsx | 4 + web/components/CompositeScoreCard.tsx | 219 ++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 web/components/CompositeScoreCard.tsx diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 091b65c..13525cd 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { MetricsPanel } from "../../components/MetricsPanel"; import { NewsList } from "../../components/NewsList"; @@ -140,6 +141,9 @@ export default function CodePage({ params }: { params: { code: string } }) { +
+ +
diff --git a/web/components/CompositeScoreCard.tsx b/web/components/CompositeScoreCard.tsx new file mode 100644 index 0000000..e0e2982 --- /dev/null +++ b/web/components/CompositeScoreCard.tsx @@ -0,0 +1,219 @@ +"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"; + +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 ( +
+
+
종합 점수 (AI)
+
+ 모멘텀·수급·감성·추세 평균 +
+
+
+ +
+ + + + +
+
+
+ 점수는 보조지표일 뿐 매매 추천이 아닙니다. 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}
+
+ ); +}