UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성
This commit is contained in:
@@ -7,9 +7,17 @@ import {
|
||||
addPortfolio,
|
||||
updatePortfolio,
|
||||
deletePortfolio,
|
||||
upsertCash,
|
||||
deleteCash,
|
||||
getFearAndGreed,
|
||||
} from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import './Stock.css';
|
||||
import {
|
||||
PieChart, Pie, Cell,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -73,6 +81,28 @@ const toNumeric = (value) => {
|
||||
return Number.isNaN(numeric) ? null : numeric;
|
||||
};
|
||||
|
||||
/* ── Fear & Greed helpers ──────────────────────────────────────── */
|
||||
|
||||
const getFgColor = (score) => {
|
||||
if (score <= 25) return '#ef4444';
|
||||
if (score <= 45) return '#f97316';
|
||||
if (score <= 55) return '#eab308';
|
||||
if (score <= 75) return '#84cc16';
|
||||
return '#22c55e';
|
||||
};
|
||||
|
||||
const getFgLabel = (score) => {
|
||||
if (score <= 25) return '극단적 공포';
|
||||
if (score <= 45) return '공포';
|
||||
if (score <= 55) return '중립';
|
||||
if (score <= 75) return '탐욕';
|
||||
return '극단적 탐욕';
|
||||
};
|
||||
|
||||
/* ── Chart colors ──────────────────────────────────────────────── */
|
||||
|
||||
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||
|
||||
const profitColorClass = (numericValue) => {
|
||||
if (numericValue > 0) return 'is-up';
|
||||
if (numericValue < 0) return 'is-down';
|
||||
@@ -94,6 +124,7 @@ const emptyPortfolioForm = {
|
||||
|
||||
const TAB_PORTFOLIO = 'portfolio';
|
||||
const TAB_AI = 'ai';
|
||||
const TAB_REPORT = 'report';
|
||||
|
||||
/* ── component ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -124,6 +155,30 @@ const StockTrade = () => {
|
||||
/* Portfolio delete */
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
|
||||
/* Cash (예수금) form */
|
||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||
const [cashSaving, setCashSaving] = useState(false);
|
||||
const [cashError, setCashError] = useState('');
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* 리포트 탭 state */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
const [reportSortField, setReportSortField] = useState('profit_rate');
|
||||
const [reportSortDir, setReportSortDir] = useState('desc');
|
||||
|
||||
/* Fear & Greed */
|
||||
const [fgData, setFgData] = useState(null);
|
||||
const [fgLoading, setFgLoading] = useState(false);
|
||||
const [fgError, setFgError] = useState('');
|
||||
const [fgLoaded, setFgLoaded] = useState(false);
|
||||
|
||||
/* AI Coach */
|
||||
const [aiApiKey, setAiApiKey] = useState('');
|
||||
const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001');
|
||||
const [aiResult, setAiResult] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState('');
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* AI 투자 (Balance) state */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
@@ -160,6 +215,23 @@ const StockTrade = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFearAndGreed = useCallback(async () => {
|
||||
setFgLoading(true);
|
||||
setFgError('');
|
||||
try {
|
||||
const data = await getFearAndGreed();
|
||||
const fg = data?.fear_and_greed ?? data;
|
||||
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
|
||||
if (isNaN(score)) throw new Error('지수 데이터 형식 오류');
|
||||
setFgData({ score, rating: fg.rating ?? '', timestamp: fg.timestamp ?? null });
|
||||
setFgLoaded(true);
|
||||
} catch (err) {
|
||||
setFgError('F&G 지수 조회 실패: ' + (err?.message ?? String(err)));
|
||||
} finally {
|
||||
setFgLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBalance = useCallback(async () => {
|
||||
setBalanceLoading(true);
|
||||
setBalanceError('');
|
||||
@@ -180,9 +252,31 @@ const StockTrade = () => {
|
||||
loadPortfolio();
|
||||
} else if (activeTab === TAB_AI && !balanceLoaded) {
|
||||
loadBalance();
|
||||
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
|
||||
loadPortfolio();
|
||||
}
|
||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
||||
|
||||
/* Fear & Greed: 리포트 탭 첫 진입 시 자동 로드 */
|
||||
useEffect(() => {
|
||||
if (activeTab === TAB_REPORT && !fgLoaded) {
|
||||
loadFearAndGreed();
|
||||
}
|
||||
}, [activeTab, fgLoaded, loadFearAndGreed]);
|
||||
|
||||
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
|
||||
useEffect(() => {
|
||||
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
|
||||
const savedModel = localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001';
|
||||
setAiApiKey(savedKey);
|
||||
setAiModel(savedModel);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const cached = localStorage.getItem(`ai_coach_${today}`);
|
||||
if (cached) {
|
||||
try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
|
||||
useEffect(() => {
|
||||
if (activeTab !== TAB_PORTFOLIO) return;
|
||||
@@ -277,6 +371,126 @@ const StockTrade = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── cash (예수금) actions ──────────────────────────────────── */
|
||||
|
||||
const handleCashSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!cashForm.broker.trim() || cashForm.cash === '') return;
|
||||
setCashSaving(true);
|
||||
setCashError('');
|
||||
try {
|
||||
await upsertCash(cashForm.broker.trim(), Number(cashForm.cash));
|
||||
setCashForm({ broker: '', cash: '' });
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
setCashError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setCashSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashDelete = async (broker) => {
|
||||
try {
|
||||
await deleteCash(broker);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
alert('예수금 삭제 실패: ' + (err?.message ?? String(err)));
|
||||
}
|
||||
};
|
||||
|
||||
/* ── report sort ─────────────────────────────────────────────── */
|
||||
|
||||
const handleReportSort = (field) => {
|
||||
if (reportSortField === field) {
|
||||
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setReportSortField(field);
|
||||
setReportSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── AI coach ────────────────────────────────────────────────── */
|
||||
|
||||
const handleAiCoach = async () => {
|
||||
if (!aiApiKey.trim() || portfolioHoldings.length === 0) return;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const cacheKey = `ai_coach_${today}`;
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid cache */ }
|
||||
}
|
||||
|
||||
setAiLoading(true);
|
||||
setAiError('');
|
||||
|
||||
const holdingsText = portfolioHoldings
|
||||
.map((item) =>
|
||||
`- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오를 분석하여 JSON으로만 답하세요.
|
||||
|
||||
분석 일자: ${today}
|
||||
총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||
총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||
총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||
예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||
총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||
보유 종목 수: ${portfolioHoldings.length}개
|
||||
보유 종목:
|
||||
${holdingsText}
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
|
||||
{
|
||||
"score": 85,
|
||||
"grade": "A",
|
||||
"summary": "30자 이내 한줄 평가",
|
||||
"evaluation": "200자 이내 상세 평가",
|
||||
"advice": [
|
||||
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
|
||||
]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': aiApiKey.trim(),
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Claude API 오류 (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data.content?.[0]?.text ?? '';
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.');
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
const final = { ...result, generated_at: new Date().toISOString(), cached: false };
|
||||
localStorage.setItem(cacheKey, JSON.stringify(final));
|
||||
setAiResult(final);
|
||||
} catch (err) {
|
||||
setAiError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── manual order ────────────────────────────────────────────── */
|
||||
|
||||
const submitManualOrder = async (event) => {
|
||||
@@ -327,6 +541,9 @@ const StockTrade = () => {
|
||||
|
||||
const portfolioHoldings = portfolio?.holdings ?? [];
|
||||
const portfolioSummary = portfolio?.summary ?? {};
|
||||
const cashList = portfolio?.cash ?? [];
|
||||
const totalCash = portfolioSummary.total_cash ?? null;
|
||||
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||
const brokerGroups = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of portfolioHoldings) {
|
||||
@@ -370,6 +587,62 @@ const StockTrade = () => {
|
||||
return map;
|
||||
}, [brokerGroups]);
|
||||
|
||||
/* ── derived: Report ──────────────────────────────────────────── */
|
||||
|
||||
const brokerPieData = useMemo(() =>
|
||||
brokerGroups
|
||||
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
|
||||
.filter((d) => d.value > 0),
|
||||
[brokerGroups]
|
||||
);
|
||||
|
||||
const profitBarData = useMemo(() =>
|
||||
portfolioHoldings
|
||||
.filter((item) => item.profit_rate != null)
|
||||
.map((item) => ({
|
||||
name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5),
|
||||
fullName: item.name ?? item.ticker ?? 'N/A',
|
||||
rate: toNumeric(item.profit_rate) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate),
|
||||
[portfolioHoldings]
|
||||
);
|
||||
|
||||
const maxAbsRate = useMemo(() =>
|
||||
Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))),
|
||||
[portfolioHoldings]
|
||||
);
|
||||
|
||||
const sortedHoldings = useMemo(() => {
|
||||
const getVal = (item) => {
|
||||
switch (reportSortField) {
|
||||
case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity;
|
||||
case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity;
|
||||
case 'eval_amount': {
|
||||
const ea = toNumeric(item.eval_amount);
|
||||
if (ea != null) return ea;
|
||||
const cp = toNumeric(item.current_price);
|
||||
const qty = toNumeric(item.quantity);
|
||||
return cp != null && qty != null ? cp * qty : -Infinity;
|
||||
}
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
return [...portfolioHoldings].sort((a, b) => {
|
||||
if (reportSortField === 'name')
|
||||
return reportSortDir === 'asc'
|
||||
? (a.name ?? '').localeCompare(b.name ?? '')
|
||||
: (b.name ?? '').localeCompare(a.name ?? '');
|
||||
if (reportSortField === 'broker')
|
||||
return reportSortDir === 'asc'
|
||||
? (a.broker ?? '').localeCompare(b.broker ?? '')
|
||||
: (b.broker ?? '').localeCompare(a.broker ?? '');
|
||||
const av = getVal(a);
|
||||
const bv = getVal(b);
|
||||
return reportSortDir === 'asc' ? av - bv : bv - av;
|
||||
});
|
||||
}, [portfolioHoldings, reportSortField, reportSortDir]);
|
||||
|
||||
/* ── render ───────────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
@@ -390,11 +663,9 @@ const StockTrade = () => {
|
||||
</div>
|
||||
<div className="stock-card">
|
||||
<p className="stock-card__title">
|
||||
{activeTab === TAB_PORTFOLIO
|
||||
? '쟁승토리 계좌 요약'
|
||||
: 'AI 투자 요약'}
|
||||
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
|
||||
</p>
|
||||
{activeTab === TAB_PORTFOLIO ? (
|
||||
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
|
||||
/* Portfolio summary */
|
||||
<div className="stock-status">
|
||||
<div>
|
||||
@@ -424,6 +695,22 @@ const StockTrade = () => {
|
||||
<span>보유 종목</span>
|
||||
<strong>{portfolioHoldings.length}</strong>
|
||||
</div>
|
||||
{totalCash != null && (
|
||||
<div>
|
||||
<span>예수금 합계</span>
|
||||
<strong style={{ color: '#93c5fd' }}>
|
||||
{formatNumber(totalCash)}원
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{totalAssets != null && (
|
||||
<div>
|
||||
<span>총 자산</span>
|
||||
<strong style={{ fontWeight: 700 }}>
|
||||
{formatNumber(totalAssets)}원
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* AI balance summary */
|
||||
@@ -472,6 +759,15 @@ const StockTrade = () => {
|
||||
<span className="stock-main-tab__label">AI 투자</span>
|
||||
<span className="stock-main-tab__sub">모의투자</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-main-tab ${activeTab === TAB_REPORT ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveTab(TAB_REPORT)}
|
||||
>
|
||||
<span className="stock-main-tab__icon">📊</span>
|
||||
<span className="stock-main-tab__label">리포트</span>
|
||||
<span className="stock-main-tab__sub">분석·AI코치</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
@@ -612,10 +908,102 @@ const StockTrade = () => {
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
{totalCash != null && (
|
||||
<div className="pf-total-summary__card is-cash">
|
||||
<span>예수금 합계</span>
|
||||
<strong>{formatNumber(totalCash)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
{totalAssets != null && (
|
||||
<div className="pf-total-summary__card is-assets">
|
||||
<span>총 자산</span>
|
||||
<strong>{formatNumber(totalAssets)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 예수금 패널 */}
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">예수금 관리</p>
|
||||
<h3>증권사별 예수금</h3>
|
||||
<p className="stock-panel__sub">
|
||||
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cashList.length > 0 && (
|
||||
<div className="pf-cash-table">
|
||||
{cashList.map((item) => (
|
||||
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||
<span className="pf-cash-broker">{item.broker}</span>
|
||||
<strong className="pf-cash-amount">
|
||||
{formatNumber(item.cash)}원
|
||||
</strong>
|
||||
<span className="pf-cash-date">
|
||||
{item.updated_at
|
||||
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => handleCashDelete(item.broker)}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cashList.length === 0 && (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||
등록된 예수금이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="pf-cash-form" onSubmit={handleCashSave}>
|
||||
<label>
|
||||
증권사명
|
||||
<input
|
||||
type="text"
|
||||
value={cashForm.broker}
|
||||
onChange={(e) =>
|
||||
setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||
}
|
||||
placeholder="KB증권"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
예수금 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={cashForm.cash}
|
||||
onChange={(e) =>
|
||||
setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||
}
|
||||
placeholder="1500000"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={cashSaving}
|
||||
>
|
||||
{cashSaving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
{cashError && <p className="stock-error">{cashError}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Broker cards stacked */}
|
||||
{brokerGroups.map(([broker, items]) => {
|
||||
const bSummary = getBrokerSummary(items);
|
||||
@@ -649,6 +1037,16 @@ const StockTrade = () => {
|
||||
{formatNumber(bSummary.totalProfit)} (
|
||||
{formatPercent(bSummary.totalProfitRate)})
|
||||
</span>
|
||||
{(() => {
|
||||
const bc = cashList.find(
|
||||
(c) => c.broker === broker
|
||||
);
|
||||
return bc ? (
|
||||
<span className="pf-cash-badge">
|
||||
예수금 {formatNumber(bc.cash)}원
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1044,6 +1442,344 @@ const StockTrade = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
TAB 3: 리포트 + AI 코치
|
||||
════════════════════════════════════════════════════════ */}
|
||||
{activeTab === TAB_REPORT && (
|
||||
<>
|
||||
{portfolioLoading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||
</div>
|
||||
)}
|
||||
{portfolioError && <p className="stock-error">{portfolioError}</p>}
|
||||
|
||||
{/* ── Fear & Greed Index ─────────────────────────── */}
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">시장 심리 지표</p>
|
||||
<h3>Fear & Greed Index</h3>
|
||||
<p className="stock-panel__sub">
|
||||
CNN Fear & Greed 지수로 현재 시장 심리를 파악합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={loadFearAndGreed}
|
||||
disabled={fgLoading}
|
||||
>
|
||||
{fgLoading ? '조회 중...' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{fgError && <p className="stock-error">{fgError}</p>}
|
||||
{fgData ? (
|
||||
<div className="fg-panel">
|
||||
<div className="fg-gauge">
|
||||
<div className="fg-gauge__track">
|
||||
<div
|
||||
className="fg-gauge__needle"
|
||||
style={{ left: `${Math.min(100, Math.max(0, fgData.score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="fg-gauge__labels">
|
||||
<span>극단적 공포</span>
|
||||
<span>공포</span>
|
||||
<span>중립</span>
|
||||
<span>탐욕</span>
|
||||
<span>극단적 탐욕</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fg-score-display">
|
||||
<span className="fg-score-number" style={{ color: getFgColor(fgData.score) }}>
|
||||
{Math.round(fgData.score)}
|
||||
</span>
|
||||
<span className="fg-score-label" style={{ color: getFgColor(fgData.score) }}>
|
||||
{getFgLabel(fgData.score)}
|
||||
</span>
|
||||
{fgData.timestamp && (
|
||||
<span className="fg-score-date">
|
||||
{new Date(fgData.timestamp).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : !fgError ? (
|
||||
<p className="stock-empty">지수 데이터를 불러오는 중...</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
|
||||
{portfolioHoldings.length > 0 && (
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
|
||||
<h3>자산 배분 현황</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="report-charts-row">
|
||||
<div className="report-chart-box">
|
||||
<p className="report-chart-title">증권사별 자산 배분</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={brokerPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={52}
|
||||
outerRadius={84}
|
||||
dataKey="value"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{brokerPieData.map((_, i) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
|
||||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="report-chart-box">
|
||||
<p className="report-chart-title">종목별 수익률 (%)</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||
angle={-40}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
|
||||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
|
||||
{profitBarData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
|
||||
{portfolioHoldings.length > 0 && (
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">수익률 랭킹</p>
|
||||
<h3>종목별 상세 현황</h3>
|
||||
<p className="stock-panel__sub">헤더 클릭으로 정렬</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="report-table-wrapper">
|
||||
<table className="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
{ key: 'name', label: '종목명' },
|
||||
{ key: 'broker', label: '증권사' },
|
||||
{ key: 'profit_rate', label: '수익률' },
|
||||
{ key: 'profit_amount', label: '평가손익' },
|
||||
{ key: 'eval_amount', label: '평가금액' },
|
||||
].map(({ key, label }) => (
|
||||
<th key={key} onClick={() => handleReportSort(key)}>
|
||||
{label}{' '}
|
||||
<span className="report-sort-icon">
|
||||
{reportSortField === key
|
||||
? reportSortDir === 'asc' ? '↑' : '↓'
|
||||
: '↕'}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHoldings.map((item) => {
|
||||
const rateN = toNumeric(item.profit_rate);
|
||||
const pnlN = toNumeric(item.profit_amount);
|
||||
const evalAmt = item.eval_amount != null
|
||||
? item.eval_amount
|
||||
: item.current_price != null
|
||||
? item.current_price * item.quantity
|
||||
: null;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
|
||||
<span className="report-table-code">{item.ticker ?? ''}</span>
|
||||
</td>
|
||||
<td className="report-td-muted">{item.broker ?? '-'}</td>
|
||||
<td className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||
<div className="report-rate-cell">
|
||||
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
|
||||
{rateN != null && (
|
||||
<div className="report-rate-bar">
|
||||
<div
|
||||
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
|
||||
style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
|
||||
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
|
||||
</td>
|
||||
<td className="report-td-muted">
|
||||
{evalAmt != null ? formatNumber(evalAmt) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||||
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> 탭에서 종목을 먼저 등록하세요.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── AI 투자 코치 ───────────────────────────────── */}
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">AI 투자 코치</p>
|
||||
<h3>오늘의 투자 평가</h3>
|
||||
<p className="stock-panel__sub">
|
||||
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key 설정 */}
|
||||
<div className="ai-coach-settings">
|
||||
<label>
|
||||
Anthropic API Key
|
||||
<div className="ai-coach-key-row">
|
||||
<input
|
||||
type="password"
|
||||
className="ai-coach-key-input"
|
||||
value={aiApiKey}
|
||||
onChange={(e) => setAiApiKey(e.target.value)}
|
||||
placeholder="sk-ant-api03-..."
|
||||
/>
|
||||
<button
|
||||
className="button ghost small"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.setItem('ai_coach_key', aiApiKey);
|
||||
localStorage.setItem('ai_coach_model', aiModel);
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
AI 모델
|
||||
<select
|
||||
value={aiModel}
|
||||
onChange={(e) => {
|
||||
setAiModel(e.target.value);
|
||||
localStorage.setItem('ai_coach_model', e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
|
||||
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ai-coach-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={handleAiCoach}
|
||||
disabled={aiLoading || !aiApiKey.trim() || portfolioHoldings.length === 0}
|
||||
>
|
||||
{aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
|
||||
</button>
|
||||
{portfolioHoldings.length === 0 && (
|
||||
<span className="ai-coach-note">종목 등록 후 이용 가능합니다.</span>
|
||||
)}
|
||||
{aiResult?.generated_at && (
|
||||
<span className="ai-coach-note">
|
||||
{aiResult.cached ? '오늘 캐시 결과 · ' : ''}
|
||||
{new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aiError && <p className="stock-error" style={{ marginTop: 8 }}>{aiError}</p>}
|
||||
|
||||
{aiResult && !aiLoading && (
|
||||
<div className="ai-coach-result">
|
||||
<div className="ai-coach-header">
|
||||
<div className={`ai-grade-badge grade-${(aiResult.grade ?? 'c').toLowerCase()}`}>
|
||||
{aiResult.grade ?? '?'}
|
||||
</div>
|
||||
<div className="ai-score-wrap">
|
||||
<span className="ai-score-num">{aiResult.score ?? 0}</span>
|
||||
<span className="ai-score-unit">/ 100</span>
|
||||
</div>
|
||||
<p className="ai-summary-text">{aiResult.summary}</p>
|
||||
</div>
|
||||
<p className="ai-evaluation-text">{aiResult.evaluation}</p>
|
||||
{aiResult.advice?.length > 0 && (
|
||||
<div className="ai-advice-list">
|
||||
{aiResult.advice.map((a, i) => (
|
||||
<div key={i} className="ai-advice-card">
|
||||
<p className="ai-advice-title">{a.title}</p>
|
||||
<p className="ai-advice-body">{a.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button ghost small"
|
||||
type="button"
|
||||
style={{ marginTop: 16, fontSize: 11 }}
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
localStorage.removeItem(`ai_coach_${today}`);
|
||||
setAiResult(null);
|
||||
}}
|
||||
>
|
||||
다시 평가받기 (캐시 삭제)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* KIS modal */}
|
||||
{kisModal ? (
|
||||
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||
|
||||
Reference in New Issue
Block a user