diff --git a/src/pages/stock/hooks/usePortfolio.js b/src/pages/stock/hooks/usePortfolio.js index 2bb8d78..31bb8d2 100644 --- a/src/pages/stock/hooks/usePortfolio.js +++ b/src/pages/stock/hooks/usePortfolio.js @@ -3,7 +3,7 @@ import { getPortfolio, addPortfolio, updatePortfolio, deletePortfolio, upsertCash, deleteCash, } from '../../../api'; -import { emptyPortfolioForm } from '../stockUtils'; +import { emptyPortfolioForm, computeBrokerSummary } from '../stockUtils'; export default function usePortfolio() { const [portfolio, setPortfolio] = useState(null); @@ -38,7 +38,12 @@ export default function usePortfolio() { /* derived */ const portfolioHoldings = portfolio?.holdings ?? []; - const portfolioSummary = portfolio?.summary ?? {}; + // 총 매입은 "각 종목 매입가의 단순 합(수량 미곱산)"으로 표시 (박재오 정의). + // 백엔드 summary.total_buy(매입가×수량)는 무시하고 프론트에서 재계산해 + // 요약카드·증권사별·AI 프롬프트가 모두 같은 값을 쓰도록 통일. + const portfolioSummary = portfolioHoldings.length + ? { ...(portfolio?.summary ?? {}), total_buy: computeBrokerSummary(portfolioHoldings).totalBuy } + : (portfolio?.summary ?? {}); const cashList = portfolio?.cash ?? []; const totalCash = portfolioSummary.total_cash ?? null; const totalAssets = portfolioSummary.total_assets ?? null; @@ -69,23 +74,7 @@ export default function usePortfolio() { return map; }, [brokerGroups]); - const getBrokerSummary = (items) => { - // totalBuy: 요약 표시용 (매입가 purchase_price 기준) - // totalCostBasis: 손익 계산용 (평균단가 avg_price 기준) - let totalBuy = 0, totalCostBasis = 0, totalEvalAmt = 0, hasNullPrice = false; - for (const item of items) { - const qty = item.quantity ?? 0; - const purchase = item.purchase_price ?? item.avg_price ?? 0; - // 총 매입 = 종목별 매입가의 단순 합 (수량 미곱산) - totalBuy += purchase; - totalCostBasis += (item.avg_price ?? 0) * qty; - if (item.eval_amount != null) totalEvalAmt += item.eval_amount; - else hasNullPrice = true; - } - const totalProfit = totalEvalAmt - totalCostBasis; - const totalProfitRate = totalCostBasis > 0 ? (totalProfit / totalCostBasis) * 100 : 0; - return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; - }; + const getBrokerSummary = computeBrokerSummary; /* loaders */ const loadPortfolio = useCallback(async () => { diff --git a/src/pages/stock/stockUtils.js b/src/pages/stock/stockUtils.js index 8b0533f..f58b2f2 100644 --- a/src/pages/stock/stockUtils.js +++ b/src/pages/stock/stockUtils.js @@ -125,6 +125,25 @@ export const emptySellForm = () => ({ sold_at: toLocalDatetimeValue(new Date().toISOString()), }); +/* ── 증권사별 요약 집계 ──────────────────────────────────────────── */ +// totalBuy: 총 매입 = 각 종목 매입가(purchase_price)의 단순 합 (수량 미곱산, 박재오 정의). +// 매입가 미설정 시 avg_price 폴백. 백엔드 total_buy(×수량)는 표시에 쓰지 않음. +// totalCostBasis: 손익 계산용 매입원가 = SUM(avg_price × quantity) — 손익은 수량 곱산 유지. +export const computeBrokerSummary = (items) => { + let totalBuy = 0, totalCostBasis = 0, totalEval = 0, hasNullPrice = false; + for (const item of items) { + const qty = item.quantity ?? 0; + const purchase = item.purchase_price ?? item.avg_price ?? 0; + totalBuy += purchase; + totalCostBasis += (item.avg_price ?? 0) * qty; + if (item.eval_amount != null) totalEval += item.eval_amount; + else hasNullPrice = true; + } + const totalProfit = totalEval - totalCostBasis; + const totalProfitRate = totalCostBasis > 0 ? (totalProfit / totalCostBasis) * 100 : 0; + return { totalBuy, totalEval, totalProfit, totalProfitRate, hasNullPrice }; +}; + /* ── TAB IDs ─────────────────────────────────────────────────────── */ export const TAB_PORTFOLIO = 'portfolio'; diff --git a/src/pages/stock/stockUtils.test.js b/src/pages/stock/stockUtils.test.js new file mode 100644 index 0000000..5b4bf7f --- /dev/null +++ b/src/pages/stock/stockUtils.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { computeBrokerSummary } from './stockUtils.js'; + +describe('computeBrokerSummary - 총 매입(total_buy) 계산', () => { + it('총 매입 = 각 종목 매입가(purchase_price)의 단순 합 (수량 미곱산)', () => { + const items = [ + { quantity: 100, avg_price: 72000, purchase_price: 70000, eval_amount: 7450000 }, + ]; + // 매입가 70000 (수량 곱하지 않음) + expect(computeBrokerSummary(items).totalBuy).toBe(70_000); + }); + + it('purchase_price 미설정 시 avg_price로 폴백 (단순 합)', () => { + const items = [ + { quantity: 100, avg_price: 72000, purchase_price: null, eval_amount: 7450000 }, + ]; + // 매입가 미입력 → 평균단가 72000 폴백 + expect(computeBrokerSummary(items).totalBuy).toBe(72_000); + }); + + it('여러 종목 합산: 각 매입가의 단순 합', () => { + const items = [ + { quantity: 100, avg_price: 70000, purchase_price: 70000, eval_amount: 7500000 }, + { quantity: 50, avg_price: 130000, purchase_price: 130000, eval_amount: 6800000 }, + ]; + // 70000 + 130000 = 200,000 (수량 미곱산) + expect(computeBrokerSummary(items).totalBuy).toBe(200_000); + }); + + it('손익 = 총 평가 - 매입원가(avg_price × qty) — 손익은 수량 곱산 유지', () => { + const items = [ + { quantity: 10, avg_price: 100000, purchase_price: 90000, eval_amount: 1_200_000 }, + ]; + const s = computeBrokerSummary(items); + // cost_basis = 100000 × 10 = 1,000,000; profit = 1,200,000 - 1,000,000 = 200,000 + expect(s.totalEval).toBe(1_200_000); + expect(s.totalProfit).toBe(200_000); + expect(s.totalProfitRate).toBeCloseTo(20, 5); + expect(s.hasNullPrice).toBe(false); + }); + + it('eval_amount가 null인 종목이 있으면 hasNullPrice=true', () => { + const items = [ + { quantity: 10, avg_price: 100000, purchase_price: 100000, eval_amount: null }, + ]; + expect(computeBrokerSummary(items).hasNullPrice).toBe(true); + }); +});