요약카드(백엔드 매입가×수량)와 증권사별(매입가 단순 합) 총 매입이 서로 달라 혼란. 박재오 정의대로 총 매입 = Σ매입가(수량 미곱산)로 통일. getBrokerSummary를 stockUtils.computeBrokerSummary로 추출(테스트 5건), usePortfolio가 portfolioSummary.total_buy를 프론트 단순 합으로 override해 요약카드·증권사별·AI 프롬프트가 동일 값 사용. 손익은 avg_price×수량 유지. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.5 KiB
JavaScript
152 lines
5.5 KiB
JavaScript
/* ── helpers ─────────────────────────────────────────────────────── */
|
||
|
||
export 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);
|
||
};
|
||
|
||
export 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)}%`;
|
||
};
|
||
|
||
export const pickFirst = (...values) =>
|
||
values.find((value) => value !== undefined && value !== null && value !== '');
|
||
|
||
export const getQty = (item) =>
|
||
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
||
|
||
export const getBuyPrice = (item) =>
|
||
pickFirst(
|
||
item?.buy_price,
|
||
item?.avg_price,
|
||
item?.avg,
|
||
item?.purchase_price,
|
||
item?.buyPrice,
|
||
item?.price
|
||
);
|
||
|
||
export const getCurrentPrice = (item) =>
|
||
pickFirst(
|
||
item?.current_price,
|
||
item?.current,
|
||
item?.cur_price,
|
||
item?.now_price,
|
||
item?.market_price
|
||
);
|
||
|
||
export const getProfitRate = (item) =>
|
||
pickFirst(
|
||
item?.profit_rate,
|
||
item?.profitRate,
|
||
item?.profit_pct,
|
||
item?.profitPercent,
|
||
item?.pnl_rate,
|
||
item?.return_rate,
|
||
item?.yield
|
||
);
|
||
|
||
export const getProfitLoss = (item) =>
|
||
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
||
|
||
export 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 ──────────────────────────────────────────────── */
|
||
|
||
export const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||
|
||
export const profitColorClass = (numericValue) => {
|
||
if (numericValue > 0) return 'is-up';
|
||
if (numericValue < 0) return 'is-down';
|
||
if (numericValue === 0) return 'is-flat';
|
||
return '';
|
||
};
|
||
|
||
export const numFitClass = (text) => {
|
||
const len = String(text ?? '').length;
|
||
if (len >= 13) return 'is-fit-xs';
|
||
if (len >= 10) return 'is-fit-sm';
|
||
return '';
|
||
};
|
||
|
||
export const getVixLabel = (vix) => {
|
||
if (vix < 12) return '극히 낮음 (안일 주의)';
|
||
if (vix < 20) return '정상 (안정적)';
|
||
if (vix < 30) return '주의 (불확실성 증가)';
|
||
if (vix < 40) return '높음 (극도의 공포)';
|
||
return '극단 (패닉)';
|
||
};
|
||
|
||
export const getFgLabel = (score) => {
|
||
if (score <= 25) return '극단적 공포';
|
||
if (score <= 45) return '공포';
|
||
if (score <= 55) return '중립';
|
||
if (score <= 75) return '탐욕';
|
||
return '극단적 탐욕';
|
||
};
|
||
|
||
/* ── empty portfolio form ────────────────────────────────────────── */
|
||
|
||
export const emptyPortfolioForm = {
|
||
broker: '',
|
||
ticker: '',
|
||
name: '',
|
||
quantity: '',
|
||
avg_price: '',
|
||
purchase_price: '',
|
||
};
|
||
|
||
/* ── empty sell-history form ─────────────────────────────────────── */
|
||
|
||
export 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())}`;
|
||
};
|
||
|
||
export const emptySellForm = () => ({
|
||
broker: '',
|
||
ticker: '',
|
||
name: '',
|
||
quantity: '',
|
||
avg_price: '',
|
||
sell_price: '',
|
||
commission: '',
|
||
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';
|
||
export const TAB_REPORT = 'report';
|
||
export const TAB_ADVISOR = 'advisor';
|