Files
stock_chart_site/web/components/CompositeScoreCard.tsx
claude-owner 959e9afa80 refactor(card): extract shared CardHeader for detail-page body cards
상세 페이지 본문 카드 8개가 character-by-character 로 동일한 헤더
markup 을 중복 정의하고 있었음 — ReturnBucket 분리와 같은 future
drift 리스크. 단일 source of truth 로 묶음.

신규 web/components/CardHeader.tsx:
- 표준 패턴 캡슐화: mb-3 flex items-baseline justify-between 컨테이너
  + text-sm font-medium text-zinc-200 title + 옵셔널 text-[11px]
  text-zinc-500 subtitle.
- subtitle 은 옵셔널 — MetricsPanel 처럼 제목만 있는 카드도 같은
  컨테이너로 정렬돼 본문 카드 라인 높이 통일.

적용한 카드 8개:
- IntradayReturns / PeriodReturns / CompositeScoreCard /
  PeerComparePanel / TradingValuePanel / DisclosuresPanel / NewsList
  — 기존 markup 과 시각 결과 완전 동일.
- MetricsPanel — 기존 mb-2 standalone div → CardHeader 로 흡수,
  마진이 mb-2 → mb-3 으로 정렬됨. 다른 본문 카드들과 헤더 아래 공간
  통일.

적용 제외:
- PredictionPanel: 우측 슬롯이 button (예측 실행), 좌측이 title+
  subtitle 두 줄 중첩. 단순 text subtitle 모델에 안 맞음.
- InvestorCumulative: 우측 슬롯이 window 선택 button 그룹 (1M/3M/...)
  + aria-pressed. 위와 같은 이유.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:52:32 +09:00

216 lines
6.8 KiB
TypeScript

"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 (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader title="종합 점수 (AI)" subtitle="모멘텀·수급·감성·추세 평균" />
<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>
);
}