Files
web-page/src/pages/stock/StockTrade.jsx
gahusb f7175ad80c AI 어드바이저 탭을 프롬프트 생성/복사 방식으로 전환
Gemini API 직접 호출 대신 포트폴리오 데이터 기반 전문가 프롬프트를
자동 생성하고 클립보드에 복사하는 방식으로 변경.
- 보유 종목, 평균매입가, 현재가, 손익, 예수금, 시장 지표 포함
- Gemini/ChatGPT 바로가기 링크 제공
- 프롬프트 미리보기 영역 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:39:13 +09:00

2931 lines
156 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} 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;
};
/* ── 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 어드바이저 — 프롬프트 복사 */
const [advisorCopied, setAdvisorCopied] = useState(false);
/* 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);
}
}, []);
/* AI 어드바이저: 포트폴리오 기반 프롬프트 생성 */
const buildAdvisorPrompt = useCallback(() => {
const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
const holdingsLines = portfolioHoldings.map((h) => {
const cp = h.current_price != null ? `${formatNumber(h.current_price)}` : '시세 미조회';
const rate = h.profit_rate != null ? formatPercent(h.profit_rate) : '미조회';
const profit = h.profit_amount != null ? `(${h.profit_amount >= 0 ? '+' : ''}${formatNumber(h.profit_amount)}원)` : '';
return `- **${h.name ?? h.ticker}** (${h.ticker ?? ''}) | 계좌: ${h.broker ?? '-'}
수량 ${h.quantity}주 | 평균매입가 ${formatNumber(h.avg_price)}원 | 현재가 ${cp} | 손익 ${rate} ${profit}`;
}).join('\n');
const cashLines = cashList.map((c) => `- ${c.broker}: ${formatNumber(c.cash)}`).join('\n') || '- 없음';
const marketLines = marketCtx
? [
`VIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}`,
`공포탐욕지수: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}`,
`미 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}`,
`WTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`,
].join('\n')
: '시장 데이터 미로드';
return `당신은 15년 이상 경력의 한국 주식시장 전문 애널리스트입니다.
오늘은 ${today}입니다. 아래 포트폴리오 정보와 시장 환경을 바탕으로 전문가 분석을 제공해주세요.
---
## 📊 현재 시장 환경
${marketLines}
---
## 💼 보유 포트폴리오
### 보유 종목 (${portfolioHoldings.length}개)
${holdingsLines || '보유 종목 없음'}
### 포트폴리오 요약
- 총 매입금액: ${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) + '원' : '미집계'}
### 예수금 현황
${cashLines}
---
## 🔍 분석 요청
다음 형식으로 명확하게 작성해주세요:
### 📈 오늘의 시장 환경
시장 환경 데이터를 바탕으로 오늘 한국 주식시장의 전반적인 분위기와 주요 이슈를 2-3문장으로 요약하세요.
### 🔍 종목별 분석 및 행동 지침
각 보유 종목에 대해 아래 형식으로 작성하세요:
**[종목명 (티커)]**
- 현황: 현재 손익 상태와 포지션 평가
- 분석: 업황·섹터 동향, 주요 리스크/기회
- 🎯 행동 지침: **[매도 / 보유 / 추가매수 / 분할매도]** — 구체적 이유와 목표 참고 가격대
### 💼 포트폴리오 종합 의견
전체 포트폴리오의 섹터 편중, 리밸런싱 필요 여부, 현금 비중 조언을 작성하세요.
### ⚠️ 오늘 주의해야 할 리스크
매크로·섹터·개별 종목 측면에서 오늘 특히 주의할 리스크를 2-3가지 나열하세요.
---
분석은 반드시 한국어로, 구체적인 수치와 근거를 들어 전문적으로 작성해주세요.
투자 결정은 최종적으로 투자자 본인이 판단함을 명시하세요.`;
}, [portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx]);
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 && !portfolioLoaded) {
loadPortfolio();
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
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 (
<div className="stock">
{/* ── Header ──────────────────────────────────────────── */}
<header className="stock-header">
<div>
<p className="stock-kicker">거래 데스크</p>
<h1>거래 데스크</h1>
<p className="stock-sub">
실제 계좌와 AI 모의투자를 곳에서 관리하세요.
</p>
<div className="stock-actions">
<Link className="button ghost" to="/stock">
주식 랩으로 돌아가기
</Link>
</div>
</div>
<div className="stock-card">
<p className="stock-card__title">
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
</p>
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
/* Portfolio summary */
<div className="stock-status">
<div>
<span> 매입</span>
<strong>{formatNumber(portfolioSummary.total_buy)}</strong>
</div>
<div>
<span> 평가</span>
<strong>{formatNumber(portfolioSummary.total_eval)}</strong>
</div>
<div>
<span> 손익</span>
<strong
className={`stock-profit ${profitColorClass(
toNumeric(portfolioSummary.total_profit)
)}`}
>
{formatNumber(portfolioSummary.total_profit)}
{portfolioSummary.total_profit_rate != null && (
<small style={{ marginLeft: 4, fontSize: 11 }}>
({formatPercent(portfolioSummary.total_profit_rate)})
</small>
)}
</strong>
</div>
<div>
<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 */
<div className="stock-status">
<div>
<span> 평가금액</span>
<strong>{formatNumber(totalEval)}</strong>
</div>
<div>
<span>예수금</span>
<strong>{formatNumber(deposit)}</strong>
</div>
<div>
<span>보유 종목</span>
<strong>{holdings.length}</strong>
</div>
</div>
)}
{activeTab === TAB_AI && summary.note ? (
<p className="stock-status__note">{summary.note}</p>
) : null}
</div>
</header>
{/* ── Main Tabs ───────────────────────────────────────── */}
<div className="stock-main-tabs">
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_PORTFOLIO ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_PORTFOLIO)}
>
<span className="stock-main-tab__icon">💼</span>
<span className="stock-main-tab__label">쟁승토리 계좌</span>
{portfolioHoldings.length > 0 && (
<span className="stock-main-tab__badge">
{portfolioHoldings.length}
</span>
)}
</button>
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_AI ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_AI)}
>
<span className="stock-main-tab__icon">🤖</span>
<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>
<button
type="button"
className={`stock-main-tab stock-main-tab--advisor ${activeTab === TAB_ADVISOR ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_ADVISOR)}
>
<span className="stock-main-tab__icon">🧠</span>
<span className="stock-main-tab__label">AI 어드바이저</span>
<span className="stock-main-tab__sub">Gemini Pro</span>
</button>
</div>
{/* ════════════════════════════════════════════════════════
TAB 1: 쟁승토리 계좌
════════════════════════════════════════════════════════ */}
{activeTab === TAB_PORTFOLIO && (
<>
{portfolioError ? (
<p className="stock-error">{portfolioError}</p>
) : null}
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
<section className="stock-panel stock-panel--wide pf-section">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">포트폴리오</p>
<h3>수동 입력 종목 관리</h3>
<p className="stock-panel__sub">
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3 캐시)
</p>
</div>
<div className="stock-panel__actions">
{portfolioLoading ? (
<Loading type="spinner" message="" />
) : null}
<button
className="button ghost small"
onClick={loadPortfolio}
disabled={portfolioLoading}
>
새로고침
</button>
<button
className="button primary small"
onClick={() => setAddFormOpen((v) => !v)}
>
{addFormOpen ? '취소' : '+ 종목 추가'}
</button>
</div>
</div>
{/* Add form */}
{addFormOpen && (
<form className="pf-add-form" onSubmit={handleAddSubmit}>
<label>
증권사
<input
type="text"
value={addForm.broker}
onChange={(e) =>
setAddForm((p) => ({ ...p, broker: e.target.value }))
}
placeholder="KB증권"
required
/>
</label>
<label>
종목코드
<input
type="text"
value={addForm.ticker}
onChange={(e) =>
setAddForm((p) => ({ ...p, ticker: e.target.value }))
}
placeholder="005930"
required
maxLength={6}
/>
</label>
<label>
종목명
<input
type="text"
value={addForm.name}
onChange={(e) =>
setAddForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="삼성전자"
required
/>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={addForm.quantity}
onChange={(e) =>
setAddForm((p) => ({ ...p, quantity: e.target.value }))
}
required
/>
</label>
<label>
평균 매입가 ()
<input
type="number"
min={0}
step={1}
value={addForm.avg_price}
onChange={(e) =>
setAddForm((p) => ({ ...p, avg_price: e.target.value }))
}
required
/>
</label>
<button
className="button primary"
type="submit"
disabled={addLoading}
>
{addLoading ? '등록 중...' : '종목 등록'}
</button>
{addError && <p className="stock-error">{addError}</p>}
</form>
)}
{/* Portfolio total summary */}
{portfolioHoldings.length > 0 && (
<div className="pf-total-summary">
{[
{ 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) => (
<div key={s.label} className="pf-total-summary__card">
<span>{s.label}</span>
<strong
className={
s.isProfit || s.isRate
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
: ''
}
>
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
</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>
)}
{/* 자산 추이 차트 */}
<div className="pf-asset-history">
<div className="pf-asset-history__head">
<p className="pf-asset-history__title"> 자산 추이</p>
<div className="pf-asset-history__controls">
{[
{ label: '7일', value: 7 },
{ label: '30일', value: 30 },
{ label: '90일', value: 90 },
{ label: '전체', value: 0 },
].map(({ label, value }) => (
<button
key={value}
type="button"
className={`pf-asset-period-btn ${assetHistoryDays === value ? 'is-active' : ''}`}
onClick={() => setAssetHistoryDays(value)}
>
{label}
</button>
))}
<button
type="button"
className="button ghost small"
onClick={handleSaveSnapshot}
disabled={snapshotSaving || totalAssets == null}
title="현재 총 자산을 오늘 날짜로 저장"
>
{snapshotSaving ? '저장 중...' : '📸 스냅샷'}
</button>
</div>
</div>
{assetHistoryLoading ? (
<div className="pf-asset-history__empty">
<Loading type="spinner" message="" />
</div>
) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart
data={assetHistory}
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="assetGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.25} />
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
tickFormatter={(v) => v?.slice(5)}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
hide
domain={['auto', 'auto']}
/>
<ChartTooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--line)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: 'var(--text-dim)', marginBottom: 4 }}
formatter={(v) => [`${new Intl.NumberFormat('ko-KR').format(v)}`, '총 자산']}
/>
<Area
type="monotone"
dataKey="total_assets"
stroke="#38bdf8"
strokeWidth={2}
fill="url(#assetGrad)"
dot={false}
activeDot={{ r: 4, fill: '#38bdf8' }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="pf-asset-history__empty">
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
</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) => {
const isEditing = cashEditingBroker === item.broker;
return (
<div key={item.id ?? item.broker} className="pf-cash-row">
<span className="pf-cash-broker">{item.broker}</span>
{isEditing ? (
<input
className="pf-cash-edit-input"
type="number"
min={0}
step={1}
value={cashEditingValue}
onChange={(e) => setCashEditingValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCashInlineSave(item.broker);
if (e.key === 'Escape') handleCashInlineCancel();
}}
autoFocus
/>
) : (
<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>
{isEditing ? (
<>
<button
className="button primary small"
onClick={() => handleCashInlineSave(item.broker)}
disabled={cashEditSaving}
>
{cashEditSaving ? '저장 중' : '저장'}
</button>
<button
className="button ghost small"
onClick={handleCashInlineCancel}
disabled={cashEditSaving}
>
취소
</button>
</>
) : (
<>
<button
className="button ghost small"
onClick={() => handleCashInlineEdit(item)}
title="수정"
>
</button>
<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);
const color = brokerColors[broker];
return (
<section
key={broker}
className="stock-panel stock-panel--wide pf-broker-section"
style={{
borderColor: color?.border,
background: color?.bg,
}}
>
<div className="stock-panel__head">
<div>
<p
className="stock-panel__eyebrow"
style={{ color: color?.border }}
>
{broker}
</p>
<h3>{broker} 보유 현황</h3>
<p className="stock-panel__sub">
{items.length}종목 · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '}
<span
className={`stock-profit ${profitColorClass(
bSummary.totalProfit
)}`}
>
{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>
<div className="stock-holdings">
{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 (
<div
key={item.id}
className="stock-holdings__item pf-item"
>
{isEditing ? (
<div className="pf-edit-row">
<div className="pf-edit-fields">
<label>
수량
<input
type="number"
min={1}
value={editForm.quantity ?? ''}
onChange={(e) =>
setEditForm((p) => ({
...p,
quantity: Number(e.target.value),
}))
}
/>
</label>
<label>
평균매입가
<input
type="number"
min={0}
value={editForm.avg_price ?? ''}
onChange={(e) =>
setEditForm((p) => ({
...p,
avg_price: Number(e.target.value),
}))
}
/>
</label>
</div>
<div className="pf-edit-actions">
<button
className="button primary small"
onClick={() => handleEditSave(item.id)}
disabled={editLoading}
>
{editLoading ? '저장 중...' : '저장'}
</button>
<button
className="button ghost small"
onClick={() => setEditingId(null)}
>
취소
</button>
</div>
</div>
) : (
<>
<div>
<p className="stock-holdings__name">
{item.name ?? item.ticker ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.ticker ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>{formatNumber(item.quantity)}</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>{formatNumber(item.avg_price)}</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong
className={item.current_price == null ? 'pf-null-price' : ''}
>
{item.current_price != null
? formatNumber(item.current_price)
: '조회 실패'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가금액</span>
<strong>
{item.current_price != null && item.quantity != null
? formatNumber(item.current_price * item.quantity)
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitColorClass(profitRateN)}`}
>
{profitRate != null
? formatPercent(profitRate)
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitColorClass(profitAmtN)}`}
>
{profitAmt != null
? formatNumber(profitAmt)
: '-'}
</strong>
</div>
<div className="pf-item-actions">
{!isSelling && !isDeleting && (
<button
className="button ghost small"
onClick={() => handleEditStart(item)}
title="수정"
>
</button>
)}
{isSelling ? (
<div className="pf-sell-confirm">
<span className="pf-sell-confirm__msg">
{item.current_price == null && (
<small className="pf-sell-confirm__warn">현재가 미조회 매입가 기준</small>
)}
{saleAmount != null
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
: '매도 처리'}
</span>
<button
className="button small pf-btn-sell"
onClick={() => handleSell(item)}
disabled={sellLoading}
>
{sellLoading ? '처리 중...' : '매도 확인'}
</button>
<button
className="button ghost small"
onClick={() => setSellConfirmId(null)}
disabled={sellLoading}
>
취소
</button>
</div>
) : isDeleting ? (
<>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleDelete(item.id)}
>
확인
</button>
<button
className="button ghost small"
onClick={() => setDeleteConfirmId(null)}
>
취소
</button>
</>
) : (
<>
<button
className="button ghost small pf-btn-sell"
onClick={() => {
setSellConfirmId(item.id);
setDeleteConfirmId(null);
}}
title="매도"
>
매도
</button>
<button
className="button ghost small"
onClick={() => {
setDeleteConfirmId(item.id);
setSellConfirmId(null);
}}
title="삭제"
>
🗑
</button>
</>
)}
</div>
</>
)}
</div>
);
})}
</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>
)}
{/* sell history → 드로어로 이동됨 */}
</>
)}
{/* ════════════════════════════════════════════════════════
TAB 2: AI 투자 (모의투자)
════════════════════════════════════════════════════════ */}
{activeTab === TAB_AI && (
<>
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
{/* AI Balance section */}
<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 className="stock-panel__actions">
{balanceLoading ? (
<span className="stock-chip">조회 </span>
) : null}
<button
className="button ghost small"
onClick={loadBalance}
disabled={balanceLoading}
>
새로고침
</button>
</div>
</div>
<div className="stock-balance">
<div className="stock-balance__summary">
{[
{ label: '총 평가', value: totalEval },
{ label: '예수금', value: deposit },
].map((item) => (
<div
key={item.label}
className="stock-balance__card"
>
<span>{item.label}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
))}
</div>
{holdings.length ? (
<div className="stock-holdings">
{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 (
<div
key={item.code ?? `${item.name}-${idx}`}
className="stock-holdings__item"
>
<div>
<p className="stock-holdings__name">
{item.name ?? item.code ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.code ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>
{formatNumber(getQty(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>
{formatNumber(getBuyPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong>
{formatNumber(getCurrentPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가금액</span>
<strong>
{getCurrentPrice(item) != null && getQty(item) != null
? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item)))
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitRateClass}`}
>
{formatPercent(profitRate)}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitClass}`}
>
{formatNumber(profitLoss)}
</strong>
</div>
</div>
);
})}
</div>
) : (
<p className="stock-empty">보유 종목이 없습니다.</p>
)}
</div>
</section>
{/* Manual order 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>
<form className="stock-order" onSubmit={submitManualOrder}>
<label>
종목명/코드
<input
type="text"
value={manualForm.code}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
code: event.target.value,
}))
}
placeholder="005930 또는 삼성전자"
required
/>
</label>
<label>
매수/매도
<select
value={manualForm.type}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
type: event.target.value,
}))
}
>
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={manualForm.qty}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
qty: Number(event.target.value),
}))
}
required
/>
</label>
<label>
금액()
<input
type="number"
min={0}
step={1}
value={manualForm.price}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
price: Number(event.target.value),
}))
}
/>
</label>
<button
className="button primary"
type="submit"
disabled={manualLoading}
>
{manualLoading ? '요청 중...' : '주문 요청'}
</button>
{manualError ? (
<p className="stock-error">{manualError}</p>
) : null}
{manualResult ? (
<div className="stock-result">
<p className="stock-result__title">요청 결과</p>
<pre>
{typeof manualResult === 'string'
? manualResult
: JSON.stringify(manualResult, null, 2)}
</pre>
</div>
) : null}
</form>
</section>
</>
)}
{/* ════════════════════════════════════════════════════════
TAB 4: AI 어드바이저 — 프롬프트 생성/복사
════════════════════════════════════════════════════════ */}
{activeTab === TAB_ADVISOR && (
<section className="stock-panel stock-panel--wide advisor-panel">
{/* 헤더 */}
<div className="advisor-panel__head">
<div className="advisor-panel__title-block">
<span className="advisor-panel__badge">AI 어드바이저</span>
<h3 className="advisor-panel__title">포트폴리오 분석 프롬프트</h3>
<p className="advisor-panel__sub">
보유 종목 정보를 담은 전문가용 프롬프트를 생성합니다.
복사 Gemini, ChatGPT 등에 붙여넣어 분석을 받아보세요.
</p>
</div>
<div className="advisor-panel__actions">
<a
href="https://gemini.google.com"
target="_blank"
rel="noopener noreferrer"
className="button ghost small"
>
Gemini 열기
</a>
<a
href="https://chatgpt.com"
target="_blank"
rel="noopener noreferrer"
className="button ghost small"
>
ChatGPT 열기
</a>
</div>
</div>
{portfolioLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Loading type="spinner" message="포트폴리오 로딩 중..." />
</div>
)}
{!portfolioLoading && portfolioHoldings.length === 0 && (
<div className="advisor-panel__empty">
<span className="advisor-panel__empty-icon">📋</span>
<p>포트폴리오 탭에서 보유 종목을 먼저 등록해주세요.</p>
</div>
)}
{!portfolioLoading && portfolioHoldings.length > 0 && (
<div className="advisor-panel__body">
{/* 복사 버튼 */}
<div className="advisor-prompt__toolbar">
<span className="advisor-prompt__info">
종목 {portfolioHoldings.length} · 자산 {totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
</span>
<button
className={`button primary small ${advisorCopied ? 'is-copied' : ''}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(buildAdvisorPrompt());
setAdvisorCopied(true);
setTimeout(() => setAdvisorCopied(false), 2500);
} catch {
alert('클립보드 복사에 실패했습니다. 텍스트를 직접 선택해 복사하세요.');
}
}}
>
{advisorCopied ? '✅ 복사됨' : '📋 프롬프트 복사'}
</button>
</div>
{/* 프롬프트 미리보기 */}
<pre className="advisor-prompt__preview">{buildAdvisorPrompt()}</pre>
<p className="advisor-panel__disclaimer">
프롬프트를 AI에 붙여넣으면 전문가 관점의 매매 조언을 받을 있습니다.
투자 결정은 최종적으로 본인의 판단과 책임 하에 이루어져야 합니다.
</p>
</div>
)}
</section>
)}
{/* ════════════════════════════════════════════════════════
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>}
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
{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 && portfolioSummary.total_eval != null && (
<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">증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 주의.</p>
</div>
</div>
<div className="risk-grid">
{/* 증권사별 집중도 */}
<div className="risk-card">
<p className="risk-card__title">증권사별 집중도</p>
{brokerConcentration.length === 0 ? (
<p className="stock-empty" style={{ fontSize: 13 }}>평가금액 데이터 없음</p>
) : (
<>
{brokerConcentration.some((b) => b.ratio > 40) && (
<div className="risk-warning">
단일 증권사 집중도가 40% 초과합니다
</div>
)}
{brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => {
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
return (
<div key={broker} className="risk-item">
<div className="risk-item__head">
<span className="risk-item__name">{broker}</span>
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
</div>
<div className="risk-bar">
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{formatNumber(evalAmt)}</span>
</div>
);
})}
</>
)}
</div>
{/* 종목별 집중도 */}
<div className="risk-card">
<p className="risk-card__title">상위 5 종목 집중도</p>
{stockConcentration.length === 0 ? (
<p className="stock-empty" style={{ fontSize: 13 }}>현재가 데이터 없음</p>
) : (
<>
{stockConcentration.some((s) => s.ratio > 40) && (
<div className="risk-warning">
단일 종목 집중도가 40% 초과합니다
</div>
)}
{stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => {
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
return (
<div key={ticker || name} className="risk-item">
<div className="risk-item__head">
<span className="risk-item__name">{name}</span>
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
</div>
<div className="risk-bar">
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
{ticker && <span style={{ marginRight: 6 }}>{ticker}</span>}
{formatNumber(evalAmt)}
</span>
</div>
);
})}
</>
)}
</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>
))}
<th style={{ cursor: 'default' }}>비중</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;
const totalEval = toNumeric(portfolioSummary.total_eval);
const weight = evalAmt != null && totalEval
? Math.round((evalAmt / totalEval) * 1000) / 10
: 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>
<td className="report-td-muted">
{weight != null ? `${weight.toFixed(1)}%` : '-'}
</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>
{/* 시장 컨텍스트 미니 패널 */}
{marketCtx && (
<div className="ai-market-ctx">
<span className="ai-market-ctx__label">시장 환경</span>
<div className="ai-market-ctx__chips">
{marketCtx.vix != null && (
<span className="ai-market-chip">
VIX&nbsp;<strong>{marketCtx.vix}</strong>
<em>{getVixLabel(marketCtx.vix)}</em>
</span>
)}
{marketCtx.fg != null && (
<span className="ai-market-chip">
F&amp;G&nbsp;<strong>{marketCtx.fg}</strong>
<em>{getFgLabel(marketCtx.fg)}</em>
</span>
)}
{marketCtx.treasury != null && (
<span className="ai-market-chip">
10년물&nbsp;<strong>{marketCtx.treasury}%</strong>
</span>
)}
{marketCtx.wti != null && (
<span className="ai-market-chip">
WTI&nbsp;<strong>${marketCtx.wti}</strong>
</span>
)}
</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">
<div
className="stock-modal__backdrop"
onClick={() => setKisModal('')}
/>
<div className="stock-modal__card">
<div className="stock-modal__head">
<h4>주문 결과</h4>
<button
type="button"
className="button ghost small"
onClick={() => setKisModal('')}
>
닫기
</button>
</div>
<pre>{kisModal}</pre>
</div>
</div>
) : null}
{/* ── 실현손익 floating 토글 버튼 (마우스 추적) ────────── */}
{!sellDrawerOpen && (
<button
type="button"
className="sh-floating-toggle"
onClick={() => {
setSellDrawerOpen(true);
loadSellHistory();
}}
title="실현손익 내역"
>
<span className="sh-floating-toggle__icon">💹</span>
<span className="sh-floating-toggle__label">실현손익</span>
{sellHistory.length > 0 && (
<span className="sh-floating-toggle__badge">{sellHistory.length}</span>
)}
</button>
)}
{/* ════════════════════════════════════════════════════════
실현손익 드로어
════════════════════════════════════════════════════════ */}
{sellDrawerOpen && (
<div
className="sh-backdrop"
onClick={() => { setSellDrawerOpen(false); handleSellFormClose(); }}
/>
)}
<aside className={`sh-drawer ${sellDrawerOpen ? 'is-open' : ''}`}>
{/* 드로어 헤더 */}
<div className="sh-drawer__header">
<div>
<p className="sh-drawer__eyebrow">실현손익</p>
<h3 className="sh-drawer__title">매도 거래 내역</h3>
</div>
<div className="sh-drawer__header-actions">
{sellHistoryLoading && <Loading type="spinner" message="" />}
<button
className="button ghost small"
onClick={loadSellHistory}
disabled={sellHistoryLoading}
>
새로고침
</button>
<button
className="button primary small"
onClick={sellFormOpen && sellEditId == null ? handleSellFormClose : handleSellFormOpen}
>
{sellFormOpen && sellEditId == null ? '취소' : '+ 추가'}
</button>
<button
className="sh-drawer__close"
type="button"
onClick={() => { setSellDrawerOpen(false); handleSellFormClose(); }}
aria-label="닫기"
>
</button>
</div>
</div>
{/* 수동 추가 / 수정 폼 */}
{sellFormOpen && (
<form className="sh-form" onSubmit={handleSellFormSubmit}>
<div className="sh-form__title">
{sellEditId != null ? '거래 내역 수정' : '매도 내역 수동 추가'}
</div>
<div className="sh-form__grid">
<label>
증권사
<input
type="text"
value={sellForm.broker}
onChange={(e) => setSellForm((p) => ({ ...p, broker: e.target.value }))}
placeholder="KB증권"
required
/>
</label>
<label>
종목코드
<input
type="text"
value={sellForm.ticker}
onChange={(e) => setSellForm((p) => ({ ...p, ticker: e.target.value }))}
placeholder="005930"
/>
</label>
<label>
종목명
<input
type="text"
value={sellForm.name}
onChange={(e) => setSellForm((p) => ({ ...p, name: e.target.value }))}
placeholder="삼성전자"
required
/>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={sellForm.quantity}
onChange={(e) => setSellForm((p) => ({ ...p, quantity: e.target.value }))}
placeholder="10"
required
/>
</label>
<label>
평균 매입가 ()
<input
type="number"
min={0}
step={1}
value={sellForm.avg_price}
onChange={(e) => setSellForm((p) => ({ ...p, avg_price: e.target.value }))}
placeholder="58000"
required
/>
</label>
<label>
매도가 ()
<input
type="number"
min={0}
step={1}
value={sellForm.sell_price}
onChange={(e) => setSellForm((p) => ({ ...p, sell_price: e.target.value }))}
placeholder="62000"
required
/>
</label>
<label>
수수료 & 세금 ()
<input
type="number"
min={0}
step={1}
value={sellForm.commission}
onChange={(e) => setSellForm((p) => ({ ...p, commission: e.target.value }))}
placeholder="0"
/>
</label>
<label className="sh-form__datetime">
매도 일시
<input
type="datetime-local"
value={sellForm.sold_at}
onChange={(e) => setSellForm((p) => ({ ...p, sold_at: e.target.value }))}
required
/>
</label>
</div>
{sellForm.quantity && sellForm.avg_price && sellForm.sell_price && (() => {
const qty = Number(sellForm.quantity);
const buy = Number(sellForm.avg_price) * qty;
const sell = Number(sellForm.sell_price) * qty;
const commission = Number(sellForm.commission) || 0;
const profit = sell - buy - commission;
const rate = buy > 0 ? (profit / buy) * 100 : 0;
return (
<div className="sh-form__preview">
<span>매도금액 <strong>{formatNumber(Math.round(sell))}</strong></span>
{commission > 0 && (
<span>수수료 & 세금 <strong className="stock-profit is-negative">-{formatNumber(Math.round(commission))}</strong></span>
)}
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}</strong></span>
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
</div>
);
})()}
<div className="sh-form__actions">
<button className="button primary" type="submit" disabled={sellFormSaving}>
{sellFormSaving ? '저장 중...' : (sellEditId != null ? '수정 저장' : '추가')}
</button>
<button className="button ghost" type="button" onClick={handleSellFormClose} disabled={sellFormSaving}>
취소
</button>
{sellFormError && <p className="stock-error">{sellFormError}</p>}
</div>
</form>
)}
{/* 필터 바 */}
<div className="sell-history__filters">
<div className="sell-history__filter-group">
<span className="sell-history__filter-label">계좌</span>
{sellHistoryBrokers.map((b) => (
<button
key={b}
type="button"
className={`sell-history__filter-btn ${sellHistoryBroker === b ? 'is-active' : ''}`}
onClick={() => setSellHistoryBroker(b)}
>
{b === 'ALL' ? '전체' : b}
</button>
))}
</div>
<div className="sell-history__filter-group">
<span className="sell-history__filter-label">기간</span>
{[
{ label: '1개월', value: '1M' },
{ label: '3개월', value: '3M' },
{ label: '6개월', value: '6M' },
{ label: '1년', value: '1Y' },
{ label: '전체', value: 'ALL' },
].map(({ label, value }) => (
<button
key={value}
type="button"
className={`sell-history__filter-btn ${sellHistoryPeriod === value ? 'is-active' : ''}`}
onClick={() => setSellHistoryPeriod(value)}
>
{label}
</button>
))}
</div>
</div>
{/* 요약 카드 */}
{filteredSellHistory.length > 0 && (
<div className="sell-history__summary">
<div className="sell-history__summary-card">
<span>거래 횟수</span>
<strong>{sellHistorySummary.count}</strong>
</div>
<div className="sell-history__summary-card">
<span> 매도금액</span>
<strong>{formatNumber(sellHistorySummary.totalSell)}</strong>
</div>
<div className="sell-history__summary-card">
<span> 수수료 & 세금</span>
<strong className="stock-profit is-negative">
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}
</strong>
</div>
<div className="sell-history__summary-card">
<span>실현손익 합계</span>
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
{formatNumber(Math.round(sellHistorySummary.totalProfit))}
</strong>
</div>
<div className="sell-history__summary-card">
<span>평균 수익률</span>
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.rate)}`}>
{formatPercent(sellHistorySummary.rate)}
</strong>
</div>
</div>
)}
{/* 거래 내역 목록 */}
{filteredSellHistory.length > 0 ? (
<div className="sh-drawer__list">
{filteredSellHistory.map((r) => {
const profitN = r.realized_profit ?? 0;
const rateN = r.realized_rate ?? 0;
return (
<div key={r.id} className="sh-drawer__item">
<div className="sh-drawer__item-top">
<div className="sh-drawer__item-name">
<span>{r.name}</span>
{r.ticker && <code>{r.ticker}</code>}
</div>
<div className="sh-drawer__item-actions">
<button
type="button"
className="button ghost small"
onClick={() => handleSellEditStart(r)}
title="수정"
>
</button>
<button
type="button"
className="button ghost small pf-btn-danger"
onClick={() => handleDeleteSellRecord(r.id)}
title="삭제"
>
🗑
</button>
</div>
</div>
<div className="sh-drawer__item-meta">
<span className="sell-history__broker">{r.broker}</span>
<span className="sell-history__date">
{new Date(r.sold_at).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
<div className="sh-drawer__item-metrics">
<div>
<span>수량</span>
<strong>{formatNumber(r.quantity)}</strong>
</div>
<div>
<span>매입가</span>
<strong>{formatNumber(r.avg_price)}</strong>
</div>
<div>
<span>매도가</span>
<strong>{formatNumber(r.sell_price)}</strong>
</div>
<div>
<span>매도금액</span>
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
</div>
{(r.commission > 0) && (
<div>
<span>수수료 & 세금</span>
<strong className="stock-profit is-negative">
-{formatNumber(Math.round(r.commission))}
</strong>
</div>
)}
<div>
<span>실현손익</span>
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
{formatNumber(Math.round(profitN))}
</strong>
</div>
<div>
<span>수익률</span>
<strong className={`stock-profit ${profitColorClass(rateN)}`}>
{formatPercent(rateN)}
</strong>
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="stock-empty sh-drawer__empty">
{sellHistory.length === 0
? '아직 매도 기록이 없습니다.'
: '필터 조건에 맞는 기록이 없습니다.'}
</p>
)}
</aside>
</div>
);
};
export default StockTrade;