feat: 주식 보유종목 인텔리전스 탭 (액션·이슈·포트건강)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
src/pages/stock/components/HoldingsIntelTab.jsx
Normal file
152
src/pages/stock/components/HoldingsIntelTab.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { stockHoldingsIntel } from '../../../api';
|
||||
|
||||
/* ── action config ────────────────────────────────────────────────── */
|
||||
const ACTION_MAP = {
|
||||
add: { label: '추가매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
|
||||
hold: { label: '보유', color: '#94a3b8', bg: 'rgba(148,163,184,0.10)' },
|
||||
trim: { label: '축소', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||
sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
};
|
||||
|
||||
const SEV_COLOR = { high: '#ef4444', med: '#f59e0b', low: '#94a3b8' };
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────── */
|
||||
const fmtRate = (v) => (v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` : '—');
|
||||
const fmtPct = (v) => (v != null ? `${(v * 100).toFixed(0)}%` : '—');
|
||||
|
||||
/* ── sub-components ───────────────────────────────────────────────── */
|
||||
const HealthBar = ({ ph }) => (
|
||||
<div className="hi-health">
|
||||
<span className={`hi-health__pnl ${(ph.total_pnl_rate ?? 0) >= 0 ? 'is-up' : 'is-down'}`}>
|
||||
포트 손익 {fmtRate(ph.total_pnl_rate)}
|
||||
</span>
|
||||
<span className="hi-health__sep">·</span>
|
||||
<span>종목 {ph.positions ?? 0}</span>
|
||||
<span className="hi-health__sep">·</span>
|
||||
<span>최대비중 {fmtPct(ph.max_weight)}</span>
|
||||
<span className="hi-health__sep">·</span>
|
||||
<span>현금 {fmtPct(ph.cash_ratio)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HoldingCard = ({ h }) => {
|
||||
const cfg = ACTION_MAP[h.action] ?? { label: h.action, color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
|
||||
const issues = (h.issues || []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="hi-card">
|
||||
<div className="hi-card__head">
|
||||
<span
|
||||
className="hi-action-badge"
|
||||
style={{ color: cfg.color, background: cfg.bg }}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
<strong className="hi-card__name">{h.name || h.ticker}</strong>
|
||||
<span className="hi-card__ticker">{h.ticker}</span>
|
||||
<span className={`hi-card__pnl ${(h.pnl_rate ?? 0) >= 0 ? 'is-up' : 'is-down'}`}>
|
||||
{fmtRate(h.pnl_rate)}
|
||||
</span>
|
||||
</div>
|
||||
{h.reasons && (
|
||||
<div className="hi-card__reasons">{h.reasons}</div>
|
||||
)}
|
||||
{h.tech_score != null && (
|
||||
<div className="hi-card__score">
|
||||
기술강도 <strong>{h.tech_score.toFixed(0)}</strong>
|
||||
<span className="hi-score-bar" style={{ '--score': `${h.tech_score}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<div className="hi-card__issues">
|
||||
{issues.map((iss, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="hi-issue"
|
||||
style={{ color: SEV_COLOR[iss.severity] ?? '#94a3b8' }}
|
||||
>
|
||||
<span className="hi-issue__dot" style={{ background: SEV_COLOR[iss.severity] ?? '#94a3b8' }} />
|
||||
{iss.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── main component ───────────────────────────────────────────────── */
|
||||
const HoldingsIntelTab = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
stockHoldingsIntel()
|
||||
.then(setData)
|
||||
.catch((err) => setError(err?.message ?? String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const ph = data?.portfolio_health ?? {};
|
||||
const holdings = data?.holdings ?? [];
|
||||
|
||||
return (
|
||||
<section className="stock-panel stock-panel--wide hi-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">보유종목 인텔리전스</p>
|
||||
<h3>보유종목 신호 분석</h3>
|
||||
<p className="stock-panel__sub">
|
||||
스크리너 엔진 기반 기술분석·매도룰·이슈를 보유종목에 적용합니다 (어드바이저리).
|
||||
</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">
|
||||
{loading && <Loading type="spinner" message="" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="stock-error">{error}</p>}
|
||||
|
||||
{!loading && !error && !data && (
|
||||
<p className="stock-empty">데이터를 불러오는 중입니다.</p>
|
||||
)}
|
||||
|
||||
{!loading && data && (
|
||||
<>
|
||||
{Object.keys(ph).length > 0 && <HealthBar ph={ph} />}
|
||||
|
||||
{data.date && (
|
||||
<p className="hi-date">분석 기준일: {data.date}</p>
|
||||
)}
|
||||
|
||||
{holdings.length === 0 ? (
|
||||
<div className="hi-empty">
|
||||
<span className="hi-empty__icon">📊</span>
|
||||
<p>아직 분석 데이터가 없습니다.</p>
|
||||
<p className="hi-empty__sub">
|
||||
보유종목 등록 후 EOD 계산이 완료되면 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hi-cards">
|
||||
{holdings.map((h) => (
|
||||
<HoldingCard key={h.ticker} h={h} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hi-disclaimer">
|
||||
※ 투자 판단 보조용 제안입니다. 자동매매가 아니며 최종 결정은 본인 책임입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoldingsIntelTab;
|
||||
Reference in New Issue
Block a user