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}
+
+ );
+}