Files
web-page/src/pages/stock/StockTrade.jsx
gahusb d1ecf13400 stock AI 어드바이저 추가 및 UX 개선
- Gemini Pro 기반 AI 어드바이저 탭 추가 (TAB_ADVISOR)
  - 보유 종목 현재가 + 뉴스 → 종목별 매도/매수/분할매도 지침
  - 5분 캐시, 강제 새로고침 버튼
  - 경량 마크다운 렌더러 (AdvisorMarkdown)
- 실현손익 수수료 → 수수료 & 세금으로 레이블 변경
- 총 자산 추이 그래프: 0 데이터 제외 (장 미개장일 필터)
- Todo 완료 패널 하단 이동 + 날짜 필터 추가

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

2894 lines
154 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,
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(<h4 key={i} className="adv-md-h3">{line.slice(4)}</h4>);
} else if (line.startsWith('## ')) {
elements.push(<h3 key={i} className="adv-md-h2">{line.slice(3)}</h3>);
} else if (line.startsWith('---')) {
elements.push(<hr key={i} className="adv-md-hr" />);
} else if (line.trim() === '') {
elements.push(<div key={i} className="adv-md-gap" />);
} else {
// 인라인 마크다운: **bold**, *italic*, 🎯 등 이모지 보존
const rendered = line
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>');
elements.push(
<p key={i} className="adv-md-p" dangerouslySetInnerHTML={{ __html: rendered }} />
);
}
i++;
}
return <div className="adv-md">{elements}</div>;
};
/* ── 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 (
<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 어드바이저 (Gemini Pro)
════════════════════════════════════════════════════════ */}
{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">Gemini Pro</span>
<h3 className="advisor-panel__title">AI 전문가 분석</h3>
<p className="advisor-panel__sub">
보유 종목 현재가와 오늘의 뉴스를 기반으로 전문가 관점의 매매 조언을 제공합니다.
</p>
</div>
<div className="advisor-panel__actions">
{advisorData && (
<span className="advisor-panel__timestamp">
{advisorData.cached ? '🗂 캐시' : '✨ 신규'} ·{' '}
{new Date(advisorData.generated_at).toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit',
})} 기준
</span>
)}
<button
className="button ghost small"
onClick={() => loadAdvisorAnalysis(true)}
disabled={advisorLoading}
>
{advisorLoading ? '분석 중...' : '🔄 새로 분석'}
</button>
</div>
</div>
{/* 로딩 */}
{advisorLoading && (
<div className="advisor-panel__loading">
<div className="advisor-loading-spinner" />
<div className="advisor-loading-text">
<p>Gemini Pro가 포트폴리오를 분석 중입니다...</p>
<p className="advisor-loading-sub">현재가 조회 · 뉴스 분석 · 전략 수립</p>
</div>
</div>
)}
{/* 에러 */}
{!advisorLoading && advisorError && (
<div className="advisor-panel__error">
<span> {advisorError}</span>
<button className="button ghost small" onClick={() => loadAdvisorAnalysis(true)}>
다시 시도
</button>
</div>
)}
{/* 분석 결과 */}
{!advisorLoading && !advisorError && advisorData && (
<div className="advisor-panel__body">
<AdvisorMarkdown text={advisorData.analysis} />
<p className="advisor-panel__disclaimer">
분석은 AI가 생성한 참고 자료이며 투자 결정은 본인의 판단과 책임 하에 이루어져야 합니다.
</p>
</div>
)}
{/* 초기 상태 */}
{!advisorLoading && !advisorError && !advisorData && (
<div className="advisor-panel__empty">
<span className="advisor-panel__empty-icon">🧠</span>
<p>아직 분석 결과가 없습니다.</p>
<button className="button primary" onClick={() => loadAdvisorAnalysis()}>
분석 시작
</button>
</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;