Compare commits

6 Commits

Author SHA1 Message Date
96191b2d7c merge: 주식 보유종목 인텔리전스 탭 (액션·이슈·포트건강·현재가)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:56:14 +09:00
5b29854251 feat: 보유종목 탭 현재가 표시 + 빈상태 문구 수정
- HoldingCard 헤더에 h.close 현재가 표시 (null guard, toLocaleString 천단위)
- Stock.css에 .hi-card__close 추가 (#94a3b8, 11px, margin-right 4px)
- !loading && !error && !data 분기 메시지 '데이터를 불러오는 중입니다.' → '데이터가 없습니다.'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:38:11 +09:00
597e6504e1 feat: 주식 보유종목 인텔리전스 탭 (액션·이슈·포트건강)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:32:35 +09:00
b15cbbb1b6 merge: 로또 자율학습 탭 — 성적표·캘리브레이션·당첨조합 분석
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:09:11 +09:00
dacd01e6b9 feat: 로또 백테스트 탭 UI 폴리시 (1·2등 컬럼·빈 상태·차트 박스 CSS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:13:32 +09:00
a57ac23064 feat: 로또 자율학습 탭 — 성적표·캘리브레이션·당첨조합 분석
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:07:03 +09:00
10 changed files with 633 additions and 5 deletions

View File

@@ -697,6 +697,10 @@ export const refreshScreenerSnap = () => apiPost('/api/stock/screener
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`); export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`); export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
// ---- Stock Holdings Intelligence ----
export const stockHoldingsIntel = () => apiGet('/api/stock/holdings/intel');
export const stockHoldingsHistory = (ticker, days = 30) => apiGet(`/api/stock/holdings/intel/history?ticker=${ticker}&days=${days}`);
// --- Lotto Weight Evolver --- // --- Lotto Weight Evolver ---
export async function fetchEvolverStatus() { export async function fetchEvolverStatus() {
@@ -740,6 +744,11 @@ export async function triggerEvolverEvaluate() {
return r.json(); return r.json();
} }
// --- Lotto Backtest ---
export const lottoBacktestTrackRecord = () => apiGet('/api/lotto/backtest/track-record');
export const lottoBacktestCalibration = (weeks=52) => apiGet(`/api/lotto/backtest/calibration?weeks=${weeks}`);
export const lottoBacktestReview = (drawNo) => apiGet(`/api/lotto/backtest/review/${drawNo}`);
// --- Tarot Lab --- // --- Tarot Lab ---
export function tarotInterpret(body) { export function tarotInterpret(body) {

View File

@@ -58,6 +58,9 @@
.winner-card .winner-meta strong { color: #f1f5f9; font-weight: 600; } .winner-card .winner-meta strong { color: #f1f5f9; font-weight: 600; }
.winner-card .winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; } .winner-card .winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; }
/* Backtest — WinnerAnalysisCard chart wrapper (standalone, not inside .winner-card) */
.backtest-winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; }
/* TrialsGrid */ /* TrialsGrid */
.trials-grid .grid { .trials-grid .grid {
display: grid; grid-template-columns: repeat(6, 1fr); display: grid; grid-template-columns: repeat(6, 1fr);
@@ -186,6 +189,47 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Backtest — TrackRecordCard */
.backtest-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-bottom: 10px;
}
.backtest-table th {
text-align: left;
color: #94a3b8;
font-weight: 500;
padding: 6px 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.backtest-table td {
padding: 6px 8px;
color: #cbd5e1;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.backtest-table tr:last-child td { border-bottom: none; }
/* Backtest — shared note */
.backtest-note {
margin: 8px 0 0;
color: #64748b;
font-size: 0.8rem;
line-height: 1.4;
}
.backtest-note strong { color: #cbd5e1; }
/* Backtest — section divider */
.backtest-section-header {
margin: 8px 0 4px;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.03em;
border-top: 1px solid rgba(255,255,255,0.06);
padding-top: 14px;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; } .trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; }
.base-diff .diff-grid { grid-template-columns: repeat(3, 1fr); } .base-diff .diff-grid { grid-template-columns: repeat(3, 1fr); }

View File

@@ -0,0 +1,53 @@
import React from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
export default function CalibrationChart({ history }) {
if (!history || history.length === 0) {
return (
<div className="evolver-card backtest-calibration empty">
<h2>당첨조합 캘리브레이션 추세</h2>
<p className="muted">캘리브레이션 데이터가 없습니다.</p>
</div>
);
}
// history는 DESC 순서로 오므로 역순해서 오름차순 x축
const data = [...history].reverse().map((h) => ({
draw: h.draw_no,
score: h.score_total != null ? +h.score_total.toFixed(3) : null,
pct: h.percentile != null ? +h.percentile.toFixed(3) : null,
}));
return (
<div className="evolver-card backtest-calibration">
<h2>당첨조합 캘리브레이션 추세 (최근 {history.length}회차)</h2>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
<XAxis dataKey="draw" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis domain={[0, 1]} tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid rgba(255,255,255,0.1)', color: '#e2e8f0' }} />
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: '0.8rem' }} />
<Line
type="monotone"
dataKey="score"
stroke="#f59e0b"
dot={false}
name="당첨조합 분석치"
connectNulls
/>
<Line
type="monotone"
dataKey="pct"
stroke="#34d399"
dot={false}
name="무작위 percentile"
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
const STRATEGY_ORDER = ['engine_w', 'random_null', 'coverage'];
const STRATEGY_LABEL = { engine_w: '엔진', random_null: '무작위', coverage: '커버리지' };
export default function TrackRecordCard({ byStrategy }) {
if (!byStrategy) return null;
const rows = STRATEGY_ORDER.filter((s) => byStrategy[s]);
return (
<div className="evolver-card backtest-track-record">
<h2>누적 성적표</h2>
{rows.length === 0 ? (
<p className="backtest-note">아직 백테스트 데이터가 없습니다.</p>
) : (
<>
<table className="backtest-table">
<thead>
<tr>
<th>전략</th>
<th>누적 장수</th>
<th>회차수</th>
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
<th>5</th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const a = byStrategy[s];
return (
<tr key={s}>
<td>{STRATEGY_LABEL[s] || s}</td>
<td>{(a.n_tickets || 0).toLocaleString()}</td>
<td>{a.draws || 0}</td>
<td>{a['1st'] || 0}</td>
<td>{a['2nd'] || 0}</td>
<td>{a['3rd'] || 0}</td>
<td>{a['4th'] || 0}</td>
<td>{a['5th'] || 0}</td>
</tr>
);
})}
</tbody>
</table>
<p className="backtest-note">
엔진이 무작위를 넘지 못하면 분석에 통계적 우위가 없다는 정직한 증거입니다.
</p>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import {
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
Radar, ResponsiveContainer,
} from 'recharts';
export default function WinnerAnalysisCard({ analysis }) {
if (!analysis) return null;
const data = [
{ k: '빈도', v: analysis.score_frequency ?? 0 },
{ k: '지문', v: analysis.score_fingerprint ?? 0 },
{ k: '갭', v: analysis.score_gap ?? 0 },
{ k: '공동출현', v: analysis.score_cooccur ?? 0 },
{ k: '다양성', v: analysis.score_diversity ?? 0 },
];
const pct = analysis.percentile != null
? `${(analysis.percentile * 100).toFixed(0)}%`
: '—';
return (
<div className="evolver-card backtest-winner-analysis">
<h2>
이번 당첨조합 분석치
<span className="badge">무작위 상위 {pct}</span>
</h2>
<div className="backtest-winner-chart">
<ResponsiveContainer width="100%" height={240}>
<RadarChart data={data}>
<PolarGrid stroke="rgba(255,255,255,0.12)" />
<PolarAngleAxis dataKey="k" tick={{ fill: '#cbd5e1', fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 1]} tick={{ fill: '#64748b', fontSize: 10 }} />
<Radar
name="분석치"
dataKey="v"
stroke="#60a5fa"
fill="#60a5fa"
fillOpacity={0.4}
/>
</RadarChart>
</ResponsiveContainer>
</div>
<p className="backtest-note">
종합 점수: <strong>{(analysis.score_total ?? 0).toFixed(3)}</strong>
</p>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import '../Evolver.css'; import '../Evolver.css';
import { useEvolverApi } from '../evolver/useEvolverApi'; import { useEvolverApi } from '../evolver/useEvolverApi';
import WinnerCard from '../evolver/WinnerCard'; import WinnerCard from '../evolver/WinnerCard';
@@ -7,10 +7,40 @@ import BaseDiff from '../evolver/BaseDiff';
import BaseHistory from '../evolver/BaseHistory'; import BaseHistory from '../evolver/BaseHistory';
import LottoActivityTimeline from '../evolver/LottoActivityTimeline'; import LottoActivityTimeline from '../evolver/LottoActivityTimeline';
import EvolverActions from '../evolver/EvolverActions'; import EvolverActions from '../evolver/EvolverActions';
import TrackRecordCard from '../evolver/TrackRecordCard';
import CalibrationChart from '../evolver/CalibrationChart';
import WinnerAnalysisCard from '../evolver/WinnerAnalysisCard';
import { getLatest, lottoBacktestTrackRecord, lottoBacktestCalibration, lottoBacktestReview } from '../../../api';
export default function EvolverTab() { export default function EvolverTab() {
const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 }); const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 });
const [trackRecord, setTrackRecord] = useState(null);
const [calibHistory, setCalibHistory] = useState([]);
const [winnerAnalysis, setWinnerAnalysis] = useState(null);
useEffect(() => {
(async () => {
try {
const [tr, cal] = await Promise.all([
lottoBacktestTrackRecord(),
lottoBacktestCalibration(52),
]);
setTrackRecord(tr);
setCalibHistory(cal.history || []);
} catch (_) { /* 백엔드 미준비 시 graceful skip */ }
try {
const latest = await getLatest();
const drawNo = latest?.drawNo || latest?.drw_no || latest?.draw_no;
if (drawNo) {
const review = await lottoBacktestReview(drawNo);
setWinnerAnalysis(review.winner_analysis || null);
}
} catch (_) { /* 아직 데이터 없으면 null 유지 */ }
})();
}, []);
if (loading) return <div className="lotto-evolver"><p className="lotto-evolver-muted">로딩 ...</p></div>; if (loading) return <div className="lotto-evolver"><p className="lotto-evolver-muted">로딩 ...</p></div>;
if (error) return <div className="lotto-evolver"><p className="lotto-evolver-muted">에러: {String(error)}</p></div>; if (error) return <div className="lotto-evolver"><p className="lotto-evolver-muted">에러: {String(error)}</p></div>;
@@ -73,6 +103,16 @@ export default function EvolverTab() {
<EvolverActions onChange={refetch} /> <EvolverActions onChange={refetch} />
</> </>
)} )}
{/* 백테스트 성적표 · 캘리브레이션 · 당첨조합 분석 */}
{(winnerAnalysis || trackRecord || calibHistory.length > 0) && (
<>
<p className="backtest-section-header">백테스트 &amp; 캘리브레이션</p>
<WinnerAnalysisCard analysis={winnerAnalysis} />
<TrackRecordCard byStrategy={trackRecord?.by_strategy} />
<CalibrationChart history={calibHistory} />
</>
)}
</div> </div>
); );
} }

View File

@@ -3016,3 +3016,219 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ══════════════════════════════════════════════════════
Holdings Intelligence Tab
══════════════════════════════════════════════════════ */
.hi-panel {
/* reuses stock-panel--wide layout */
}
/* ── 포트 건강 요약 줄 ── */
.hi-health {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 0;
font-size: 13px;
color: #94a3b8;
background: rgba(148, 163, 184, 0.06);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
}
.hi-health__sep {
margin: 0 8px;
color: rgba(148, 163, 184, 0.4);
}
.hi-health__pnl {
font-weight: 700;
font-size: 14px;
}
.hi-health__pnl.is-up { color: #22c55e; }
.hi-health__pnl.is-down { color: #ef4444; }
/* ── 분석 기준일 ── */
.hi-date {
font-size: 11px;
color: #64748b;
margin: 0 0 12px;
}
/* ── 카드 그리드 ── */
.hi-cards {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
/* ── 개별 카드 ── */
.hi-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hi-card__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hi-action-badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.hi-card__name {
font-size: 14px;
font-weight: 700;
color: #e2e8f0;
}
.hi-card__ticker {
font-size: 11px;
color: #64748b;
font-family: monospace;
}
.hi-card__pnl {
margin-left: auto;
font-size: 13px;
font-weight: 700;
}
.hi-card__pnl.is-up { color: #22c55e; }
.hi-card__pnl.is-down { color: #ef4444; }
.hi-card__close {
font-size: 11px;
color: #94a3b8;
margin-right: 4px;
}
.hi-card__reasons {
font-size: 12px;
color: #94a3b8;
line-height: 1.5;
}
/* ── 기술강도 미니 바 ── */
.hi-card__score {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: #64748b;
}
.hi-card__score strong {
color: #93c5fd;
font-size: 12px;
}
.hi-score-bar {
flex: 1;
height: 4px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
position: relative;
overflow: hidden;
max-width: 120px;
}
.hi-score-bar::after {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: var(--score, 0%);
background: #93c5fd;
border-radius: 2px;
transition: width 0.4s ease;
}
/* ── 이슈 목록 ── */
.hi-card__issues {
display: flex;
flex-direction: column;
gap: 4px;
}
.hi-issue {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 11px;
line-height: 1.5;
}
.hi-issue__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 4px;
}
/* ── 빈 상태 ── */
.hi-empty {
text-align: center;
padding: 48px 16px;
color: #64748b;
}
.hi-empty__icon {
font-size: 36px;
display: block;
margin-bottom: 12px;
}
.hi-empty__sub {
font-size: 12px;
margin-top: 6px;
color: #475569;
}
/* ── 면책 고지 ── */
.hi-disclaimer {
font-size: 11px;
color: #475569;
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
/* ── 탭 버튼 (holdings intel) ── */
.stock-main-tab--holdings-intel {
/* reuses stock-main-tab base styles */
}
@media (max-width: 640px) {
.hi-card__head {
gap: 6px;
}
.hi-health {
font-size: 12px;
}
.hi-score-bar {
display: none;
}
}

View File

@@ -6,7 +6,7 @@ import SwipeableView from '../../components/SwipeableView';
import { import {
formatNumber, formatPercent, formatNumber, formatPercent,
toNumeric, profitColorClass, toNumeric, profitColorClass,
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL,
} from './stockUtils'; } from './stockUtils';
/* ── hooks ──────────────────────────────────────────────────────── */ /* ── hooks ──────────────────────────────────────────────────────── */
@@ -22,6 +22,7 @@ import useAdvisor from './hooks/useAdvisor';
import PortfolioTab from './components/PortfolioTab'; import PortfolioTab from './components/PortfolioTab';
import ReportTab from './components/ReportTab'; import ReportTab from './components/ReportTab';
import AdvisorTab from './components/AdvisorTab'; import AdvisorTab from './components/AdvisorTab';
import HoldingsIntelTab from './components/HoldingsIntelTab';
import SellHistoryDrawer from './components/SellHistoryDrawer'; import SellHistoryDrawer from './components/SellHistoryDrawer';
/* ── component ───────────────────────────────────────────────────── */ /* ── component ───────────────────────────────────────────────────── */
@@ -30,8 +31,8 @@ const StockTrade = () => {
const [activeTab, setActiveTab] = React.useState(TAB_REPORT); const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR]; const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL];
const tabLabels = ['포트폴리오', '리포트', '어드바이저']; const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔'];
const tabIndex = TAB_ORDER.indexOf(activeTab); const tabIndex = TAB_ORDER.indexOf(activeTab);
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -166,7 +167,9 @@ const StockTrade = () => {
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} /> ? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
: tabId === TAB_REPORT : tabId === TAB_REPORT
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} /> ? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
: <AdvisorTab pf={pf} advisor={advisor} />, : tabId === TAB_ADVISOR
? <AdvisorTab pf={pf} advisor={advisor} />
: <HoldingsIntelTab />,
}))} }))}
activeIndex={tabIndex} activeIndex={tabIndex}
onTabChange={handleTabChange} onTabChange={handleTabChange}
@@ -178,6 +181,7 @@ const StockTrade = () => {
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null }, { id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' }, { id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' }, { id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
].map(({ id, icon, label, sub, badge, className: cls }) => ( ].map(({ id, icon, label, sub, badge, className: cls }) => (
<button <button
key={id} key={id}
@@ -198,6 +202,7 @@ const StockTrade = () => {
)} )}
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />} {activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />} {activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
</> </>
)} )}

View File

@@ -0,0 +1,155 @@
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>
{h.close != null && (
<span className="hi-card__close">{h.close.toLocaleString()}</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;

View File

@@ -149,3 +149,4 @@ export const computeBrokerSummary = (items) => {
export const TAB_PORTFOLIO = 'portfolio'; export const TAB_PORTFOLIO = 'portfolio';
export const TAB_REPORT = 'report'; export const TAB_REPORT = 'report';
export const TAB_ADVISOR = 'advisor'; export const TAB_ADVISOR = 'advisor';
export const TAB_HOLDINGS_INTEL = 'holdings_intel';