// Portfolio.jsx — 포트폴리오 관리 (트레이딩 데스크 실무). 주식/채권 데스크 분리. // 화면 순서: 종합 요약 → 보유 현황 → 리스크 요인 분석 → 투자 시뮬레이션 + AI 최적화 → 스트레스 + 시나리오. // 외부 클릭 닫힘 훅 function useOutside(ref, onOut) { React.useEffect(() => {const h = (e) => {if (ref.current && !ref.current.contains(e.target)) onOut();}; document.addEventListener('mousedown', h);return () => document.removeEventListener('mousedown', h);}, [onOut]); } // 담당자 검색 콤보박스 (사번·이름·부서·팀) function PersonPicker({ value, onChange, people }) { const POOL = people || PEOPLE; const [open, setOpen] = React.useState(false); const [q, setQ] = React.useState(''); const ref = React.useRef(null); useOutside(ref, () => setOpen(false)); const sel = PMAP[value]; const list = POOL.filter((p) => {const s = (p.name + p.emp + p.dept + p.team).toLowerCase();return s.includes(q.toLowerCase());}); return (
{open &&
setQ(e.target.value)} placeholder="사번·이름·부서 검색" style={{ border: 0, outline: 'none', flex: 1, background: 'transparent', fontFamily: T.font, fontWeight: 500, fontSize: 12.5, color: T.c800 }} />
{list.map((p) => {const on = p.emp === value; return ;})} {list.length === 0 &&
검색 결과가 없어요.
}
}
); } // 조직 선택 드롭다운 function DeptPicker({ value, onChange, depts, asset }) { const LIST = depts || DEPT_LIST; const [open, setOpen] = React.useState(false); const ref = React.useRef(null); useOutside(ref, () => setOpen(false)); const countOf = (d) => PEOPLE.filter((p) => p.dept === d && (!asset || p.asset === asset)).length; return (
{open &&
{LIST.map((d) => {const on = d === value;const n = countOf(d); return ;})}
}
); } function KPIStat({ label, value, unit, sub, color, subColor }) { return (
{label}
{value} {unit && {unit}}
{sub &&
{sub}
}
); } // 섹션 헤더 (번호 스텝) function StepHead({ n, title, sub }) { return (
{n} {title} {sub && {sub}}
); } // 딜러 한도 현황 — SA(표준방법) 한도 · 투자한도 · 손절매한도 · 비율 한도. 한도 설정은 리스크총괄부, 딜러는 조회. function DealerLimitsCard({ emp, asset, pf }) { const lim = dealerLimit(emp); if (!lim) return null; const tiles = [ { label: 'SA 한도', pair: lim.sa, unit: '억', sub: '표준방법(SA) 산출 시장리스크' }, { label: '투자한도', pair: lim.invest, unit: '억', sub: '배정 투자가능금액 대비 집행액' }]; const zc = stopCounts(pf); const flagged = zc[1] + zc[2] + zc[3] + zc[4]; const delta = asset === '주식' ? Math.round(kospiDelta(pf)) : null; return ( {delta != null && 델타(KOSPI) {delta.toLocaleString()}억} {flagged > 0 && 손절 구간 도달 {flagged}종목} } />
{tiles.map((t, i) =>
{t.label}
= 100 ? T.alert : limUtil(t.pair) >= 80 ? T.warn : T.c900, letterSpacing: '-.02em', whiteSpace: 'nowrap' }}>{t.pair[0].toLocaleString()} / {t.pair[1].toLocaleString()}{t.unit}
{t.sub} · 소진 = 100 ? T.alert : limUtil(t.pair) >= 80 ? T.warn : T.c600 }}>{limUtil(t.pair).toFixed(1)}%
)}
손절매한도 · 구간 도달 종목 수
{STOP_ZONES.slice(1).map((z) => {const n = zc[z.v];const on = n > 0; return {z.l} {n};})}
정상 {zc[0]}종목 · 종목별 구간은 아래 보유 종목 참조
{lim.ratio &&
비율 한도 (단일 종목 집중도)
= 100 ? T.alert : (lim.ratio.max / lim.ratio.cap * 100) >= 80 ? T.warn : T.c900, letterSpacing: '-.02em' }}>{lim.ratio.max.toFixed(2)} / {lim.ratio.cap.toFixed(1)}%
시가총액 비중 기준 · 최대 {lim.ratio.n}
}
); } function Portfolio({ asset, preset }) { const people = peopleOf(asset); const depts = deptsOf(asset); const [mode, setMode] = React.useState(preset && preset.dept ? '조직' : '담당자'); const [emp, setEmp] = React.useState(preset && preset.emp ? preset.emp : people[0].emp); const [dept, setDept] = React.useState(preset && preset.dept ? preset.dept : depts[0]); const [tool, setTool] = React.useState('trade'); const [stress, setStress] = React.useState('single'); const [comp, setComp] = React.useState('종목'); const [holds, setHolds] = React.useState(null); const [val, setVal] = React.useState(null); React.useEffect(() => { if (preset) { if (preset.emp) { setMode('담당자'); setEmp(preset.emp); } else if (preset.dept) { setMode('조직'); setDept(preset.dept); } } }, [preset]); // 선택된 소유자 → 기준 포트폴리오 const ownerKey = mode === '담당자' ? 'p:' + emp : 'd:' + dept; const owner = React.useMemo(() => { if (mode === '담당자') {const p = PMAP[emp];return { kind: 'person', name: p.name, sub: `${p.dept} · ${p.team} · 사번 ${p.emp}`, title: p.title, base: p.pf };} const dp = deptPortfolio(dept, asset);return { kind: 'dept', name: dept, sub: `${asset} 담당 ${people.filter((p) => p.dept === dept).length}명 합산 · 부서 포트폴리오`, base: dp }; }, [mode, emp, dept]); // 소유자 변경 시 working holds·평가액 리셋 React.useEffect(() => {setHolds(owner.base.holds.map((h) => ({ ...h })));setVal(owner.base.value);setTool('trade');}, [ownerKey]); const pf = { holds: holds || owner.base.holds, value: val != null ? val : owner.base.value, limit: owner.base.limit }; const m = summarize(pf); const contribs = riskContribs(pf.holds); const cmap = {};contribs.forEach((c) => cmap[c.id] = c.frac); const commit = React.useCallback((h, v) => {setHolds(h);if (v != null) setVal(v);}, []); const dirty = holds && JSON.stringify(holds) !== JSON.stringify(owner.base.holds.map((h) => ({ ...h }))) || val != null && Math.round(val) !== Math.round(owner.base.value); // 구성 데이터 const compKeys = asset === '주식' ? ['종목', '국가', '섹터'] : ['종목', '국가', '등급']; const compData = React.useMemo(() => { if (comp === '종목') return pf.holds.slice().sort((a, b) => b.w - a.w).map((h, i) => ({ label: UMAP[h.id].name, value: h.w, color: PAL[i % PAL.length] })); const keyOf = comp === '국가' ? (u) => u.country : comp === '섹터' ? (u) => u.sector : (u) => u.rating || '기타'; const g = {};pf.holds.forEach((h) => {const k = keyOf(UMAP[h.id]);g[k] = (g[k] || 0) + h.w;}); return Object.entries(g).sort((a, b) => b[1] - a[1]).map(([label, value], i) => ({ label, value: +value.toFixed(1), color: comp === '국가' ? COUNTRY_HUE[label] || PAL[i % PAL.length] : PAL[i % PAL.length] })); }, [pf.holds, comp]); // AI 컨텍스트 등록 React.useEffect(() => { const top = pf.holds.slice().sort((a, b) => (cmap[b.id] || 0) - (cmap[a.id] || 0))[0]; window.setAIContext && window.setAIContext({ screen: `${asset} 포트폴리오 관리 · ${owner.name}`, summary: `${owner.sub} · 평가액 ${fmtEok(pf.value)}`, facts: [ `전체 수익률(연) ${m.ret.toFixed(1)}%`, `포트폴리오 리스크(CVaR 99%/1일) ${fmtEok(m.risk)}`, `리스크 한도 ${fmtEok(pf.limit)} · 잔여 ${fmtEok(m.remain)} · 소진율 ${m.util.toFixed(1)}%`, top ? `위험기여 1위 ${UMAP[top.id].name} (${(cmap[top.id] * 100).toFixed(0)}%)` : '']. filter(Boolean), suggestions: asset === '주식' ? ['국가별 리스크 기여가 가장 큰 곳은?', '이 포트폴리오에서 리스크 기여가 큰 종목은?', 'COVID 시나리오에서 예상 손실은?'] : ['Net DV01이 큰 포지션은?', '금리 100bp 상승 시 손실은?', '신용 스프레드 노출을 줄이려면?'] }); }, [ownerKey, holds, val]); const reset = () => {setHolds(owner.base.holds.map((h) => ({ ...h })));setVal(owner.base.value);}; return (
{/* 선택 바 */}
{asset} 포트폴리오 불러오기
{mode === '담당자' ? : }
{dirty && 편집됨} {dirty && 원장 복원} 기준 {FIRM.asOf}
{/* ① 종합 요약 */}
{owner.kind === 'dept' ? : owner.name[0]}
{owner.name}{owner.title && {owner.title}}
{owner.sub}
{asset} 데스크 = 100 ? 'alert' : m.util >= 80 ? 'warn' : 'ok'} dot>{m.util >= 100 ? '한도 초과' : m.util >= 80 ? '한도 임박' : '한도 내 정상'}
0 ? '+' : '') + m.ret.toFixed(1)} unit="%" color={dirColor(m.ret)} sub="가중평균 기대수익" />
= 80 ? T.warn : T.c400} /> {asset === '주식' &&
}
한도 소진 = 100 ? T.alert : m.util >= 80 ? T.warn : T.c700 }}>{m.util.toFixed(1)}%
한도 {Math.round(pf.limit).toLocaleString()}억 · 잔여 {Math.round(m.remain).toLocaleString()}억
{mode === '담당자' && } {/* ② 포트폴리오(보유 종목) 현황 */}
} />
{asset} {pf.holds.length}종목} /> {const u = UMAP[h.id];return { _key: h.id, id: h.id, name: u.name, country: u.country, sector: u.sector, rating: u.rating, dur: u.dur, w: h.w, ret: u.ret, vol: u.vol, ctr: (cmap[h.id] || 0) * 100, ctrAmt: (cmap[h.id] || 0) * m.risk, dv01: u.type === '채권' ? pf.value * h.w / 100 * u.dur * 0.0001 : null };})} cols={[ { k: 'name', t: '종목', w: '17%', render: (r) => {r.name} }, { k: 'country', t: '국가', w: 58, render: (r) => {r.country} }, ...(asset === '주식' ? [ { k: 'sector', t: '섹터', render: (r) => {r.sector} }, { k: 'fac', t: '주 노출 요인', render: (r) => {mainFactorsOf(r.id).map((fid) => {const f = FMAP[fid]; return {f.name === '변동성·개별' ? '변동성' : f.name};})}, sortVal: (r) => mainFactorsOf(r.id)[0] }, ] : [ { k: 'rating', t: '등급', w: 58, render: (r) => {r.rating} }, { k: 'dur', t: '듀레이션', num: true, render: (r) => {r.dur.toFixed(1)}Y, sortVal: (r) => r.dur }, { k: 'dv01', t: 'DV01', num: true, render: (r) => {bfMn(r.dv01)}백만, sortVal: (r) => r.dv01 }, ]), { k: 'w', t: '비중', num: true, render: (r) => {r.w.toFixed(1)}%, sortVal: (r) => r.w }, { k: 'ret', t: '수익률', num: true, render: (r) => , sortVal: (r) => r.ret }, { k: 'stopz', t: '손절 구간', num: true, render: (r) => , sortVal: (r) => stopZoneOf(r.ret) * 1000 - r.ret }, { k: 'ctr', t: '리스크 기여', num: true, render: (r) => 20 ? T.alert : r.ctr > 12 ? T.warn : T.c400} w={48} />{r.ctr.toFixed(1)}%, sortVal: (r) => r.ctr }, { k: 'ctrAmt', t: '기여 위험액', num: true, render: (r) => {Math.round(r.ctrAmt).toLocaleString()}억, sortVal: (r) => r.ctrAmt }] } />
{/* ③ 리스크 요인 분석 */} {asset === '주식' ? : } {/* ④ 투자 시뮬레이션 + AI 최적화 */} } />
{tool === 'trade' && } {tool === 'opt' && }
{/* ⑤ 스트레스 테스트 */} } />
{stress === 'single' && } {stress === 'scenario' && }
); } Object.assign(window, { Portfolio, PersonPicker, DeptPicker, KPIStat, StepHead, DealerLimitsCard });