Gemini API 직접 호출 대신 포트폴리오 데이터 기반 전문가 프롬프트를 자동 생성하고 클립보드에 복사하는 방식으로 변경. - 보유 종목, 평균매입가, 현재가, 손익, 예수금, 시장 지표 포함 - Gemini/ChatGPT 바로가기 링크 제공 - 프롬프트 미리보기 영역 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2931 lines
156 KiB
JavaScript
2931 lines
156 KiB
JavaScript
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 <strong>{marketCtx.vix}</strong>
|
||
<em>{getVixLabel(marketCtx.vix)}</em>
|
||
</span>
|
||
)}
|
||
{marketCtx.fg != null && (
|
||
<span className="ai-market-chip">
|
||
F&G <strong>{marketCtx.fg}</strong>
|
||
<em>{getFgLabel(marketCtx.fg)}</em>
|
||
</span>
|
||
)}
|
||
{marketCtx.treasury != null && (
|
||
<span className="ai-market-chip">
|
||
10년물 <strong>{marketCtx.treasury}%</strong>
|
||
</span>
|
||
)}
|
||
{marketCtx.wti != null && (
|
||
<span className="ai-market-chip">
|
||
WTI <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;
|