feat(ui): AI 종합 점수 카드 (모멘텀·수급·감성·추세)

토스 "투자자 분석" 톤의 단일 카드:
- 종합 점수 반원 게이지 (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 <noreply@anthropic.com>
This commit is contained in:
claude-owner
2026-05-28 15:02:04 +09:00
parent 9b25bc6970
commit 0b4795b03f
2 changed files with 223 additions and 0 deletions

View File

@@ -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 } }) {
<RelatedStocks code={code} />
</div>
</div>
<div className="mt-6">
<CompositeScoreCard chart={chart} />
</div>
<div className="mt-6">
<PeriodReturns ohlcv={chart.ohlcv} />
</div>

View File

@@ -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 (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> (AI)</div>
<div className="text-[11px] text-zinc-500">
···
</div>
</div>
<div className="grid grid-cols-[auto_1fr] items-center gap-5">
<ScoreDial value={s.composite} />
<div className="grid grid-cols-2 gap-2">
<SubScore label="모멘텀" value={s.momentum} hint="SMA5 vs SMA20" />
<SubScore label="수급" value={s.flow} hint="외국인+기관 5일" />
<SubScore label="감성" value={s.sentiment} hint="뉴스 7일 가중" />
<SubScore label="추세" value={s.trend} hint="20일 회귀 기울기" />
</div>
</div>
<div className="mt-3 text-[11px] text-zinc-500">
. 50 = , 100 = .
</div>
</div>
);
}
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 (
<div className="relative h-24 w-24">
<div
className="absolute inset-0 rounded-full"
style={{
background: `conic-gradient(from 270deg, ${ringColor} 0deg, ${ringColor} ${angle}deg, #27272a ${angle}deg, #27272a 180deg, transparent 180deg)`,
mask: "radial-gradient(circle, transparent 38px, #000 39px)",
WebkitMask:
"radial-gradient(circle, transparent 38px, #000 39px)",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center pt-2">
<div className={`text-xl font-bold tabular-nums ${tone}`}>
{value == null ? "—" : value}
</div>
<div className="text-[10px] text-zinc-500">/ 100</div>
</div>
</div>
);
}
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 (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-1.5">
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-zinc-400">{label}</span>
<span className={`text-sm font-medium tabular-nums ${tone}`}>
{value == null ? "—" : value}
</span>
</div>
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
className={`h-full ${barColor}`}
style={{ width: `${value ?? 0}%` }}
/>
</div>
<div className="mt-0.5 text-[10px] text-zinc-600">{hint}</div>
</div>
);
}