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:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
||||||
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
||||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||||
import { NewsList } from "../../components/NewsList";
|
import { NewsList } from "../../components/NewsList";
|
||||||
@@ -140,6 +141,9 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
<RelatedStocks code={code} />
|
<RelatedStocks code={code} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<CompositeScoreCard chart={chart} />
|
||||||
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<PeriodReturns ohlcv={chart.ohlcv} />
|
<PeriodReturns ohlcv={chart.ohlcv} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
219
web/components/CompositeScoreCard.tsx
Normal file
219
web/components/CompositeScoreCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user