주식 히스토리 API 및 블로그 작성 API 추가

This commit is contained in:
2026-03-11 08:08:39 +09:00
parent bbc9bf36f9
commit c6ac849a25
5 changed files with 1121 additions and 142 deletions

View File

@@ -854,6 +854,31 @@
border-color: rgba(243, 167, 167, 0.5) !important;
}
.pf-btn-sell {
color: #fbbf24 !important;
border-color: rgba(251, 191, 36, 0.5) !important;
}
.pf-sell-confirm {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pf-sell-confirm__msg {
font-size: 12px;
color: var(--text-dim);
display: flex;
flex-direction: column;
gap: 2px;
}
.pf-sell-confirm__warn {
color: #fbbf24;
font-size: 11px;
}
.pf-null-price {
color: var(--muted) !important;
font-size: 12px !important;
@@ -964,6 +989,23 @@
text-align: right;
}
.pf-cash-edit-input {
border: 1px solid var(--neon-cyan);
border-radius: 8px;
padding: 6px 10px;
background: rgba(0, 212, 255, 0.05);
color: var(--text-bright);
font-size: 14px;
font-weight: 600;
width: 140px;
outline: none;
}
.pf-cash-edit-input:focus {
border-color: var(--neon-cyan);
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.15);
}
.pf-cash-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
@@ -1026,6 +1068,77 @@
font-size: 17px;
}
/* ── 자산 추이 차트 ─────────────────────────────────────────────────────── */
.pf-asset-history {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line-subtle);
}
.pf-asset-history__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.pf-asset-history__title {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
letter-spacing: 0.03em;
}
.pf-asset-history__controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pf-asset-period-btn {
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 20px;
border: 1px solid var(--line);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.pf-asset-period-btn:hover {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
}
.pf-asset-period-btn.is-active {
background: rgba(56, 189, 248, 0.12);
border-color: var(--neon-cyan);
color: var(--neon-cyan);
}
.pf-asset-history__empty {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-size: 12px;
color: var(--text-muted);
text-align: center;
}
@media (max-width: 768px) {
.pf-asset-history__head {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
.pf-cash-form {
grid-template-columns: 1fr 1fr;

View File

@@ -13,6 +13,8 @@ import {
getVix,
getTreasury10Y,
getWTI,
getAssetHistory,
saveAssetSnapshot,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
@@ -20,6 +22,7 @@ import {
PieChart, Pie, Cell,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
AreaChart, Area,
} from 'recharts';
/* ── helpers ─────────────────────────────────────────────────────── */
@@ -156,11 +159,28 @@ const StockTrade = () => {
/* Portfolio delete */
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* Portfolio sell */
const [sellConfirmId, setSellConfirmId] = useState(null);
const [sellLoading, setSellLoading] = 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 */
/* ────────────────────────────────────────────────────────────── */
@@ -225,6 +245,59 @@ const StockTrade = () => {
}
}, []);
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);
return { date: dateStr, total_assets: byDate[dateStr] ?? 0 };
});
} else {
filled = Object.entries(byDate)
.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) {
@@ -236,6 +309,13 @@ const StockTrade = () => {
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
useEffect(() => {
if (activeTab === TAB_PORTFOLIO) {
loadAssetHistory(assetHistoryDays);
}
}, [activeTab, assetHistoryDays, loadAssetHistory]);
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
useEffect(() => {
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
@@ -386,6 +466,55 @@ const StockTrade = () => {
}
};
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 qty = item.quantity ?? 0;
const saleAmount = sellPrice * qty;
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);
setSellConfirmId(null);
await loadPortfolio();
} catch (err) {
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
} finally {
setSellLoading(false);
}
};
/* ── report sort ─────────────────────────────────────────────── */
const handleReportSort = (field) => {
@@ -951,6 +1080,93 @@ ${holdingsText}${marketText}
)}
</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>
{/* 예수금 패널 */}
@@ -967,26 +1183,73 @@ ${holdingsText}${marketText}
{cashList.length > 0 && (
<div className="pf-cash-table">
{cashList.map((item) => (
<div key={item.id ?? item.broker} className="pf-cash-row">
<span className="pf-cash-broker">{item.broker}</span>
<strong className="pf-cash-amount">
{formatNumber(item.cash)}
</strong>
<span className="pf-cash-date">
{item.updated_at
? new Date(item.updated_at).toLocaleDateString('ko-KR')
: ''}
</span>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleCashDelete(item.broker)}
title="삭제"
>
🗑
</button>
</div>
))}
{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 && (
@@ -1087,6 +1350,9 @@ ${holdingsText}${marketText}
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
@@ -1198,14 +1464,41 @@ ${holdingsText}${marketText}
</strong>
</div>
<div className="pf-item-actions">
<button
className="button ghost small"
onClick={() => handleEditStart(item)}
title="수정"
>
</button>
{isDeleting ? (
{!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"
@@ -1215,23 +1508,34 @@ ${holdingsText}${marketText}
</button>
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(null)
}
onClick={() => setDeleteConfirmId(null)}
>
취소
</button>
</>
) : (
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(item.id)
}
title="삭제"
>
🗑
</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>
</>