fix(stock): 총 매입을 각 종목 매입가의 단순 합으로 표시
요약카드(백엔드 매입가×수량)와 증권사별(매입가 단순 합) 총 매입이 서로 달라 혼란. 박재오 정의대로 총 매입 = Σ매입가(수량 미곱산)로 통일. getBrokerSummary를 stockUtils.computeBrokerSummary로 추출(테스트 5건), usePortfolio가 portfolioSummary.total_buy를 프론트 단순 합으로 override해 요약카드·증권사별·AI 프롬프트가 동일 값 사용. 손익은 avg_price×수량 유지. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import {
|
|||||||
getPortfolio, addPortfolio, updatePortfolio, deletePortfolio,
|
getPortfolio, addPortfolio, updatePortfolio, deletePortfolio,
|
||||||
upsertCash, deleteCash,
|
upsertCash, deleteCash,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import { emptyPortfolioForm } from '../stockUtils';
|
import { emptyPortfolioForm, computeBrokerSummary } from '../stockUtils';
|
||||||
|
|
||||||
export default function usePortfolio() {
|
export default function usePortfolio() {
|
||||||
const [portfolio, setPortfolio] = useState(null);
|
const [portfolio, setPortfolio] = useState(null);
|
||||||
@@ -38,7 +38,12 @@ export default function usePortfolio() {
|
|||||||
|
|
||||||
/* derived */
|
/* derived */
|
||||||
const portfolioHoldings = portfolio?.holdings ?? [];
|
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 cashList = portfolio?.cash ?? [];
|
||||||
const totalCash = portfolioSummary.total_cash ?? null;
|
const totalCash = portfolioSummary.total_cash ?? null;
|
||||||
const totalAssets = portfolioSummary.total_assets ?? null;
|
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||||
@@ -69,23 +74,7 @@ export default function usePortfolio() {
|
|||||||
return map;
|
return map;
|
||||||
}, [brokerGroups]);
|
}, [brokerGroups]);
|
||||||
|
|
||||||
const getBrokerSummary = (items) => {
|
const getBrokerSummary = computeBrokerSummary;
|
||||||
// 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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
/* loaders */
|
/* loaders */
|
||||||
const loadPortfolio = useCallback(async () => {
|
const loadPortfolio = useCallback(async () => {
|
||||||
|
|||||||
@@ -125,6 +125,25 @@ export const emptySellForm = () => ({
|
|||||||
sold_at: toLocalDatetimeValue(new Date().toISOString()),
|
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 ─────────────────────────────────────────────────────── */
|
/* ── TAB IDs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export const TAB_PORTFOLIO = 'portfolio';
|
export const TAB_PORTFOLIO = 'portfolio';
|
||||||
|
|||||||
48
src/pages/stock/stockUtils.test.js
Normal file
48
src/pages/stock/stockUtils.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user