import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
createTradeOrder,
getTradeBalance,
getPortfolio,
addPortfolio,
updatePortfolio,
deletePortfolio,
upsertCash,
deleteCash,
getFearAndGreed,
getVix,
getTreasury10Y,
getWTI,
getAssetHistory,
saveAssetSnapshot,
getSellHistory,
addSellHistory,
updateSellHistory,
deleteSellHistory,
getAiAnalysis,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
import {
PieChart, Pie, Cell,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
AreaChart, Area,
} from 'recharts';
/* ── helpers ─────────────────────────────────────────────────────── */
const formatNumber = (value) => {
if (value === null || value === undefined || value === '') return '-';
const numeric = Number(value);
if (Number.isNaN(numeric)) return value;
return new Intl.NumberFormat('ko-KR').format(numeric);
};
const formatPercent = (value) => {
if (value === null || value === undefined || value === '') return '-';
if (typeof value === 'string' && value.includes('%')) return value;
const numeric = Number(value);
if (Number.isNaN(numeric)) return value;
return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`;
};
const pickFirst = (...values) =>
values.find((value) => value !== undefined && value !== null && value !== '');
const getQty = (item) =>
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
const getBuyPrice = (item) =>
pickFirst(
item?.buy_price,
item?.avg_price,
item?.avg,
item?.purchase_price,
item?.buyPrice,
item?.price
);
const getCurrentPrice = (item) =>
pickFirst(
item?.current_price,
item?.current,
item?.cur_price,
item?.now_price,
item?.market_price
);
const getProfitRate = (item) =>
pickFirst(
item?.profit_rate,
item?.profitRate,
item?.profit_pct,
item?.profitPercent,
item?.pnl_rate,
item?.return_rate,
item?.yield
);
const getProfitLoss = (item) =>
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
const toNumeric = (value) => {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
return Number.isNaN(numeric) ? null : numeric;
};
/* ── AdvisorMarkdown — 경량 마크다운 렌더러 ──────────────────────── */
const AdvisorMarkdown = ({ text }) => {
if (!text) return null;
const lines = text.split('\n');
const elements = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith('### ')) {
elements.push(
{line.slice(4)} );
} else if (line.startsWith('## ')) {
elements.push({line.slice(3)} );
} else if (line.startsWith('---')) {
elements.push( );
} else if (line.trim() === '') {
elements.push(
);
} else {
// 인라인 마크다운: **bold**, *italic*, 🎯 등 이모지 보존
const rendered = line
.replace(/\*\*(.+?)\*\*/g, '$1 ')
.replace(/\*(.+?)\*/g, '$1 ')
.replace(/`(.+?)`/g, '$1');
elements.push(
);
}
i++;
}
return {elements}
;
};
/* ── 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';
if (numericValue === 0) return 'is-flat';
return '';
};
const getVixLabel = (vix) => {
if (vix < 12) return '극히 낮음 (안일 주의)';
if (vix < 20) return '정상 (안정적)';
if (vix < 30) return '주의 (불확실성 증가)';
if (vix < 40) return '높음 (극도의 공포)';
return '극단 (패닉)';
};
const getFgLabel = (score) => {
if (score <= 25) return '극단적 공포';
if (score <= 45) return '공포';
if (score <= 55) return '중립';
if (score <= 75) return '탐욕';
return '극단적 탐욕';
};
/* ── empty portfolio form ────────────────────────────────────────── */
const emptyPortfolioForm = {
broker: '',
ticker: '',
name: '',
quantity: '',
avg_price: '',
};
/* ── empty sell-history form ─────────────────────────────────────── */
const toLocalDatetimeValue = (isoStr) => {
if (!isoStr) return '';
const d = new Date(isoStr);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const emptySellForm = () => ({
broker: '',
ticker: '',
name: '',
quantity: '',
avg_price: '',
sell_price: '',
commission: '',
sold_at: toLocalDatetimeValue(new Date().toISOString()),
});
/* ── TAB IDs ─────────────────────────────────────────────────────── */
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
const TAB_REPORT = 'report';
const TAB_ADVISOR = 'advisor';
/* ── component ───────────────────────────────────────────────────── */
const StockTrade = () => {
/* Active tab */
const [activeTab, setActiveTab] = useState(TAB_REPORT);
/* ────────────────────────────────────────────────────────────── */
/* 쟁승토리 계좌 (Portfolio) state */
/* ────────────────────────────────────────────────────────────── */
const [portfolio, setPortfolio] = useState(null);
const [portfolioLoading, setPortfolioLoading] = useState(false);
const [portfolioError, setPortfolioError] = useState('');
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
/* Portfolio add form */
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
const [addFormOpen, setAddFormOpen] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState('');
/* Portfolio edit */
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const [editLoading, setEditLoading] = useState(false);
const editOrigRef = useRef({});
/* Portfolio delete */
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* Portfolio sell */
const [sellConfirmId, setSellConfirmId] = useState(null);
const [sellLoading, setSellLoading] = useState(false);
/* 실현손익 내역 */
const [sellHistory, setSellHistory] = useState([]);
const [sellHistoryLoading, setSellHistoryLoading] = useState(false);
const [sellHistoryBroker, setSellHistoryBroker] = useState('ALL');
const [sellHistoryPeriod, setSellHistoryPeriod] = useState('3M');
/* 실현손익 드로어 */
const [sellDrawerOpen, setSellDrawerOpen] = useState(false);
/* 실현손익 수동 추가/수정 폼 */
const [sellFormOpen, setSellFormOpen] = useState(false);
const [sellEditId, setSellEditId] = useState(null); // null = 추가, number = 수정 중 id
const [sellForm, setSellForm] = useState(emptySellForm());
const [sellFormSaving, setSellFormSaving] = useState(false);
const [sellFormError, setSellFormError] = useState('');
/* AI 전문가 분석 (Gemini Pro Advisor) */
const [advisorData, setAdvisorData] = useState(null); // { analysis, generated_at, cached, holdings_count }
const [advisorLoading, setAdvisorLoading] = useState(false);
const [advisorError, setAdvisorError] = useState('');
/* Cash (예수금) form */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
const [cashSaving, setCashSaving] = useState(false);
const [cashError, setCashError] = useState('');
/* Cash inline edit */
const [cashEditingBroker, setCashEditingBroker] = useState(null);
const [cashEditingValue, setCashEditingValue] = useState('');
const [cashEditSaving, setCashEditSaving] = useState(false);
/* ────────────────────────────────────────────────────────────── */
/* 자산 추이 state */
/* ────────────────────────────────────────────────────────────── */
const [assetHistory, setAssetHistory] = useState(null);
const [assetHistoryLoading, setAssetHistoryLoading] = useState(false);
const [assetHistoryDays, setAssetHistoryDays] = useState(30);
const [snapshotSaving, setSnapshotSaving] = useState(false);
/* ────────────────────────────────────────────────────────────── */
/* 리포트 탭 state */
/* ────────────────────────────────────────────────────────────── */
const [reportSortField, setReportSortField] = useState('profit_rate');
const [reportSortDir, setReportSortDir] = useState('desc');
/* 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('');
const [marketCtx, setMarketCtx] = useState(null);
/* ────────────────────────────────────────────────────────────── */
/* AI 투자 (Balance) state */
/* ────────────────────────────────────────────────────────────── */
const [balance, setBalance] = useState(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const [balanceError, setBalanceError] = useState('');
const [balanceLoaded, setBalanceLoaded] = useState(false);
/* Manual order state */
const [manualForm, setManualForm] = useState({
code: '',
qty: 1,
price: 0,
type: 'buy',
});
const [manualLoading, setManualLoading] = useState(false);
const [manualError, setManualError] = useState('');
const [manualResult, setManualResult] = useState(null);
const [kisModal, setKisModal] = useState('');
/* ── loaders ─────────────────────────────────────────────────── */
const loadPortfolio = useCallback(async () => {
setPortfolioLoading(true);
setPortfolioError('');
try {
const data = await getPortfolio();
setPortfolio(data);
setPortfolioLoaded(true);
} catch (err) {
setPortfolioError(err?.message ?? String(err));
} finally {
setPortfolioLoading(false);
}
}, []);
const loadSellHistory = useCallback(async () => {
setSellHistoryLoading(true);
try {
const data = await getSellHistory();
setSellHistory(data?.records ?? (Array.isArray(data) ? data : []));
} catch {
/* 백엔드 미구현 시 빈 배열 유지 */
} finally {
setSellHistoryLoading(false);
}
}, []);
const loadAdvisorAnalysis = useCallback(async (force = false) => {
setAdvisorLoading(true);
setAdvisorError('');
try {
const data = await getAiAnalysis(force);
if (data?.error) throw new Error(data.error);
setAdvisorData(data);
} catch (err) {
setAdvisorError(err?.message ?? '분석 중 오류가 발생했습니다.');
} finally {
setAdvisorLoading(false);
}
}, []);
const loadBalance = useCallback(async () => {
setBalanceLoading(true);
setBalanceError('');
try {
const data = await getTradeBalance();
setBalance(data);
setBalanceLoaded(true);
} catch (err) {
setBalanceError(err?.message ?? String(err));
} finally {
setBalanceLoading(false);
}
}, []);
const loadAssetHistory = useCallback(async (days) => {
setAssetHistoryLoading(true);
try {
const data = await getAssetHistory(days);
// 백엔드 응답 키: snapshots 또는 history 모두 허용
const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []);
// 날짜 → total_assets 맵
const byDate = {};
for (const item of raw) {
byDate[item.date] = item.total_assets ?? 0;
}
// days > 0: 오늘 기준으로 days일치 전체 날짜 생성 후 없는 날은 0 채움
// days = 0(전체): 받은 데이터만 날짜순 정렬
const toLocalDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
let filled;
if (days > 0) {
const today = new Date();
filled = Array.from({ length: days }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - (days - 1 - i));
const dateStr = toLocalDate(d);
const val = byDate[dateStr];
return val > 0 ? { date: dateStr, total_assets: val } : null;
}).filter(Boolean);
} else {
filled = Object.entries(byDate)
.filter(([, total_assets]) => total_assets > 0)
.map(([date, total_assets]) => ({ date, total_assets }))
.sort((a, b) => a.date.localeCompare(b.date));
}
setAssetHistory(filled);
} catch {
setAssetHistory([]);
} finally {
setAssetHistoryLoading(false);
}
}, []);
const handleSaveSnapshot = async () => {
setSnapshotSaving(true);
try {
await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined);
await loadAssetHistory(assetHistoryDays);
} catch (err) {
alert('스냅샷 저장 실패: ' + (err?.message ?? String(err)));
} finally {
setSnapshotSaving(false);
}
};
/* Lazy load: 탭 전환 시 해당 API만 호출 */
useEffect(() => {
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
loadPortfolio();
loadSellHistory();
} else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio();
} else if (activeTab === TAB_ADVISOR && !advisorData) {
loadAdvisorAnalysis();
}
}, [activeTab, portfolioLoaded, balanceLoaded, advisorData, loadPortfolio, loadBalance, loadSellHistory, loadAdvisorAnalysis]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
useEffect(() => {
if (activeTab === TAB_PORTFOLIO) {
loadAssetHistory(assetHistoryDays);
}
}, [activeTab, assetHistoryDays, loadAssetHistory]);
/* 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 */ }
}
}, []);
/* 리포트 탭 진입 시 시장 컨텍스트(VIX, F&G, 국채, WTI) 한 번 로드 */
useEffect(() => {
if (activeTab !== TAB_REPORT || marketCtx !== null) return;
Promise.allSettled([getFearAndGreed(), getVix(), getTreasury10Y(), getWTI()])
.then(([fg, vix, t, w]) => {
const fgRaw = fg.status === 'fulfilled' ? fg.value : null;
const fgScore = fgRaw?.fear_and_greed?.score ?? fgRaw?.score;
setMarketCtx({
fg: fgScore != null ? Math.round(Number(fgScore)) : null,
vix: vix.status === 'fulfilled' ? (vix.value?.value ?? null) : null,
treasury: t.status === 'fulfilled' ? (t.value?.value ?? null) : null,
wti: w.status === 'fulfilled' ? (w.value?.value ?? null) : null,
});
});
}, [activeTab, marketCtx]);
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
useEffect(() => {
if (activeTab !== TAB_PORTFOLIO) return;
const timer = window.setInterval(loadPortfolio, 180000);
return () => window.clearInterval(timer);
}, [activeTab, loadPortfolio]);
/* ── portfolio actions ───────────────────────────────────────── */
const handleAddSubmit = async (e) => {
e.preventDefault();
setAddLoading(true);
setAddError('');
try {
await addPortfolio({
broker: addForm.broker.trim(),
ticker: addForm.ticker.trim(),
name: addForm.name.trim(),
quantity: Number(addForm.quantity),
avg_price: Number(addForm.avg_price),
});
setAddForm({ ...emptyPortfolioForm });
setAddFormOpen(false);
await loadPortfolio();
} catch (err) {
setAddError(err?.message ?? String(err));
} finally {
setAddLoading(false);
}
};
const handleEditStart = (item) => {
setEditingId(item.id);
setEditForm({
quantity: item.quantity,
avg_price: item.avg_price,
broker: item.broker,
name: item.name,
});
editOrigRef.current = {
quantity: item.quantity,
avg_price: item.avg_price,
broker: item.broker,
name: item.name,
};
};
const handleEditSave = async (id) => {
setEditLoading(true);
try {
const orig = editOrigRef.current ?? {};
const diff = {};
for (const key of Object.keys(editForm)) {
if (editForm[key] !== orig[key]) {
diff[key] = editForm[key];
}
}
if (Object.keys(diff).length === 0) {
setEditingId(null);
return;
}
await updatePortfolio(id, diff);
setEditingId(null);
await loadPortfolio();
} catch (err) {
const msg = err?.message ?? String(err);
if (msg.includes('404') || msg.includes('not found')) {
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
await loadPortfolio();
} else {
alert('수정 실패: ' + msg);
}
} finally {
setEditLoading(false);
}
};
const handleDelete = async (id) => {
try {
await deletePortfolio(id);
setDeleteConfirmId(null);
await loadPortfolio();
} catch (err) {
const msg = err?.message ?? String(err);
if (msg.includes('404') || msg.includes('not found')) {
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
setDeleteConfirmId(null);
await loadPortfolio();
} else {
alert('삭제 실패: ' + msg);
}
}
};
/* ── 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)));
}
};
const handleCashInlineEdit = (item) => {
setCashEditingBroker(item.broker);
setCashEditingValue(String(item.cash ?? ''));
};
const handleCashInlineSave = async (broker) => {
if (cashEditingValue === '') return;
setCashEditSaving(true);
try {
await upsertCash(broker, Number(cashEditingValue));
setCashEditingBroker(null);
setCashEditingValue('');
await loadPortfolio();
} catch (err) {
alert('예수금 수정 실패: ' + (err?.message ?? String(err)));
} finally {
setCashEditSaving(false);
}
};
const handleCashInlineCancel = () => {
setCashEditingBroker(null);
setCashEditingValue('');
};
/* ── sell (현재가 매도) ───────────────────────────────────────── */
const handleSell = async (item) => {
const sellPrice = item.current_price ?? item.avg_price;
const avgPrice = item.avg_price ?? 0;
const qty = item.quantity ?? 0;
const saleAmount = sellPrice * qty;
const buyAmount = avgPrice * qty;
const realizedProfit = saleAmount - buyAmount;
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
const broker = item.broker ?? '';
setSellLoading(true);
try {
// 기존 예수금에 판매금액 합산
const existing = cashList.find((c) => c.broker === broker);
const newCash = (existing?.cash ?? 0) + saleAmount;
await upsertCash(broker, newCash);
await deletePortfolio(item.id);
// 실현손익 기록 저장 (백엔드)
const record = {
broker,
ticker: item.ticker ?? '',
name: item.name ?? item.ticker ?? 'N/A',
quantity: qty,
avg_price: avgPrice,
sell_price: sellPrice,
buy_amount: buyAmount,
sell_amount: saleAmount,
realized_profit: realizedProfit,
realized_rate: realizedRate,
sold_at: new Date().toISOString(),
};
try {
const saved = await addSellHistory(record);
setSellHistory((prev) => [saved ?? record, ...prev]);
} catch {
/* 백엔드 미구현 시 낙관적 UI 유지 */
setSellHistory((prev) => [{ ...record, id: Date.now() }, ...prev]);
}
setSellConfirmId(null);
await loadPortfolio();
} catch (err) {
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
} finally {
setSellLoading(false);
}
};
const handleDeleteSellRecord = async (id) => {
setSellHistory((prev) => prev.filter((r) => r.id !== id));
try {
await deleteSellHistory(id);
} catch {
/* 실패 시 목록 재로드로 복구 */
loadSellHistory();
}
};
/* 수동 추가 폼 열기 */
const handleSellFormOpen = () => {
setSellEditId(null);
setSellForm(emptySellForm());
setSellFormError('');
setSellFormOpen(true);
};
/* 수정 폼 열기 */
const handleSellEditStart = (record) => {
setSellEditId(record.id);
setSellForm({
broker: record.broker ?? '',
ticker: record.ticker ?? '',
name: record.name ?? '',
quantity: String(record.quantity ?? ''),
avg_price: String(record.avg_price ?? ''),
sell_price: String(record.sell_price ?? ''),
commission: String(record.commission ?? ''),
sold_at: toLocalDatetimeValue(record.sold_at),
});
setSellFormError('');
setSellFormOpen(true);
};
/* 폼 닫기 */
const handleSellFormClose = () => {
setSellFormOpen(false);
setSellEditId(null);
setSellFormError('');
};
/* 폼 제출 (추가 or 수정) */
const handleSellFormSubmit = async (e) => {
e.preventDefault();
setSellFormSaving(true);
setSellFormError('');
const qty = Number(sellForm.quantity);
const avgPrice = Number(sellForm.avg_price);
const sellPrice = Number(sellForm.sell_price);
const commission = Number(sellForm.commission) || 0;
const buyAmount = avgPrice * qty;
const sellAmount = sellPrice * qty;
const realizedProfit = sellAmount - buyAmount - commission;
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
const payload = {
broker: sellForm.broker.trim(),
ticker: sellForm.ticker.trim(),
name: sellForm.name.trim(),
quantity: qty,
avg_price: avgPrice,
sell_price: sellPrice,
commission,
buy_amount: buyAmount,
sell_amount: sellAmount,
realized_profit: realizedProfit,
realized_rate: realizedRate,
sold_at: sellForm.sold_at ? new Date(sellForm.sold_at).toISOString() : new Date().toISOString(),
};
try {
if (sellEditId != null) {
const updated = await updateSellHistory(sellEditId, payload);
setSellHistory((prev) =>
prev.map((r) => (r.id === sellEditId ? (updated ?? { ...payload, id: sellEditId }) : r))
);
} else {
const saved = await addSellHistory(payload);
setSellHistory((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
}
handleSellFormClose();
} catch (err) {
setSellFormError(err?.message ?? String(err));
} finally {
setSellFormSaving(false);
}
};
/* ── 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 marketText = marketCtx
? `\n[현재 시장 환경]\nVIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}\nFear & Greed: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}\n미국 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}\nWTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`
: '';
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}${marketText}
반드시 아래 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) => {
event.preventDefault();
setManualLoading(true);
setManualError('');
setManualResult(null);
try {
const payload = {
ticker: manualForm.code.trim(),
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
quantity: Number(manualForm.qty),
price: Number(manualForm.price),
};
const result = await createTradeOrder(payload);
setManualResult(result ?? { ok: true });
if (result?.kis_result !== undefined) {
const message =
typeof result.kis_result === 'string'
? result.kis_result
: JSON.stringify(result.kis_result, null, 2);
setKisModal(message);
}
await loadBalance();
} catch (err) {
setManualError(err?.message ?? String(err));
} finally {
setManualLoading(false);
}
};
/* ── derived: AI balance ──────────────────────────────────────── */
const holdings = useMemo(() => {
if (!balance) return [];
if (Array.isArray(balance.holdings)) return balance.holdings;
if (Array.isArray(balance.positions)) return balance.positions;
if (Array.isArray(balance.items)) return balance.items;
return [];
}, [balance]);
const summary = balance?.summary ?? {};
const totalEval =
summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
const deposit =
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
/* ── derived: Portfolio ───────────────────────────────────────── */
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) {
const broker = item.broker || '기타';
if (!map[broker]) map[broker] = [];
map[broker].push(item);
}
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
}, [portfolioHoldings]);
const getBrokerSummary = (items) => {
let totalBuy = 0;
let totalEvalAmt = 0;
let hasNullPrice = false;
for (const item of items) {
totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0);
if (item.eval_amount != null) {
totalEvalAmt += item.eval_amount;
} else {
hasNullPrice = true;
}
}
const totalProfit = totalEvalAmt - totalBuy;
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
};
const brokerColors = useMemo(() => {
const palette = [
{ border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' },
{ border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' },
{ border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' },
{ border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' },
{ border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' },
{ border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' },
];
const map = {};
brokerGroups.forEach(([broker], i) => {
map[broker] = palette[i % palette.length];
});
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]
);
/* ── derived: 리스크 분산 분석 ────────────────────────────────── */
const brokerConcentration = useMemo(() => {
const totalEval = toNumeric(portfolioSummary.total_eval);
if (!totalEval || totalEval === 0) return [];
return brokerGroups
.map(([broker, items]) => {
const { totalEval: brokerEval } = getBrokerSummary(items);
const ratio = Math.round((brokerEval / totalEval) * 1000) / 10;
return { broker, eval: brokerEval, ratio };
})
.sort((a, b) => b.ratio - a.ratio);
}, [brokerGroups, portfolioSummary.total_eval]); // eslint-disable-line react-hooks/exhaustive-deps
const stockConcentration = useMemo(() => {
const totalEval = toNumeric(portfolioSummary.total_eval);
if (!totalEval || totalEval === 0) return [];
return portfolioHoldings
.map((item) => {
const evalAmt = item.eval_amount != null
? toNumeric(item.eval_amount)
: (item.current_price != null && item.quantity != null)
? toNumeric(item.current_price) * toNumeric(item.quantity)
: null;
if (!evalAmt) return null;
return {
name: item.name ?? item.ticker ?? 'N/A',
ticker: item.ticker ?? '',
eval: evalAmt,
ratio: Math.round((evalAmt / totalEval) * 1000) / 10,
};
})
.filter(Boolean)
.sort((a, b) => b.ratio - a.ratio)
.slice(0, 5);
}, [portfolioHoldings, portfolioSummary.total_eval]);
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]);
/* ── derived: 실현손익 필터 ────────────────────────────────────── */
const sellHistoryBrokers = useMemo(() => {
const set = new Set(sellHistory.map((r) => r.broker).filter(Boolean));
return ['ALL', ...Array.from(set).sort()];
}, [sellHistory]);
const filteredSellHistory = useMemo(() => {
const now = new Date();
const periodMs = {
'1M': 30 * 86400000,
'3M': 90 * 86400000,
'6M': 180 * 86400000,
'1Y': 365 * 86400000,
'ALL': Infinity,
}[sellHistoryPeriod] ?? Infinity;
return sellHistory.filter((r) => {
if (sellHistoryBroker !== 'ALL' && r.broker !== sellHistoryBroker) return false;
const diff = now - new Date(r.sold_at);
return diff <= periodMs;
});
}, [sellHistory, sellHistoryBroker, sellHistoryPeriod]);
const sellHistorySummary = useMemo(() => {
const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
const totalCommission = filteredSellHistory.reduce((s, r) => s + (r.commission ?? 0), 0);
const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length };
}, [filteredSellHistory]);
/* ── render ───────────────────────────────────────────────────── */
return (
{/* ── Header ──────────────────────────────────────────── */}
거래 데스크
거래 데스크
실제 계좌와 AI 모의투자를 한 곳에서 관리하세요.
주식 랩으로 돌아가기
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
/* Portfolio summary */
총 매입
{formatNumber(portfolioSummary.total_buy)}
총 평가
{formatNumber(portfolioSummary.total_eval)}
총 손익
{formatNumber(portfolioSummary.total_profit)}
{portfolioSummary.total_profit_rate != null && (
({formatPercent(portfolioSummary.total_profit_rate)})
)}
보유 종목
{portfolioHoldings.length}
{totalCash != null && (
예수금 합계
{formatNumber(totalCash)}원
)}
{totalAssets != null && (
총 자산
{formatNumber(totalAssets)}원
)}
) : (
/* AI balance summary */
총 평가금액
{formatNumber(totalEval)}
예수금
{formatNumber(deposit)}
보유 종목
{holdings.length}
)}
{activeTab === TAB_AI && summary.note ? (
{summary.note}
) : null}
{/* ── Main Tabs ───────────────────────────────────────── */}
setActiveTab(TAB_PORTFOLIO)}
>
💼
쟁승토리 계좌
{portfolioHoldings.length > 0 && (
{portfolioHoldings.length}
)}
setActiveTab(TAB_AI)}
>
🤖
AI 투자
모의투자
setActiveTab(TAB_REPORT)}
>
📊
리포트
분석·AI코치
setActiveTab(TAB_ADVISOR)}
>
🧠
AI 어드바이저
Gemini Pro
{/* ════════════════════════════════════════════════════════
TAB 1: 쟁승토리 계좌
════════════════════════════════════════════════════════ */}
{activeTab === TAB_PORTFOLIO && (
<>
{portfolioError ? (
{portfolioError}
) : null}
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
포트폴리오
수동 입력 종목 관리
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
{portfolioLoading ? (
) : null}
새로고침
setAddFormOpen((v) => !v)}
>
{addFormOpen ? '취소' : '+ 종목 추가'}
{/* Add form */}
{addFormOpen && (
)}
{/* Portfolio total summary */}
{portfolioHoldings.length > 0 && (
{[
{ label: '총 매입', value: portfolioSummary.total_buy },
{ label: '총 평가', value: portfolioSummary.total_eval },
{ label: '총 손익', value: portfolioSummary.total_profit, isProfit: true },
{ label: '수익률', value: portfolioSummary.total_profit_rate, isRate: true },
].map((s) => (
{s.label}
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
))}
{totalCash != null && (
예수금 합계
{formatNumber(totalCash)}원
)}
{totalAssets != null && (
총 자산
{formatNumber(totalAssets)}원
)}
)}
{/* 자산 추이 차트 */}
총 자산 추이
{[
{ label: '7일', value: 7 },
{ label: '30일', value: 30 },
{ label: '90일', value: 90 },
{ label: '전체', value: 0 },
].map(({ label, value }) => (
setAssetHistoryDays(value)}
>
{label}
))}
{snapshotSaving ? '저장 중...' : '📸 스냅샷'}
{assetHistoryLoading ? (
) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? (
v?.slice(5)}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
[`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']}
/>
) : (
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
)}
{/* 예수금 패널 */}
예수금 관리
증권사별 예수금
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
{cashList.length > 0 && (
{cashList.map((item) => {
const isEditing = cashEditingBroker === item.broker;
return (
{item.broker}
{isEditing ? (
setCashEditingValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCashInlineSave(item.broker);
if (e.key === 'Escape') handleCashInlineCancel();
}}
autoFocus
/>
) : (
{formatNumber(item.cash)}원
)}
{item.updated_at
? new Date(item.updated_at).toLocaleDateString('ko-KR')
: ''}
{isEditing ? (
<>
handleCashInlineSave(item.broker)}
disabled={cashEditSaving}
>
{cashEditSaving ? '저장 중' : '저장'}
취소
>
) : (
<>
handleCashInlineEdit(item)}
title="수정"
>
✏️
handleCashDelete(item.broker)}
title="삭제"
>
🗑️
>
)}
);
})}
)}
{cashList.length === 0 && (
등록된 예수금이 없습니다.
)}
{/* Broker cards stacked */}
{brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items);
const color = brokerColors[broker];
return (
{broker}
{broker} 보유 현황
{items.length}종목 · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '}
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
{(() => {
const bc = cashList.find(
(c) => c.broker === broker
);
return bc ? (
예수금 {formatNumber(bc.cash)}원
) : null;
})()}
{items.map((item) => {
const profitAmt = item.profit_amount;
const profitRate = item.profit_rate;
const profitAmtN = toNumeric(profitAmt);
const profitRateN = toNumeric(profitRate);
const isEditing = editingId === item.id;
const isDeleting = deleteConfirmId === item.id;
const isSelling = sellConfirmId === item.id;
const sellPrice = item.current_price ?? item.avg_price;
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
return (
{isEditing ? (
) : (
<>
{item.name ?? item.ticker ?? 'N/A'}
{item.ticker ?? ''}
수량
{formatNumber(item.quantity)}
매입가
{formatNumber(item.avg_price)}
현재가
{item.current_price != null
? formatNumber(item.current_price)
: '조회 실패'}
평가금액
{item.current_price != null && item.quantity != null
? formatNumber(item.current_price * item.quantity)
: '-'}
수익률
{profitRate != null
? formatPercent(profitRate)
: '-'}
평가손익
{profitAmt != null
? formatNumber(profitAmt)
: '-'}
{!isSelling && !isDeleting && (
handleEditStart(item)}
title="수정"
>
✏️
)}
{isSelling ? (
{item.current_price == null && (
현재가 미조회 — 매입가 기준
)}
{saleAmount != null
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
: '매도 처리'}
handleSell(item)}
disabled={sellLoading}
>
{sellLoading ? '처리 중...' : '매도 확인'}
setSellConfirmId(null)}
disabled={sellLoading}
>
취소
) : isDeleting ? (
<>
handleDelete(item.id)}
>
확인
setDeleteConfirmId(null)}
>
취소
>
) : (
<>
{
setSellConfirmId(item.id);
setDeleteConfirmId(null);
}}
title="매도"
>
매도
{
setDeleteConfirmId(item.id);
setSellConfirmId(null);
}}
title="삭제"
>
🗑️
>
)}
>
)}
);
})}
);
})}
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
등록된 종목이 없습니다. 상단의 + 종목 추가 버튼으로 보유 종목을 등록하세요.
)}
{/* sell history → 드로어로 이동됨 */}
>
)}
{/* ════════════════════════════════════════════════════════
TAB 2: AI 투자 (모의투자)
════════════════════════════════════════════════════════ */}
{activeTab === TAB_AI && (
<>
{balanceError ?
{balanceError}
: null}
{/* AI Balance section */}
AI 모의투자
보유 현황
AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
{balanceLoading ? (
조회 중
) : null}
새로고침
{[
{ label: '총 평가', value: totalEval },
{ label: '예수금', value: deposit },
].map((item) => (
{item.label}
{formatNumber(item.value)}
))}
{holdings.length ? (
{holdings.map((item, idx) => {
const profitLoss = getProfitLoss(item);
const profitLossNumeric = toNumeric(profitLoss);
const profitClass = profitColorClass(profitLossNumeric);
const profitRate = getProfitRate(item);
const profitRateNumeric = toNumeric(profitRate);
const profitRateClass = profitColorClass(profitRateNumeric);
return (
{item.name ?? item.code ?? 'N/A'}
{item.code ?? ''}
수량
{formatNumber(getQty(item))}
매입가
{formatNumber(getBuyPrice(item))}
현재가
{formatNumber(getCurrentPrice(item))}
평가금액
{getCurrentPrice(item) != null && getQty(item) != null
? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item)))
: '-'}
수익률
{formatPercent(profitRate)}
평가손익
{formatNumber(profitLoss)}
);
})}
) : (
보유 종목이 없습니다.
)}
{/* Manual order section */}
수동 주문
직접 매수/매도
종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
>
)}
{/* ════════════════════════════════════════════════════════
TAB 4: AI 어드바이저 (Gemini Pro)
════════════════════════════════════════════════════════ */}
{activeTab === TAB_ADVISOR && (
{/* 헤더 */}
Gemini Pro
AI 전문가 분석
보유 종목 현재가와 오늘의 뉴스를 기반으로 전문가 관점의 매매 조언을 제공합니다.
{advisorData && (
{advisorData.cached ? '🗂 캐시' : '✨ 신규'} ·{' '}
{new Date(advisorData.generated_at).toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit',
})} 기준
)}
loadAdvisorAnalysis(true)}
disabled={advisorLoading}
>
{advisorLoading ? '분석 중...' : '🔄 새로 분석'}
{/* 로딩 */}
{advisorLoading && (
Gemini Pro가 포트폴리오를 분석 중입니다...
현재가 조회 · 뉴스 분석 · 전략 수립
)}
{/* 에러 */}
{!advisorLoading && advisorError && (
⚠️ {advisorError}
loadAdvisorAnalysis(true)}>
다시 시도
)}
{/* 분석 결과 */}
{!advisorLoading && !advisorError && advisorData && (
※ 이 분석은 AI가 생성한 참고 자료이며 투자 결정은 본인의 판단과 책임 하에 이루어져야 합니다.
)}
{/* 초기 상태 */}
{!advisorLoading && !advisorError && !advisorData && (
🧠
아직 분석 결과가 없습니다.
loadAdvisorAnalysis()}>
분석 시작
)}
)}
{/* ════════════════════════════════════════════════════════
TAB 3: 리포트 + AI 코치
════════════════════════════════════════════════════════ */}
{activeTab === TAB_REPORT && (
<>
{portfolioLoading && (
)}
{portfolioError &&
{portfolioError}
}
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
{portfolioHoldings.length > 0 && (
증권사별 자산 배분
{brokerPieData.map((_, i) => (
|
))}
[formatNumber(v) + '원', '평가금액']}
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
/>
{v} }
/>
종목별 수익률 (%)
`${v}%`}
/>
[`${v.toFixed(2)}%`, props.payload.fullName]}
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
/>
{profitBarData.map((entry, i) => (
| = 0 ? '#34d399' : '#f87171'} />
))}
|
)}
{/* ── 리스크 분산 분석 ─────────────────────────────── */}
{portfolioHoldings.length > 0 && portfolioSummary.total_eval != null && (
리스크 관리
분산 분석
증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 시 주의.
{/* 증권사별 집중도 */}
증권사별 집중도
{brokerConcentration.length === 0 ? (
평가금액 데이터 없음
) : (
<>
{brokerConcentration.some((b) => b.ratio > 40) && (
⚠️ 단일 증권사 집중도가 40%를 초과합니다
)}
{brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => {
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
return (
{broker}
{ratio.toFixed(1)}%
{formatNumber(evalAmt)}원
);
})}
>
)}
{/* 종목별 집중도 */}
상위 5 종목 집중도
{stockConcentration.length === 0 ? (
현재가 데이터 없음
) : (
<>
{stockConcentration.some((s) => s.ratio > 40) && (
⚠️ 단일 종목 집중도가 40%를 초과합니다
)}
{stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => {
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
return (
{name}
{ratio.toFixed(1)}%
{ticker && {ticker} }
{formatNumber(evalAmt)}원
);
})}
>
)}
)}
{/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
{portfolioHoldings.length > 0 && (
수익률 랭킹
종목별 상세 현황
헤더 클릭으로 정렬 · 비중은 총 평가금액 대비
{[
{ key: 'name', label: '종목명' },
{ key: 'broker', label: '증권사' },
{ key: 'profit_rate', label: '수익률' },
{ key: 'profit_amount', label: '평가손익' },
{ key: 'eval_amount', label: '평가금액' },
].map(({ key, label }) => (
handleReportSort(key)}>
{label}{' '}
{reportSortField === key
? reportSortDir === 'asc' ? '↑' : '↓'
: '↕'}
))}
비중
{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;
const totalEval = toNumeric(portfolioSummary.total_eval);
const weight = evalAmt != null && totalEval
? Math.round((evalAmt / totalEval) * 1000) / 10
: null;
return (
{item.name ?? item.ticker ?? 'N/A'}
{item.ticker ?? ''}
{item.broker ?? '-'}
{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}
{rateN != null && (
= 0 ? 'is-up' : 'is-down'}`}
style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }}
/>
)}
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
{evalAmt != null ? formatNumber(evalAmt) : '-'}
{weight != null ? `${weight.toFixed(1)}%` : '-'}
);
})}
)}
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
등록된 종목이 없습니다. 쟁승토리 계좌 탭에서 종목을 먼저 등록하세요.
)}
{/* ── AI 투자 코치 ───────────────────────────────── */}
AI 투자 코치
오늘의 투자 평가
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
{/* 시장 컨텍스트 미니 패널 */}
{marketCtx && (
시장 환경
{marketCtx.vix != null && (
VIX {marketCtx.vix}
{getVixLabel(marketCtx.vix)}
)}
{marketCtx.fg != null && (
F&G {marketCtx.fg}
{getFgLabel(marketCtx.fg)}
)}
{marketCtx.treasury != null && (
10년물 {marketCtx.treasury}%
)}
{marketCtx.wti != null && (
WTI ${marketCtx.wti}
)}
)}
{/* API Key 설정 */}
{aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
{portfolioHoldings.length === 0 && (
종목 등록 후 이용 가능합니다.
)}
{aiResult?.generated_at && (
{aiResult.cached ? '오늘 캐시 결과 · ' : ''}
{new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
)}
{aiError && {aiError}
}
{aiResult && !aiLoading && (
{aiResult.grade ?? '?'}
{aiResult.score ?? 0}
/ 100
{aiResult.summary}
{aiResult.evaluation}
{aiResult.advice?.length > 0 && (
{aiResult.advice.map((a, i) => (
))}
)}
{
const today = new Date().toISOString().slice(0, 10);
localStorage.removeItem(`ai_coach_${today}`);
setAiResult(null);
}}
>
다시 평가받기 (캐시 삭제)
)}
>
)}
{/* KIS modal */}
{kisModal ? (
setKisModal('')}
/>
주문 결과
setKisModal('')}
>
닫기
{kisModal}
) : null}
{/* ── 실현손익 floating 토글 버튼 (마우스 추적) ────────── */}
{!sellDrawerOpen && (
{
setSellDrawerOpen(true);
loadSellHistory();
}}
title="실현손익 내역"
>
💹
실현손익
{sellHistory.length > 0 && (
{sellHistory.length}
)}
)}
{/* ════════════════════════════════════════════════════════
실현손익 드로어
════════════════════════════════════════════════════════ */}
{sellDrawerOpen && (
{ setSellDrawerOpen(false); handleSellFormClose(); }}
/>
)}
{/* 드로어 헤더 */}
{sellHistoryLoading && }
새로고침
{sellFormOpen && sellEditId == null ? '취소' : '+ 추가'}
{ setSellDrawerOpen(false); handleSellFormClose(); }}
aria-label="닫기"
>
✕
{/* 수동 추가 / 수정 폼 */}
{sellFormOpen && (
)}
{/* 필터 바 */}
계좌
{sellHistoryBrokers.map((b) => (
setSellHistoryBroker(b)}
>
{b === 'ALL' ? '전체' : b}
))}
기간
{[
{ label: '1개월', value: '1M' },
{ label: '3개월', value: '3M' },
{ label: '6개월', value: '6M' },
{ label: '1년', value: '1Y' },
{ label: '전체', value: 'ALL' },
].map(({ label, value }) => (
setSellHistoryPeriod(value)}
>
{label}
))}
{/* 요약 카드 */}
{filteredSellHistory.length > 0 && (
거래 횟수
{sellHistorySummary.count}건
총 매도금액
{formatNumber(sellHistorySummary.totalSell)}원
총 수수료 & 세금
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}원
실현손익 합계
{formatNumber(Math.round(sellHistorySummary.totalProfit))}원
평균 수익률
{formatPercent(sellHistorySummary.rate)}
)}
{/* 거래 내역 목록 */}
{filteredSellHistory.length > 0 ? (
{filteredSellHistory.map((r) => {
const profitN = r.realized_profit ?? 0;
const rateN = r.realized_rate ?? 0;
return (
{r.name}
{r.ticker && {r.ticker}}
handleSellEditStart(r)}
title="수정"
>
✏️
handleDeleteSellRecord(r.id)}
title="삭제"
>
🗑️
{r.broker}
{new Date(r.sold_at).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
수량
{formatNumber(r.quantity)}
매입가
{formatNumber(r.avg_price)}
매도가
{formatNumber(r.sell_price)}
매도금액
{formatNumber(Math.round(r.sell_amount))}
{(r.commission > 0) && (
수수료 & 세금
-{formatNumber(Math.round(r.commission))}
)}
실현손익
{formatNumber(Math.round(profitN))}
수익률
{formatPercent(rateN)}
);
})}
) : (
{sellHistory.length === 0
? '아직 매도 기록이 없습니다.'
: '필터 조건에 맞는 기록이 없습니다.'}
)}
);
};
export default StockTrade;