// factorData.jsx — 리스크 요인 모델 (GIRR/CSR/EQ/FX/VOL) + 채권 요인 데이터(기준금리·스프레드·DV01·Greeks). // 요인 기여 위험액 = |단위충격 민감도| × 요인 일간표준변동 → CVaR 총액에 스케일 매칭. const FACTORS = [ { id: 'rate', name: '금리', code: 'GIRR', color: '#0A4FA3', icon: 'percent' }, { id: 'credit', name: '신용스프레드', code: 'CSR', color: '#C76A1F', icon: 'scale' }, { id: 'eq', name: '주식', code: 'EQ', color: '#0E7C5A', icon: 'trendUp' }, { id: 'fx', name: '환', code: 'FX', color: '#E0922A', icon: 'globe' }, { id: 'vol', name: '변동성·개별', code: 'VOL', color: '#6D5BD0', icon: 'sigma' }, ]; const FMAP = {}; FACTORS.forEach(f => FMAP[f.id] = f); // 요인 일간 표준변동 (금리/신용 bp, 주식/환 %) const FACTOR_DAILY_MOVE = { rate: 8, credit: 6, eq: 1.5, fx: 0.6 }; // 전일 실제 시장 변동 (MARKET 지표와 정합: UST +3bp, IG OAS +2bp, KOSPI -0.6%, USD/KRW +0.30%) const FACTOR_MKT_DELTA = { rate: 3, credit: 2, eq: -0.6, fx: 0.30 }; // 단위충격 민감도 (% 손익). rate/credit: +100bp 기준, eq: -10% 기준, fx: +10% 기준. function factorSens(pf) { const zero = { eq: 0, rateBp: 0, creditBp: 0, fxPct: 0, volX: 1 }; return { rate: simulate(pf, { ...zero, rateBp: 100 }).portPnl, credit: simulate(pf, { ...zero, creditBp: 100 }).portPnl, eq: simulate(pf, { ...zero, eq: -10 }).portPnl, fx: simulate(pf, { ...zero, fxPct: 10 }).portPnl, }; } // 요인별 기여 위험액 분해. returns [{id,name,code,color,amt,frac,sensAmt,sensLabel,dod}] function factorDecompose(pf) { const s = factorSens(pf); const m = summarize(pf); // 일간 표준변동 기준 요인 위험액 (억) const raw = { rate: Math.abs(s.rate) / 100 * FACTOR_DAILY_MOVE.rate, credit: Math.abs(s.credit) / 100 * FACTOR_DAILY_MOVE.credit, eq: Math.abs(s.eq) / 10 * FACTOR_DAILY_MOVE.eq, fx: Math.abs(s.fx) / 10 * FACTOR_DAILY_MOVE.fx, }; let amt = {}; Object.keys(raw).forEach(k => amt[k] = raw[k] / 100 * pf.value); let sum4 = Object.values(amt).reduce((a, b) => a + b, 0) || 1; // 스케일 보정: 4요인 합이 CVaR의 90%, 잔차(변동성/개별)가 10%가 되도록 const k = m.risk * 0.90 / sum4; Object.keys(amt).forEach(f => amt[f] *= k); amt.vol = m.risk * 0.10; // 민감도 금액 (억): 단위충격당 손익 const sensAmt = { rate: s.rate / 100 * pf.value, credit: s.credit / 100 * pf.value, eq: s.eq / 100 * pf.value, fx: s.fx / 100 * pf.value, vol: null, }; const sensLabel = { rate: '금리 +100bp', credit: '스프레드 +100bp', eq: '주식 −10%', fx: '원화 −10%', vol: '요인 설명 후 잔차', }; return FACTORS.map(f => ({ ...f, amt: amt[f.id], frac: amt[f.id] / m.risk, sensAmt: sensAmt[f.id], sensLabel: sensLabel[f.id], // 전일대비: 전일 시장변동 대비 표준변동 비율로 근사 dod: f.id === 'vol' ? 0 : amt[f.id] * (FACTOR_MKT_DELTA[f.id] / FACTOR_DAILY_MOVE[f.id]) * 0.4, })).sort((a, b) => b.amt - a.amt); } // 손익요인분해 — 보고서 요약용. 전일 시장 Δ × 민감도 → 요인별 손익 (억). function pnlExplain(pf) { const s = factorSens(pf); const pct = { rate: s.rate * FACTOR_MKT_DELTA.rate / 100, credit: s.credit * FACTOR_MKT_DELTA.credit / 100, eq: s.eq * (-FACTOR_MKT_DELTA.eq) / 10, fx: s.fx * FACTOR_MKT_DELTA.fx / 10, }; pct.vol = -0.018; // 개별 잔차 (요인 미설명분) const items = FACTORS.map(f => ({ ...f, pnl: pct[f.id] / 100 * pf.value, pct: pct[f.id] })); const total = items.reduce((a, b) => a + b.pnl, 0); const losses = items.filter(i => i.pnl < 0); const lossSum = losses.reduce((a, b) => a + b.pnl, 0); const top = losses.slice().sort((a, b) => a.pnl - b.pnl)[0]; const mktText = { rate: `금리 +${FACTOR_MKT_DELTA.rate}bp 상승`, credit: `신용스프레드 +${FACTOR_MKT_DELTA.credit}bp 확대`, eq: `주가 ${FACTOR_MKT_DELTA.eq}% 하락`, fx: `원화 ${FACTOR_MKT_DELTA.fx}% 약세`, vol: '개별 종목 변동' }; const narration = top && lossSum < 0 ? `오늘 손실의 ${Math.round(top.pnl / lossSum * 100)}%는 ${mktText[top.id]}에서 발생했어요.` : '오늘은 요인 전반에서 유의미한 손실이 발생하지 않았어요.'; return { items, total, start: pf.value, end: pf.value + total, narration }; } // 자산군 분류 function assetClassOf(u) { if (u.type === '주식') return u.fx ? '해외주식' : '국내주식'; return u.sector === '해외채권' ? '해외채권' : '국내채권'; } const ASSET_CLASSES = ['국내채권', '해외채권', '국내주식', '해외주식']; // 보유 종목의 주 노출 요인 태그 (상위 1~2개) function mainFactorsOf(id) { const u = UMAP[id]; const out = []; if (u.type === '주식') { // 주식 포트폴리오에서 '주식(eq)' 노출은 자명하므로 표시하지 않고, 구분되는 요인(환·변동성)만 태그 if (u.fx) out.push('fx'); if (u.vol >= 30) out.push('vol'); } else { out.push('rate'); if ((u.credit || 0) >= 1) out.push('credit'); if (u.fx) out.push('fx'); } return out.slice(0, 2); } // 전사 합산 포트폴리오 (asset 필터 가능) function firmPortfolio(asset) { const people = asset ? PEOPLE.filter(p => p.asset === asset) : PEOPLE; const value = people.reduce((s, m) => s + m.pf.value, 0); const limit = people.reduce((s, m) => s + m.pf.limit, 0); const agg = {}; people.forEach(m => m.pf.holds.forEach(h => { agg[h.id] = (agg[h.id] || 0) + h.w * m.pf.value; })); const tot = Object.values(agg).reduce((a, b) => a + b, 0) || 1; const holds = Object.entries(agg).map(([id, v]) => ({ id, w: +(v / tot * 100).toFixed(2) })).sort((a, b) => b.w - a.w); return { holds, value, limit }; } // ── 그룹별 리스크 기여 (국가/섹터/종목 탑다운) ── // keyOf(u) 로 묶어 [{key, amt(억), frac, w(비중%)}] 반환. amt 합 = 전체 위험액. function groupContribs(pf, keyOf) { const m = summarize(pf); const rc = riskContribs(pf.holds); const fmapc = {}; rc.forEach(c => fmapc[c.id] = c.frac); const g = {}; pf.holds.forEach(h => { const u = UMAP[h.id]; const k = keyOf(u); if (!g[k]) g[k] = { key: k, amt: 0, w: 0 }; g[k].amt += (fmapc[h.id] || 0) * m.risk; g[k].w += h.w; }); return Object.values(g).map(x => ({ ...x, frac: x.amt / (m.risk || 1) })).sort((a, b) => b.amt - a.amt); } // 종목별 기여 [{id,name,...u, amt, frac, w}] function holdContribs(pf) { const m = summarize(pf); const rc = riskContribs(pf.holds); const fmapc = {}; rc.forEach(c => fmapc[c.id] = c.frac); return pf.holds.map(h => ({ ...UMAP[h.id], w: h.w, amt: (fmapc[h.id] || 0) * m.risk, frac: fmapc[h.id] || 0 })) .sort((a, b) => b.amt - a.amt); } // ── 채권: 나라별 기준금리 ── const POLICY_RATES = [ { country: '한국', bank: '한국은행', rate: 2.50, delta: -0.25, asOfMove: '2026.05 인하', seed: 71, mvol: 0.02 }, { country: '미국', bank: 'Fed', rate: 4.50, delta: 0, asOfMove: '동결 지속', seed: 13, mvol: 0.015 }, { country: '유로존', bank: 'ECB', rate: 2.15, delta: -0.25, asOfMove: '2026.04 인하', seed: 29, mvol: 0.02 }, { country: '일본', bank: 'BOJ', rate: 0.75, delta: 0.25, asOfMove: '2026.03 인상', seed: 47, mvol: 0.04 }, { country: '카타르', bank: 'QCB', rate: 4.65, delta: 0, asOfMove: '미국 동조', seed: 59, mvol: 0.012 }, ]; // ── 채권: 국가 × 등급 신용스프레드 (bp, 국채 대비 OAS) ── const SPREAD_GRADES = ['AAA', 'AA', 'A', 'BBB']; const SPREAD_MATRIX = [ { country: '한국', spreads: { 'AAA': 28, 'AA': 45, 'A': 88, 'BBB': 176 }, dod: { 'AAA': 1, 'AA': 1, 'A': 2, 'BBB': 4 } }, { country: '미국', spreads: { 'AAA': 34, 'AA': 52, 'A': 92, 'BBB': 148 }, dod: { 'AAA': 1, 'AA': 2, 'A': 2, 'BBB': 3 } }, { country: '프랑스', spreads: { 'AAA': 41, 'AA': 66, 'A': 108, 'BBB': 172 }, dod: { 'AAA': 0, 'AA': 1, 'A': 3, 'BBB': 5 } }, { country: '카타르', spreads: { 'AAA': 58, 'AA': 84, 'A': 132, 'BBB': 214 }, dod: { 'AAA': 2, 'AA': 3, 'A': 5, 'BBB': 7 } }, ]; // ── 채권: DV01 (금리 1bp 상승 시 평가손, 백만원 단위로 표시 권장) ── // 포지션 DV01(억) = 평가액 × 비중 × 듀레이션 × 0.0001 function dv01Rows(pf) { return pf.holds.map(h => { const u = UMAP[h.id]; if (u.type !== '채권') return null; const amt = pf.value * h.w / 100; return { id: h.id, name: u.name, country: u.country, rating: u.rating, dur: u.dur, w: h.w, amt, dv01: amt * u.dur * 0.0001 }; }).filter(Boolean).sort((a, b) => b.dv01 - a.dv01); } // 헤지 포함 넷 DV01. 헤지 = 국채선물 매도 (그로스의 ~28%) function dv01Summary(pf) { const rows = dv01Rows(pf); const gross = rows.reduce((s, r) => s + r.dv01, 0); const hedge = -gross * 0.28; return { rows, gross, hedge, net: gross + hedge }; } // 전사(채권) 넷 DV01 function firmDV01() { return dv01Summary(firmPortfolio('채권')); } // 기간구조(테너) 버킷별 DV01 const TENOR_BUCKETS = [ { id: 't1', label: '1년 이하', test: d => d <= 1 }, { id: 't2', label: '1~3년', test: d => d > 1 && d <= 3 }, { id: 't3', label: '3~5년', test: d => d > 3 && d <= 5 }, { id: 't5', label: '5~10년', test: d => d > 5 && d <= 10 }, { id: 't10', label: '10년 초과', test: d => d > 10 }, ]; function tenorDV01(pf) { const rows = dv01Rows(pf); return TENOR_BUCKETS.map(b => { const hit = rows.filter(r => b.test(r.dur)); return { ...b, dv01: hit.reduce((s, r) => s + r.dv01, 0), n: hit.length }; }); } // Greeks — 감마(컨벡시티 기반, 억/(100bp)²), 베가(콜 내재 옵션성, 억/vol 1%p) function greeksRows(pf) { return pf.holds.map(h => { const u = UMAP[h.id]; if (u.type !== '채권') return null; const amt = pf.value * h.w / 100; return { id: h.id, name: u.name, dur: u.dur, call: !!u.call, gamma: amt * (u.conv || 0) / 100, vega: u.call ? amt * (u.vega || 0) / 100 : 0 }; }).filter(Boolean).sort((a, b) => b.gamma - a.gamma); } // 요인 시황 지표 (MARKET과 정합 + KOSPI 추가) const FACTOR_MARKET = [ { fid: 'rate', label: '美 국채 10년물', sub: 'UST 10Y', value: '4.28', num: 4.28, unit: '%', delta: 0.03, dunit: '%p', ddig: 2, seed: 11, mvol: 0.012, shockText: '금리 +10bp', shock: (s, v) => s.rate / 10 / 100 * v }, { fid: 'credit', label: '신용 스프레드', sub: 'IG OAS', value: '92', num: 92, unit: 'bp', delta: 2, dunit: 'bp', ddig: 0, seed: 23, mvol: 0.030, shockText: 'OAS +10bp', shock: (s, v) => s.credit / 10 / 100 * v }, { fid: 'eq', label: '코스피 지수', sub: 'KOSPI', value: '2,748', num: 2748, unit: 'pt', delta: -16.6, dunit: 'pt', ddig: 1, seed: 41, mvol: 0.010, shockText: '주식 −1%', shock: (s, v) => s.eq / 10 / 100 * v }, { fid: 'fx', label: '원/달러 환율', sub: 'USD/KRW', value: '1,382.5', num: 1382.5, unit: '원', delta: 4.2, dunit: '원', ddig: 1, seed: 37, mvol: 0.006, shockText: '원화 −1%', shock: (s, v) => s.fx / 10 / 100 * v }, ]; Object.assign(window, { FACTORS, FMAP, FACTOR_DAILY_MOVE, FACTOR_MKT_DELTA, FACTOR_MARKET, ASSET_CLASSES, factorSens, factorDecompose, pnlExplain, mainFactorsOf, assetClassOf, firmPortfolio, groupContribs, holdContribs, POLICY_RATES, SPREAD_GRADES, SPREAD_MATRIX, dv01Rows, dv01Summary, firmDV01, TENOR_BUCKETS, tenorDV01, greeksRows, });