Compare commits
4 Commits
8fcfb6b000
...
299ce636ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 299ce636ff | |||
| 2b463682d5 | |||
| 1b16b40251 | |||
| 314702cb66 |
File diff suppressed because it is too large
Load Diff
157
src/pages/lotto/components/CombinedRecommendPanel.jsx
Normal file
157
src/pages/lotto/components/CombinedRecommendPanel.jsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ballClass, NumberRow, METHOD_META, METHOD_ORDER, SCORE_META, fmtKST } from '../lottoUtils';
|
||||||
|
|
||||||
|
const CombinedRecommendPanel = ({ combined, history, loading, histLoading, onRun, onCopy }) => {
|
||||||
|
const [histExpand, setHistExpand] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide lotto-combined">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">AI · 종합 추론</p>
|
||||||
|
<h3>종합 추론 번호 추천</h3>
|
||||||
|
<p className="lotto-panel__sub">
|
||||||
|
5가지 통계 기법(빈도·지문·갭·공동출현·다양성)을 가중 투표로 합산해
|
||||||
|
최적 6개 번호를 도출합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{loading && <span className="lotto-chip">분석 중…</span>}
|
||||||
|
<button className="button primary small" onClick={onRun} disabled={loading}>
|
||||||
|
{loading ? '추론 중…' : '🔮 종합 추론 실행'}
|
||||||
|
</button>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<button className="button ghost small" onClick={() => setHistExpand(p => !p)}>
|
||||||
|
이력 {history.length}건 {histExpand ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!combined && !loading && (
|
||||||
|
<p className="lotto-empty">버튼을 눌러 종합 추론을 실행하세요.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{combined && (
|
||||||
|
<>
|
||||||
|
{/* 기법별 추천 번호 */}
|
||||||
|
<div className="lotto-combined__methods">
|
||||||
|
{METHOD_ORDER.map((key) => {
|
||||||
|
const meta = METHOD_META[key];
|
||||||
|
const m = combined.methods?.[key];
|
||||||
|
if (!m) return null;
|
||||||
|
return (
|
||||||
|
<div key={key} className="lotto-combined__method">
|
||||||
|
<div className="lotto-combined__method-head">
|
||||||
|
<span className="lotto-combined__method-icon">{meta.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="lotto-combined__method-name" style={{ color: meta.color }}>
|
||||||
|
{meta.label}
|
||||||
|
<span className="lotto-combined__method-weight"> ({m.weight_pct}%)</span>
|
||||||
|
</p>
|
||||||
|
<p className="lotto-combined__method-desc">{meta.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-combined__method-nums">
|
||||||
|
{m.numbers.map((n) => {
|
||||||
|
const inFinal = combined.final_numbers.includes(n);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={n}
|
||||||
|
className={`lotto-ball ${ballClass(n).replace('lotto-ball ', '')} ${inFinal ? 'is-final' : 'is-dim'}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최종 추론 결과 */}
|
||||||
|
<div className="lotto-combined__final">
|
||||||
|
<div className="lotto-combined__final-head">
|
||||||
|
<span className="lotto-combined__final-badge">종합 추론 결과</span>
|
||||||
|
{combined.deduped && (
|
||||||
|
<span className="lotto-chip lotto-chip--muted">중복 (이미 저장됨)</span>
|
||||||
|
)}
|
||||||
|
<button className="button ghost small" onClick={() => onCopy(combined.final_numbers)}>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-combined__final-balls">
|
||||||
|
{combined.final_numbers.map((n) => {
|
||||||
|
const votes = combined.vote_counts?.[String(n)] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={n} className="lotto-combined__final-ball-wrap">
|
||||||
|
<span className={ballClass(n)}>{n}</span>
|
||||||
|
<span className="lotto-combined__vote-dots">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<span key={i} className={`lotto-combined__vote-dot ${i < votes ? 'is-on' : ''}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="lotto-combined__final-sub">
|
||||||
|
● 점은 해당 번호가 채택된 기법 수 (최대 5개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점수 바 */}
|
||||||
|
<div className="lotto-combined__scores">
|
||||||
|
<p className="lotto-combined__scores-title">조합 품질 점수</p>
|
||||||
|
{SCORE_META.map(({ key, label, color, weight }) => {
|
||||||
|
const val = combined.scores?.[key] ?? 0;
|
||||||
|
const pct = Math.round(val * 100);
|
||||||
|
return (
|
||||||
|
<div key={key} className="lotto-combined__score-row">
|
||||||
|
<span className="lotto-combined__score-label">{label}</span>
|
||||||
|
<span className="lotto-combined__score-weight">{weight}%</span>
|
||||||
|
<div className="lotto-combined__score-bar-wrap">
|
||||||
|
<div
|
||||||
|
className="lotto-combined__score-bar"
|
||||||
|
style={{ width: `${pct}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="lotto-combined__score-val">{pct}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="lotto-combined__score-total">
|
||||||
|
종합 점수 <strong>{Math.round((combined.scores?.score_total ?? 0) * 100)}</strong> / 100
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="lotto-combined__disclaimer">
|
||||||
|
※ 이 추천은 역대 통계 패턴 기반 참고 자료이며, 당첨을 보장하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추천 이력 */}
|
||||||
|
{histExpand && (
|
||||||
|
<div className="lotto-combined__history">
|
||||||
|
<p className="lotto-combined__history-title">종합 추론 이력</p>
|
||||||
|
{histLoading && <p className="lotto-empty">로딩 중…</p>}
|
||||||
|
{history.map((item) => (
|
||||||
|
<div key={item.id} className="lotto-combined__history-item">
|
||||||
|
<div className="lotto-combined__history-meta">
|
||||||
|
<span>#{item.id}</span>
|
||||||
|
<span>{fmtKST(item.created_at)}</span>
|
||||||
|
<span>기준 {item.based_on_draw ?? '-'}회</span>
|
||||||
|
</div>
|
||||||
|
<NumberRow nums={item.numbers} />
|
||||||
|
<button className="button ghost small" onClick={() => onCopy(item.numbers)}>복사</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CombinedRecommendPanel;
|
||||||
25
src/pages/lotto/components/ConfidenceRing.jsx
Normal file
25
src/pages/lotto/components/ConfidenceRing.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ConfidenceRing = ({ score }) => {
|
||||||
|
const r = 28, c = 2 * Math.PI * r;
|
||||||
|
const fill = (score / 100) * c;
|
||||||
|
const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5';
|
||||||
|
return (
|
||||||
|
<svg width="72" height="72" viewBox="0 0 72 72" className="lotto-confidence-ring" aria-hidden>
|
||||||
|
<circle cx="36" cy="36" r={r} stroke="rgba(255,255,255,0.08)" strokeWidth="6" fill="none" />
|
||||||
|
<circle
|
||||||
|
cx="36" cy="36" r={r}
|
||||||
|
stroke={color} strokeWidth="6" fill="none"
|
||||||
|
strokeDasharray={`${fill} ${c - fill}`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 36 36)"
|
||||||
|
/>
|
||||||
|
<text x="36" y="41" textAnchor="middle" fill={color} fontSize="16" fontWeight="600"
|
||||||
|
style={{ fontFamily: 'inherit' }}>
|
||||||
|
{score}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfidenceRing;
|
||||||
39
src/pages/lotto/components/FrequencyChart.jsx
Normal file
39
src/pages/lotto/components/FrequencyChart.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { buildFrequencySeries } from '../lottoUtils';
|
||||||
|
|
||||||
|
const FrequencyChart = ({ stats }) => {
|
||||||
|
const { series, max } = useMemo(() => buildFrequencySeries(stats?.frequency), [stats]);
|
||||||
|
const ticks = useMemo(() => [max, Math.round(max * 0.5), 0], [max]);
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lotto-chart">
|
||||||
|
<div className="lotto-chart__y">
|
||||||
|
<span>횟수</span>
|
||||||
|
<div className="lotto-chart__ticks">
|
||||||
|
{ticks.map((value) => <span key={value}>{value}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-chart__plot" role="list">
|
||||||
|
{series.map((item) => {
|
||||||
|
const showLabel = item.number === 1 || item.number % 5 === 0;
|
||||||
|
return (
|
||||||
|
<div key={item.number} className="lotto-chart__col" role="listitem">
|
||||||
|
<span
|
||||||
|
className="lotto-chart__bar"
|
||||||
|
style={{ height: `${(item.count / max) * 100}%` }}
|
||||||
|
title={`${item.number}번: ${item.count}회`}
|
||||||
|
aria-label={`${item.number}번 ${item.count}회`}
|
||||||
|
/>
|
||||||
|
<span className="lotto-chart__x" aria-hidden={!showLabel}>
|
||||||
|
{showLabel ? item.number : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FrequencyChart;
|
||||||
59
src/pages/lotto/components/MetricBlock.jsx
Normal file
59
src/pages/lotto/components/MetricBlock.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { toBucketEntries } from '../lottoUtils';
|
||||||
|
|
||||||
|
const MetricBlock = ({ title, metrics }) => {
|
||||||
|
if (!metrics) return null;
|
||||||
|
const buckets = toBucketEntries(metrics);
|
||||||
|
const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 0), 1) : 1;
|
||||||
|
const odd = Number(metrics.odd) || 0;
|
||||||
|
const even = Number(metrics.even) || 0;
|
||||||
|
const totalOE = odd + even || 1;
|
||||||
|
const oddPct = (odd / totalOE) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lotto-metrics">
|
||||||
|
<div className="lotto-metrics__head">
|
||||||
|
<p className="lotto-metrics__title">{title}</p>
|
||||||
|
<span className="lotto-metrics__sum">총 출현 횟수 {metrics.sum ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-cards">
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">최소 출현</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.min ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">최대 출현</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.max ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-metric-card">
|
||||||
|
<p className="lotto-metric-card__label">출현 편차</p>
|
||||||
|
<p className="lotto-metric-card__value">{metrics.range ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-odd-even">
|
||||||
|
<div className="lotto-odd-even__labels">
|
||||||
|
<span>홀 {odd}</span><span>짝 {even}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-odd-even__bar" aria-hidden>
|
||||||
|
<span className="lotto-odd-even__odd" style={{ width: `${oddPct}%` }} />
|
||||||
|
<span className="lotto-odd-even__even" style={{ width: `${100 - oddPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{buckets.length ? (
|
||||||
|
<div className="lotto-buckets">
|
||||||
|
{buckets.map(([label, value]) => (
|
||||||
|
<div key={label} className="lotto-bucket">
|
||||||
|
<span className="lotto-bucket__label">{label}</span>
|
||||||
|
<div className="lotto-bucket__bar" aria-hidden>
|
||||||
|
<span style={{ width: `${((Number(value) || 0) / maxBucket) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lotto-bucket__value">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetricBlock;
|
||||||
48
src/pages/lotto/components/PerformanceBanner.jsx
Normal file
48
src/pages/lotto/components/PerformanceBanner.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const PerformanceBanner = ({ perf }) => {
|
||||||
|
if (!perf || perf.total_checked === 0) return null;
|
||||||
|
const imp = perf.vs_random?.improvement_pct ?? 0;
|
||||||
|
const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0);
|
||||||
|
return (
|
||||||
|
<div className="lotto-perf-banner">
|
||||||
|
<span className="lotto-perf-banner__label">신뢰도 지표</span>
|
||||||
|
<div className="lotto-perf-banner__items">
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val">{perf.total_checked}</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">검증 회차</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val">{(perf.avg_correct ?? 0).toFixed(1)}</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">평균 일치수</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className={`lotto-perf-banner__val ${imp > 0 ? 'is-pos' : ''}`}>
|
||||||
|
{imp > 0 ? '+' : ''}{imp.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">무작위 대비</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val">
|
||||||
|
{((perf.rate_3plus ?? 0) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">3개↑ 일치율</span>
|
||||||
|
</div>
|
||||||
|
{prizeHits > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="lotto-perf-banner__divider" />
|
||||||
|
<div className="lotto-perf-banner__item">
|
||||||
|
<span className="lotto-perf-banner__val is-prize">{prizeHits}건</span>
|
||||||
|
<span className="lotto-perf-banner__lbl">3~5등 당첨</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformanceBanner;
|
||||||
83
src/pages/lotto/components/PersonalAnalysisPanel.jsx
Normal file
83
src/pages/lotto/components/PersonalAnalysisPanel.jsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NumberRow } from '../lottoUtils';
|
||||||
|
|
||||||
|
const PersonalAnalysisPanel = ({ data, loading }) => {
|
||||||
|
const zones = Object.entries(data?.pattern?.zone_avg ?? {});
|
||||||
|
const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">My Pattern</p>
|
||||||
|
<h3>내 번호 패턴</h3>
|
||||||
|
{data && data.total_analyzed > 0 && (
|
||||||
|
<p className="lotto-panel__sub">총 {data.total_analyzed}회 추천 기반 분석</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(loading || !data || data.total_analyzed === 0) ? (
|
||||||
|
<p className="lotto-empty">
|
||||||
|
{loading ? '불러오는 중...' : '추천 이력이 없습니다.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="lotto-analysis">
|
||||||
|
<div className="lotto-analysis__row">
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
내가 자주 선택한 번호 <span>TOP 10</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={data.top_picks ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">선택 성향</p>
|
||||||
|
<div className="lotto-personal-tendency">
|
||||||
|
{data.vs_draw_avg?.odd_tendency && (
|
||||||
|
<span className="lotto-personal-tendency__badge">
|
||||||
|
{data.vs_draw_avg.odd_tendency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.vs_draw_avg?.sum_tendency && (
|
||||||
|
<span className="lotto-personal-tendency__badge">
|
||||||
|
{data.vs_draw_avg.sum_tendency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lotto-analysis__stats">
|
||||||
|
<span>홀수 평균 <strong>{data.pattern?.avg_odd_count?.toFixed(1)}</strong></span>
|
||||||
|
<span>합계 평균 <strong>{data.pattern?.avg_sum?.toFixed(1)}</strong></span>
|
||||||
|
<span>
|
||||||
|
연속번호 포함률{' '}
|
||||||
|
<strong>
|
||||||
|
{((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}%
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zones.length > 0 && (
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">구간별 선택 비율</p>
|
||||||
|
<div className="lotto-buckets">
|
||||||
|
{zones.map(([zone, avg]) => (
|
||||||
|
<div key={zone} className="lotto-bucket">
|
||||||
|
<span className="lotto-bucket__label">{zone}</span>
|
||||||
|
<div className="lotto-bucket__bar" aria-hidden>
|
||||||
|
<span style={{ width: `${((Number(avg) || 0) / maxZone) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lotto-bucket__value">{Number(avg).toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalAnalysisPanel;
|
||||||
173
src/pages/lotto/components/PurchasePanel.jsx
Normal file
173
src/pages/lotto/components/PurchasePanel.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { fmtWon } from '../lottoUtils';
|
||||||
|
|
||||||
|
const PurchasePanel = ({
|
||||||
|
records, stats, loading,
|
||||||
|
formOpen, form, formSaving, formError, editId,
|
||||||
|
onFormOpen, onFormClose, onFormChange, onFormSubmit,
|
||||||
|
onEditStart, onDelete,
|
||||||
|
}) => {
|
||||||
|
const winRate = stats?.total_records > 0
|
||||||
|
? ((stats.prize_count / stats.total_records) * 100).toFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Purchase Tracker</p>
|
||||||
|
<h3>구매 기록</h3>
|
||||||
|
<p className="lotto-panel__sub">구매 내역 기록 및 수익률 추적</p>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{loading && <span className="lotto-chip">로딩 중</span>}
|
||||||
|
<button className="button small" onClick={onFormOpen} disabled={formOpen}>
|
||||||
|
+ 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 바 */}
|
||||||
|
{stats && stats.total_records > 0 && (
|
||||||
|
<div className="lotto-purchase-stats">
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_invested)}</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">총 투자</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{fmtWon(stats.total_prize)}</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">총 당첨금</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className={`lotto-purchase-stat__val ${netColor}`}>
|
||||||
|
{(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)}
|
||||||
|
</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">순손익</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{stats.return_rate?.toFixed(1)}%</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">회수율</span>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val">{winRate}%</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">당첨률</span>
|
||||||
|
</div>
|
||||||
|
{stats.max_prize > 0 && (
|
||||||
|
<div className="lotto-purchase-stat">
|
||||||
|
<span className="lotto-purchase-stat__val is-prize">{fmtWon(stats.max_prize)}</span>
|
||||||
|
<span className="lotto-purchase-stat__lbl">최대 당첨금</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 입력 폼 */}
|
||||||
|
{formOpen && (
|
||||||
|
<form className="lotto-purchase-form" onSubmit={onFormSubmit}>
|
||||||
|
<p className="lotto-purchase-form__title">
|
||||||
|
{editId != null ? '기록 수정' : '구매 기록 추가'}
|
||||||
|
</p>
|
||||||
|
<div className="lotto-purchase-form__grid">
|
||||||
|
<label className="lotto-field">
|
||||||
|
회차
|
||||||
|
<input
|
||||||
|
type="number" min={1}
|
||||||
|
value={form.draw_no}
|
||||||
|
onChange={(e) => onFormChange('draw_no', e.target.value)}
|
||||||
|
placeholder="예: 1181"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field">
|
||||||
|
구매금액
|
||||||
|
<input
|
||||||
|
type="number" step={1000} min={1000}
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => onFormChange('amount', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field">
|
||||||
|
세트 수
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={20}
|
||||||
|
value={form.sets}
|
||||||
|
onChange={(e) => onFormChange('sets', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field">
|
||||||
|
당첨금
|
||||||
|
<input
|
||||||
|
type="number" min={0}
|
||||||
|
value={form.prize}
|
||||||
|
onChange={(e) => onFormChange('prize', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="lotto-field lotto-purchase-form__note">
|
||||||
|
메모
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => onFormChange('note', e.target.value)}
|
||||||
|
placeholder="예: 5등 1개"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{formError && (
|
||||||
|
<p className="lotto-empty" style={{ color: '#f9b6b1' }}>{formError}</p>
|
||||||
|
)}
|
||||||
|
<div className="lotto-purchase-form__actions">
|
||||||
|
<button type="button" className="button ghost small" onClick={onFormClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="button primary small" disabled={formSaving}>
|
||||||
|
{formSaving ? '저장 중...' : editId != null ? '수정 완료' : '추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기록 목록 */}
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<p className="lotto-empty">구매 기록이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="lotto-purchase-list">
|
||||||
|
<div className="lotto-purchase-list__head">
|
||||||
|
<span>회차</span>
|
||||||
|
<span>투자금</span>
|
||||||
|
<span>당첨금</span>
|
||||||
|
<span>손익</span>
|
||||||
|
<span>메모</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{records.map((rec) => {
|
||||||
|
const net = (rec.prize ?? 0) - (rec.amount ?? 0);
|
||||||
|
return (
|
||||||
|
<div key={rec.id} className="lotto-purchase-row">
|
||||||
|
<span className="lotto-purchase-row__drw">{rec.draw_no}회</span>
|
||||||
|
<span>{fmtWon(rec.amount)}</span>
|
||||||
|
<span className={(rec.prize ?? 0) > 0 ? 'is-prize' : ''}>
|
||||||
|
{fmtWon(rec.prize)}
|
||||||
|
</span>
|
||||||
|
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||||
|
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||||
|
</span>
|
||||||
|
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||||
|
<div className="lotto-purchase-row__actions">
|
||||||
|
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button className="button danger small" onClick={() => onDelete(rec.id)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PurchasePanel;
|
||||||
142
src/pages/lotto/components/ReportPanel.jsx
Normal file
142
src/pages/lotto/components/ReportPanel.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { NumberRow } from '../lottoUtils';
|
||||||
|
import ConfidenceRing from './ConfidenceRing';
|
||||||
|
|
||||||
|
const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => {
|
||||||
|
const [histExpand, setHistExpand] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Weekly Report</p>
|
||||||
|
<h3>이번 주 공략 리포트</h3>
|
||||||
|
{report && (
|
||||||
|
<p className="lotto-panel__sub">
|
||||||
|
{report.target_drw_no}회 대상 · {report.based_on_draw}회 기준
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lotto-panel__actions">
|
||||||
|
{loading && <span className="lotto-chip">로딩 중</span>}
|
||||||
|
<button className="button ghost small" onClick={onRefresh} disabled={loading}>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
{history?.length > 0 && (
|
||||||
|
<button className="button ghost small" onClick={() => setHistExpand((p) => !p)}>
|
||||||
|
지난 리포트 {histExpand ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지난 리포트 목록 */}
|
||||||
|
{histExpand && history?.length > 0 && (
|
||||||
|
<div className="lotto-report-history">
|
||||||
|
{history.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.drw_no}
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => { onSelectDrw(h.drw_no); setHistExpand(false); }}
|
||||||
|
>
|
||||||
|
{h.drw_no}회
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!report && !loading && (
|
||||||
|
<p className="lotto-empty">리포트 데이터가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
{loading && !report && (
|
||||||
|
<p className="lotto-empty">불러오는 중...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
{/* 신뢰도 + 패턴 요약 */}
|
||||||
|
<div className="lotto-report-top">
|
||||||
|
<div className="lotto-report-confidence">
|
||||||
|
<ConfidenceRing score={report.confidence_score ?? 0} />
|
||||||
|
<div>
|
||||||
|
<p className="lotto-report-confidence__title">신뢰도 점수</p>
|
||||||
|
<div className="lotto-report-confidence__factors">
|
||||||
|
{Object.entries(report.confidence_factors ?? {}).map(([k, v]) => (
|
||||||
|
<div key={k} className="lotto-report-confidence__factor">
|
||||||
|
<span className="lotto-report-confidence__factor-lbl">
|
||||||
|
{k === 'data_volume' ? '데이터 충분도'
|
||||||
|
: k === 'pattern_consistency' ? '패턴 안정성'
|
||||||
|
: k === 'recent_trend' ? '최근 트렌드' : k}
|
||||||
|
</span>
|
||||||
|
<div className="lotto-pick__bar">
|
||||||
|
<span style={{ width: `${v}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="lotto-report-confidence__factor-val">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lotto-report-pattern">
|
||||||
|
<p className="lotto-report-pattern__title">최근 패턴</p>
|
||||||
|
<div className="lotto-report-pattern__stats">
|
||||||
|
<div className="lotto-report-pattern__stat">
|
||||||
|
<span>합계 평균</span>
|
||||||
|
<strong>{report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="lotto-report-pattern__stat">
|
||||||
|
<span>홀수 평균</span>
|
||||||
|
<strong>{report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
{(report.recent_pattern?.triple_appear ?? []).length > 0 && (
|
||||||
|
<div className="lotto-report-pattern__stat">
|
||||||
|
<span>3회 연속 출현</span>
|
||||||
|
<NumberRow nums={report.recent_pattern.triple_appear} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 핫 / 콜드 / 오버듀 */}
|
||||||
|
<div className="lotto-analysis__row">
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
🔥 핫 번호 <span>최근 10회 과출현</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={report.hot_numbers ?? []} />
|
||||||
|
</div>
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
🧊 콜드 번호 <span>역대 저빈도 10개</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={report.cold_numbers ?? []} />
|
||||||
|
</div>
|
||||||
|
<div className="lotto-analysis__group">
|
||||||
|
<p className="lotto-analysis__label">
|
||||||
|
⏰ 오버듀 <span>가장 오래 미출현</span>
|
||||||
|
</p>
|
||||||
|
<NumberRow nums={report.overdue_numbers ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전략 추천 세트 */}
|
||||||
|
{(report.recommended_sets ?? []).length > 0 && (
|
||||||
|
<div className="lotto-strategy-cards">
|
||||||
|
{report.recommended_sets.map((set, i) => (
|
||||||
|
<div key={i} className="lotto-strategy-card">
|
||||||
|
<p className="lotto-strategy-card__name">{set.strategy}</p>
|
||||||
|
<NumberRow nums={set.numbers} />
|
||||||
|
<p className="lotto-strategy-card__desc">{set.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportPanel;
|
||||||
162
src/pages/lotto/hooks/useLottoData.js
Normal file
162
src/pages/lotto/hooks/useLottoData.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getLatest, getStats, getBestPicks, getAnalysis,
|
||||||
|
getPerformanceStats, getLatestReport, getReportHistory, getReport,
|
||||||
|
getPersonalAnalysis, getCombinedRecommend, getCombinedHistory,
|
||||||
|
} from '../../../api';
|
||||||
|
import { readStatsCache, writeStatsCache } from '../lottoUtils';
|
||||||
|
|
||||||
|
export default function useLottoData() {
|
||||||
|
const [latest, setLatest] = useState(null);
|
||||||
|
const [stats, setStats] = useState(() => readStatsCache());
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [statsError, setStatsError] = useState('');
|
||||||
|
const [loading, setLoading] = useState({
|
||||||
|
latest: false, bestPicks: false, analysis: false,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [bestPicks, setBestPicks] = useState([]);
|
||||||
|
const [bestPicksExpanded, setBestPicksExpanded] = useState(false);
|
||||||
|
const [analysis, setAnalysis] = useState(null);
|
||||||
|
const [simulating, setSimulating] = useState(false);
|
||||||
|
const [simResult, setSimResult] = useState(null);
|
||||||
|
|
||||||
|
// 종합 추론
|
||||||
|
const [combined, setCombined] = useState(null);
|
||||||
|
const [combinedLoading, setCombinedLoading] = useState(false);
|
||||||
|
const [combinedHistory, setCombinedHistory] = useState([]);
|
||||||
|
const [combinedHistLoading, setCombinedHistLoading] = useState(false);
|
||||||
|
|
||||||
|
// 신뢰도·리포트·개인분석
|
||||||
|
const [perfStats, setPerfStats] = useState(null);
|
||||||
|
const [report, setReport] = useState(null);
|
||||||
|
const [reportHistory, setReportHistory] = useState([]);
|
||||||
|
const [reportLoading, setReportLoading] = useState(false);
|
||||||
|
const [personalAnalysis, setPersonalAnalysis] = useState(null);
|
||||||
|
const [personalLoading, setPersonalLoading] = useState(false);
|
||||||
|
|
||||||
|
const refreshLatest = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, latest: true }));
|
||||||
|
setError('');
|
||||||
|
try { setLatest(await getLatest()); }
|
||||||
|
catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setLoading((s) => ({ ...s, latest: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshStats = useCallback(async () => {
|
||||||
|
setStatsLoading(true); setStatsError('');
|
||||||
|
try {
|
||||||
|
const cached = readStatsCache();
|
||||||
|
if (cached && !stats) setStats(cached);
|
||||||
|
const data = await getStats();
|
||||||
|
if (!cached || cached.total_draws !== data?.total_draws) {
|
||||||
|
setStats(data); writeStatsCache(data);
|
||||||
|
}
|
||||||
|
} catch (e) { setStatsError(e?.message ?? String(e)); }
|
||||||
|
finally { setStatsLoading(false); }
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
const refreshBestPicks = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, bestPicks: true }));
|
||||||
|
try { setBestPicks((await getBestPicks(20)).items ?? []); }
|
||||||
|
catch {}
|
||||||
|
finally { setLoading((s) => ({ ...s, bestPicks: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAnalysis = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, analysis: true }));
|
||||||
|
try { setAnalysis(await getAnalysis()); }
|
||||||
|
catch {}
|
||||||
|
finally { setLoading((s) => ({ ...s, analysis: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshPerfStats = useCallback(async () => {
|
||||||
|
try { setPerfStats(await getPerformanceStats()); } catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshReport = useCallback(async () => {
|
||||||
|
setReportLoading(true);
|
||||||
|
try {
|
||||||
|
const [rep, hist] = await Promise.all([
|
||||||
|
getLatestReport(),
|
||||||
|
getReportHistory(10),
|
||||||
|
]);
|
||||||
|
setReport(rep);
|
||||||
|
setReportHistory(hist?.reports ?? []);
|
||||||
|
} catch {}
|
||||||
|
finally { setReportLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSpecificReport = useCallback(async (drwNo) => {
|
||||||
|
setReportLoading(true);
|
||||||
|
try { setReport(await getReport(drwNo)); }
|
||||||
|
catch {}
|
||||||
|
finally { setReportLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runCombinedRecommend = useCallback(async () => {
|
||||||
|
setCombinedLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getCombinedRecommend();
|
||||||
|
setCombined(data);
|
||||||
|
const hist = await getCombinedHistory(30);
|
||||||
|
setCombinedHistory(hist?.items ?? []);
|
||||||
|
} catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setCombinedLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCombinedHistory = useCallback(async () => {
|
||||||
|
setCombinedHistLoading(true);
|
||||||
|
try {
|
||||||
|
const hist = await getCombinedHistory(30);
|
||||||
|
setCombinedHistory(hist?.items ?? []);
|
||||||
|
} catch {}
|
||||||
|
finally { setCombinedHistLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshPersonalAnalysis = useCallback(async () => {
|
||||||
|
setPersonalLoading(true);
|
||||||
|
try { setPersonalAnalysis(await getPersonalAnalysis()); }
|
||||||
|
catch {}
|
||||||
|
finally { setPersonalLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSimulate = useCallback(async () => {
|
||||||
|
const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)');
|
||||||
|
if (!ok) return;
|
||||||
|
setSimulating(true); setSimResult(null); setError('');
|
||||||
|
try {
|
||||||
|
const { triggerSimulate } = await import('../../../api');
|
||||||
|
const data = await triggerSimulate();
|
||||||
|
setSimResult(data);
|
||||||
|
await refreshBestPicks();
|
||||||
|
} catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setSimulating(false); }
|
||||||
|
}, [refreshBestPicks]);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
refreshLatest();
|
||||||
|
refreshStats();
|
||||||
|
refreshBestPicks();
|
||||||
|
refreshAnalysis();
|
||||||
|
refreshPerfStats();
|
||||||
|
refreshReport();
|
||||||
|
refreshPersonalAnalysis();
|
||||||
|
loadCombinedHistory();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
latest, loading, error, setError,
|
||||||
|
stats, statsLoading, statsError, refreshStats,
|
||||||
|
refreshLatest,
|
||||||
|
bestPicks, bestPicksExpanded, setBestPicksExpanded, refreshBestPicks,
|
||||||
|
analysis, refreshAnalysis,
|
||||||
|
simulating, simResult, onSimulate,
|
||||||
|
combined, combinedLoading, combinedHistory, combinedHistLoading,
|
||||||
|
runCombinedRecommend,
|
||||||
|
perfStats,
|
||||||
|
report, reportHistory, reportLoading, refreshReport, loadSpecificReport,
|
||||||
|
personalAnalysis, personalLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/pages/lotto/hooks/useManualRecommend.js
Normal file
75
src/pages/lotto/hooks/useManualRecommend.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { deleteHistory, getHistory, recommend } from '../../../api';
|
||||||
|
import { buildMetricsFromHistory } from '../lottoUtils';
|
||||||
|
|
||||||
|
export default function useManualRecommend() {
|
||||||
|
const [params, setParams] = useState({
|
||||||
|
recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5,
|
||||||
|
});
|
||||||
|
const presets = useMemo(() => [
|
||||||
|
{ name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 },
|
||||||
|
{ name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 },
|
||||||
|
{ name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 },
|
||||||
|
{ name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 },
|
||||||
|
], []);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||||
|
const historyEndRef = useRef(null);
|
||||||
|
const prevHistoryExpandedRef = useRef(false);
|
||||||
|
const [loading, setLoading] = useState({ recommend: false, history: false });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]);
|
||||||
|
const visibleHistory = historyExpanded ? history : history.slice(0, 5);
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, history: true }));
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const limit = 100; let offset = 0; const allItems = [];
|
||||||
|
while (true) {
|
||||||
|
const data = await getHistory(limit, offset);
|
||||||
|
const items = data.items ?? [];
|
||||||
|
allItems.push(...items);
|
||||||
|
if (items.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
setHistory(allItems);
|
||||||
|
} catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setLoading((s) => ({ ...s, history: false })); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRecommend = useCallback(async () => {
|
||||||
|
setLoading((s) => ({ ...s, recommend: true })); setError('');
|
||||||
|
try { const data = await recommend(params); setResult(data); await refreshHistory(); }
|
||||||
|
catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
finally { setLoading((s) => ({ ...s, recommend: false })); }
|
||||||
|
}, [params, refreshHistory]);
|
||||||
|
|
||||||
|
const onDelete = useCallback(async (id) => {
|
||||||
|
if (!confirm(`히스토리 #${id}를 삭제할까요?`)) return;
|
||||||
|
setError('');
|
||||||
|
try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); }
|
||||||
|
catch (e) { setError(e?.message ?? String(e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyExpanded && !prevHistoryExpandedRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevHistoryExpandedRef.current = historyExpanded;
|
||||||
|
}, [historyExpanded, visibleHistory.length]);
|
||||||
|
|
||||||
|
useEffect(() => { refreshHistory(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
params, setParams, presets,
|
||||||
|
result, history, historyExpanded, setHistoryExpanded,
|
||||||
|
historyEndRef, loading, error, setError,
|
||||||
|
historyMetrics, visibleHistory,
|
||||||
|
refreshHistory, onRecommend, onDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/pages/lotto/hooks/usePurchases.js
Normal file
105
src/pages/lotto/hooks/usePurchases.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||||
|
} from '../../../api';
|
||||||
|
import { emptyPurchaseForm } from '../lottoUtils';
|
||||||
|
|
||||||
|
export default function usePurchases() {
|
||||||
|
const [purchases, setPurchases] = useState([]);
|
||||||
|
const [purchaseStats, setPurchaseStats] = useState(null);
|
||||||
|
const [purchaseLoading, setPurchaseLoading] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [purchaseFormOpen, setPurchaseFormOpen] = useState(false);
|
||||||
|
const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm);
|
||||||
|
const [purchaseFormSaving, setPurchaseFormSaving] = useState(false);
|
||||||
|
const [purchaseFormError, setPurchaseFormError] = useState('');
|
||||||
|
const [purchaseEditId, setPurchaseEditId] = useState(null);
|
||||||
|
|
||||||
|
const refreshPurchases = useCallback(async () => {
|
||||||
|
setPurchaseLoading(true);
|
||||||
|
try {
|
||||||
|
const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]);
|
||||||
|
setPurchases(recs?.records ?? []);
|
||||||
|
setPurchaseStats(st);
|
||||||
|
} catch {}
|
||||||
|
finally { setPurchaseLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormOpen = useCallback(() => {
|
||||||
|
setPurchaseEditId(null);
|
||||||
|
setPurchaseForm(emptyPurchaseForm());
|
||||||
|
setPurchaseFormError('');
|
||||||
|
setPurchaseFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormClose = useCallback(() => {
|
||||||
|
setPurchaseFormOpen(false);
|
||||||
|
setPurchaseEditId(null);
|
||||||
|
setPurchaseFormError('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormChange = useCallback((field, value) => {
|
||||||
|
setPurchaseForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseEditStart = useCallback((rec) => {
|
||||||
|
setPurchaseEditId(rec.id);
|
||||||
|
setPurchaseForm({
|
||||||
|
draw_no: String(rec.draw_no ?? ''),
|
||||||
|
amount: rec.amount ?? 5000,
|
||||||
|
sets: rec.sets ?? 5,
|
||||||
|
prize: rec.prize ?? 0,
|
||||||
|
note: rec.note ?? '',
|
||||||
|
});
|
||||||
|
setPurchaseFormError('');
|
||||||
|
setPurchaseFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurchaseFormSubmit = useCallback(async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPurchaseFormSaving(true); setPurchaseFormError('');
|
||||||
|
const payload = {
|
||||||
|
draw_no: Number(purchaseForm.draw_no),
|
||||||
|
amount: Number(purchaseForm.amount),
|
||||||
|
sets: Number(purchaseForm.sets),
|
||||||
|
prize: Number(purchaseForm.prize),
|
||||||
|
note: purchaseForm.note.trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (purchaseEditId != null) {
|
||||||
|
const updated = await updatePurchase(purchaseEditId, payload);
|
||||||
|
setPurchases((prev) =>
|
||||||
|
prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const saved = await addPurchase(payload);
|
||||||
|
setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
try { setPurchaseStats(await getPurchaseStats()); } catch {}
|
||||||
|
handlePurchaseFormClose();
|
||||||
|
} catch (err) {
|
||||||
|
setPurchaseFormError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setPurchaseFormSaving(false);
|
||||||
|
}
|
||||||
|
}, [purchaseForm, purchaseEditId, handlePurchaseFormClose]);
|
||||||
|
|
||||||
|
const handlePurchaseDelete = useCallback(async (id) => {
|
||||||
|
if (!confirm('이 구매 기록을 삭제할까요?')) return;
|
||||||
|
setPurchases((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
try {
|
||||||
|
await deletePurchase(id);
|
||||||
|
try { setPurchaseStats(await getPurchaseStats()); } catch {}
|
||||||
|
} catch { refreshPurchases(); }
|
||||||
|
}, [refreshPurchases]);
|
||||||
|
|
||||||
|
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchases, purchaseStats, purchaseLoading,
|
||||||
|
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||||
|
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||||
|
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
141
src/pages/lotto/lottoUtils.jsx
Normal file
141
src/pages/lotto/lottoUtils.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
로또 공통 유틸리티
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const fmtKST = (value) => value?.replace('T', ' ') ?? '';
|
||||||
|
|
||||||
|
export const fmtWon = (n) => {
|
||||||
|
if (n == null || isNaN(Number(n))) return '-';
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(Math.round(Number(n))) + '원';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ballClass = (n) => {
|
||||||
|
if (n <= 10) return 'lotto-ball range-a';
|
||||||
|
if (n <= 20) return 'lotto-ball range-b';
|
||||||
|
if (n <= 30) return 'lotto-ball range-c';
|
||||||
|
if (n <= 40) return 'lotto-ball range-d';
|
||||||
|
return 'lotto-ball range-e';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Ball = ({ n }) => <span className={ballClass(n)}>{n}</span>;
|
||||||
|
|
||||||
|
export const NumberRow = ({ nums }) => (
|
||||||
|
<div className="lotto-row">
|
||||||
|
{nums.map((n) => <Ball key={n} n={n} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
통계 헬퍼
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
export const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45'];
|
||||||
|
export const STATS_CACHE_KEY = 'lotto_stats_v1';
|
||||||
|
export const BEST_PICKS_DEFAULT_SHOW = 5;
|
||||||
|
|
||||||
|
export const readStatsCache = () => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STATS_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.frequency)) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeStatsCache = (data) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try { localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildFrequencySeries = (frequency) => {
|
||||||
|
const map = new Map();
|
||||||
|
(frequency ?? []).forEach((item) => {
|
||||||
|
const number = Number(item?.number);
|
||||||
|
const count = Number(item?.count) || 0;
|
||||||
|
if (Number.isFinite(number) && number >= 1 && number <= 45) map.set(number, count);
|
||||||
|
});
|
||||||
|
const series = Array.from({ length: 45 }, (_, idx) => ({
|
||||||
|
number: idx + 1, count: map.get(idx + 1) ?? 0,
|
||||||
|
}));
|
||||||
|
const max = Math.max(1, ...series.map((item) => item.count));
|
||||||
|
return { series, max };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetricsFromCounts = (counts) => {
|
||||||
|
if (!counts?.length) return null;
|
||||||
|
const total = counts.reduce((acc, v) => acc + v, 0);
|
||||||
|
if (!total) return null;
|
||||||
|
const min = Math.min(...counts), max = Math.max(...counts);
|
||||||
|
const odd = counts.reduce((acc, v, idx) => (idx % 2 === 0 ? acc + v : acc), 0);
|
||||||
|
const even = total - odd;
|
||||||
|
const buckets = {
|
||||||
|
'1-10': counts.slice(0, 10).reduce((a, b) => a + b, 0),
|
||||||
|
'11-20': counts.slice(10, 20).reduce((a, b) => a + b, 0),
|
||||||
|
'21-30': counts.slice(20, 30).reduce((a, b) => a + b, 0),
|
||||||
|
'31-40': counts.slice(30, 40).reduce((a, b) => a + b, 0),
|
||||||
|
'41-45': counts.slice(40, 45).reduce((a, b) => a + b, 0),
|
||||||
|
};
|
||||||
|
return { sum: total, min, max, range: max - min, odd, even, buckets };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetricsFromFrequency = (frequency) => {
|
||||||
|
if (!frequency?.length) return null;
|
||||||
|
const counts = Array.from({ length: 45 }, () => 0);
|
||||||
|
frequency.forEach((item) => {
|
||||||
|
const number = Number(item?.number), count = Number(item?.count) || 0;
|
||||||
|
if (number >= 1 && number <= 45) counts[number - 1] = count;
|
||||||
|
});
|
||||||
|
return buildMetricsFromCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetricsFromHistory = (items) => {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
const counts = Array.from({ length: 45 }, () => 0);
|
||||||
|
items.forEach((item) => {
|
||||||
|
(item?.numbers ?? []).forEach((value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
if (number >= 1 && number <= 45) counts[number - 1] += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return buildMetricsFromCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toBucketEntries = (metrics) => {
|
||||||
|
if (!metrics?.buckets) return [];
|
||||||
|
const ordered = bucketOrder
|
||||||
|
.filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key))
|
||||||
|
.map((key) => [key, metrics.buckets[key]]);
|
||||||
|
const rest = Object.entries(metrics.buckets)
|
||||||
|
.filter(([key]) => !bucketOrder.includes(key))
|
||||||
|
.sort((a, b) => Number(a[0].split('-')[0]) - Number(b[0].split('-')[0]));
|
||||||
|
return [...ordered, ...rest];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyPurchaseForm = () => ({ draw_no: '', amount: 5000, sets: 5, prize: 0, note: '' });
|
||||||
|
|
||||||
|
export const copyNumbers = async (nums) => {
|
||||||
|
const text = nums.join(', ');
|
||||||
|
try { await navigator.clipboard.writeText(text); alert(`복사 완료: ${text}`); }
|
||||||
|
catch { prompt('복사해서 사용하세요:', text); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 종합 추론 상수 */
|
||||||
|
export const METHOD_META = {
|
||||||
|
frequency: { label: '빈도 Z-score', desc: '역대 출현 빈도가 기댓값보다 높은 번호', color: '#818cf8', icon: '📊' },
|
||||||
|
fingerprint: { label: '조합 지문', desc: '역대 당첨 조합의 합계·홀짝·구간 분포에 맞는 번호', color: '#fbbf24', icon: '🔏' },
|
||||||
|
gap: { label: '갭 분석', desc: '가장 오래 등장하지 않은 오버듀 번호', color: '#34d399', icon: '⏳' },
|
||||||
|
cooccur: { label: '공동 출현', desc: '역대에 함께 출현한 빈도가 높은 번호', color: '#f472b6', icon: '🔗' },
|
||||||
|
diversity: { label: '다양성', desc: '구간 커버리지와 번호 범위를 극대화한 번호', color: '#fb923c', icon: '🌈' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const METHOD_ORDER = ['fingerprint', 'frequency', 'gap', 'cooccur', 'diversity'];
|
||||||
|
|
||||||
|
export const SCORE_META = [
|
||||||
|
{ key: 'score_fingerprint', label: '조합 지문', color: '#fbbf24', weight: 30 },
|
||||||
|
{ key: 'score_frequency', label: '빈도 Z', color: '#818cf8', weight: 25 },
|
||||||
|
{ key: 'score_gap', label: '갭 분석', color: '#34d399', weight: 20 },
|
||||||
|
{ key: 'score_cooccur', label: '공동 출현', color: '#f472b6', weight: 15 },
|
||||||
|
{ key: 'score_diversity', label: '다양성', color: '#fb923c', weight: 10 },
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
72
src/pages/stock/components/AdvisorTab.jsx
Normal file
72
src/pages/stock/components/AdvisorTab.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import { formatNumber } from '../stockUtils';
|
||||||
|
|
||||||
|
const AdvisorTab = ({ pf, advisor }) => (
|
||||||
|
<section className="stock-panel stock-panel--wide advisor-panel">
|
||||||
|
<div className="advisor-panel__head">
|
||||||
|
<div className="advisor-panel__title-block">
|
||||||
|
<span className="advisor-panel__badge">AI 어드바이저</span>
|
||||||
|
<h3 className="advisor-panel__title">포트폴리오 분석 프롬프트</h3>
|
||||||
|
<p className="advisor-panel__sub">
|
||||||
|
보유 종목 정보를 담은 전문가용 프롬프트를 생성합니다.
|
||||||
|
복사 후 Gemini, ChatGPT 등에 붙여넣어 분석을 받아보세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="advisor-panel__actions">
|
||||||
|
<a
|
||||||
|
href="https://gemini.google.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="button ghost small"
|
||||||
|
>
|
||||||
|
Gemini 열기 ↗
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://chatgpt.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="button ghost small"
|
||||||
|
>
|
||||||
|
ChatGPT 열기 ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pf.portfolioLoading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!pf.portfolioLoading && pf.portfolioHoldings.length === 0 && (
|
||||||
|
<div className="advisor-panel__empty">
|
||||||
|
<span className="advisor-panel__empty-icon">📋</span>
|
||||||
|
<p>포트폴리오 탭에서 보유 종목을 먼저 등록해주세요.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!pf.portfolioLoading && pf.portfolioHoldings.length > 0 && (
|
||||||
|
<div className="advisor-panel__body">
|
||||||
|
<div className="advisor-prompt__toolbar">
|
||||||
|
<span className="advisor-prompt__info">
|
||||||
|
종목 {pf.portfolioHoldings.length}개 · 총 자산 {pf.totalAssets != null ? formatNumber(pf.totalAssets) + '원' : '미집계'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={`button primary small ${advisor.advisorCopied ? 'is-copied' : ''}`}
|
||||||
|
onClick={advisor.handleCopyPrompt}
|
||||||
|
>
|
||||||
|
{advisor.advisorCopied ? '✅ 복사됨' : '📋 프롬프트 복사'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="advisor-prompt__preview">{advisor.buildAdvisorPrompt()}</pre>
|
||||||
|
<p className="advisor-panel__disclaimer">
|
||||||
|
※ 이 프롬프트를 AI에 붙여넣으면 전문가 관점의 매매 조언을 받을 수 있습니다.
|
||||||
|
투자 결정은 최종적으로 본인의 판단과 책임 하에 이루어져야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdvisorTab;
|
||||||
220
src/pages/stock/components/AiTradeTab.jsx
Normal file
220
src/pages/stock/components/AiTradeTab.jsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
formatNumber, formatPercent,
|
||||||
|
getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss,
|
||||||
|
toNumeric, profitColorClass,
|
||||||
|
} from '../stockUtils';
|
||||||
|
|
||||||
|
const AiTradeTab = ({ aib }) => (
|
||||||
|
<>
|
||||||
|
{aib.balanceError ? <p className="stock-error">{aib.balanceError}</p> : null}
|
||||||
|
|
||||||
|
{/* AI Balance section */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">AI 모의투자</p>
|
||||||
|
<h3>보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{aib.balanceLoading ? (
|
||||||
|
<span className="stock-chip">조회 중</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={aib.loadBalance}
|
||||||
|
disabled={aib.balanceLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-balance">
|
||||||
|
<div className="stock-balance__summary">
|
||||||
|
{[
|
||||||
|
{ label: '총 평가', value: aib.totalEval },
|
||||||
|
{ label: '예수금', value: aib.deposit },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="stock-balance__card">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{formatNumber(item.value)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{aib.holdings.length ? (
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{aib.holdings.map((item, idx) => {
|
||||||
|
const profitLoss = getProfitLoss(item);
|
||||||
|
const profitLossNumeric = toNumeric(profitLoss);
|
||||||
|
const profitClass = profitColorClass(profitLossNumeric);
|
||||||
|
const profitRate = getProfitRate(item);
|
||||||
|
const profitRateNumeric = toNumeric(profitRate);
|
||||||
|
const profitRateClass = profitColorClass(profitRateNumeric);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code ?? `${item.name}-${idx}`}
|
||||||
|
className="stock-holdings__item"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.code ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.code ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(getQty(item))}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(getBuyPrice(item))}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong>{formatNumber(getCurrentPrice(item))}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가금액</span>
|
||||||
|
<strong>
|
||||||
|
{getCurrentPrice(item) != null && getQty(item) != null
|
||||||
|
? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item)))
|
||||||
|
: '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitRateClass}`}>
|
||||||
|
{formatPercent(profitRate)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong className={`stock-profit ${profitClass}`}>
|
||||||
|
{formatNumber(profitLoss)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stock-empty">보유 종목이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Manual order section */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">수동 주문</p>
|
||||||
|
<h3>직접 매수/매도</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stock-order" onSubmit={aib.submitManualOrder}>
|
||||||
|
<label>
|
||||||
|
종목명/코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={aib.manualForm.code}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, code: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="005930 또는 삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매수/매도
|
||||||
|
<select
|
||||||
|
value={aib.manualForm.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, type: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="buy">매수</option>
|
||||||
|
<option value="sell">매도</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={aib.manualForm.qty}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, qty: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
금액(원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={aib.manualForm.price}
|
||||||
|
onChange={(e) =>
|
||||||
|
aib.setManualForm((prev) => ({ ...prev, price: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={aib.manualLoading}
|
||||||
|
>
|
||||||
|
{aib.manualLoading ? '요청 중...' : '주문 요청'}
|
||||||
|
</button>
|
||||||
|
{aib.manualError ? (
|
||||||
|
<p className="stock-error">{aib.manualError}</p>
|
||||||
|
) : null}
|
||||||
|
{aib.manualResult ? (
|
||||||
|
<div className="stock-result">
|
||||||
|
<p className="stock-result__title">요청 결과</p>
|
||||||
|
<pre>
|
||||||
|
{typeof aib.manualResult === 'string'
|
||||||
|
? aib.manualResult
|
||||||
|
: JSON.stringify(aib.manualResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* KIS modal */}
|
||||||
|
{aib.kisModal ? (
|
||||||
|
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className="stock-modal__backdrop"
|
||||||
|
onClick={() => aib.setKisModal('')}
|
||||||
|
/>
|
||||||
|
<div className="stock-modal__card">
|
||||||
|
<div className="stock-modal__head">
|
||||||
|
<h4>주문 결과</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => aib.setKisModal('')}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre>{aib.kisModal}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AiTradeTab;
|
||||||
609
src/pages/stock/components/PortfolioTab.jsx
Normal file
609
src/pages/stock/components/PortfolioTab.jsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
} from 'recharts';
|
||||||
|
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
|
||||||
|
|
||||||
|
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||||
|
<>
|
||||||
|
{pf.portfolioError ? (
|
||||||
|
<p className="stock-error">{pf.portfolioError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
|
||||||
|
<section className="stock-panel stock-panel--wide pf-section">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">포트폴리오</p>
|
||||||
|
<h3>수동 입력 종목 관리</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{pf.portfolioLoading ? (
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={pf.loadPortfolio}
|
||||||
|
disabled={pf.portfolioLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => pf.setAddFormOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{pf.addFormOpen ? '취소' : '+ 종목 추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{pf.addFormOpen && (
|
||||||
|
<form className="pf-add-form" onSubmit={pf.handleAddSubmit}>
|
||||||
|
<label>
|
||||||
|
증권사
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.ticker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, ticker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="005930"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.addForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, quantity: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균 매입가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.addForm.avg_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setAddForm((p) => ({ ...p, avg_price: e.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={pf.addLoading}
|
||||||
|
>
|
||||||
|
{pf.addLoading ? '등록 중...' : '종목 등록'}
|
||||||
|
</button>
|
||||||
|
{pf.addError && <p className="stock-error">{pf.addError}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Portfolio total summary */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && (
|
||||||
|
<div className="pf-total-summary">
|
||||||
|
{[
|
||||||
|
{ label: '총 매입', value: pf.portfolioSummary.total_buy },
|
||||||
|
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
|
||||||
|
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
|
||||||
|
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="pf-total-summary__card">
|
||||||
|
<span>{s.label}</span>
|
||||||
|
<strong
|
||||||
|
className={
|
||||||
|
s.isProfit || s.isRate
|
||||||
|
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pf.totalCash != null && (
|
||||||
|
<div className="pf-total-summary__card is-cash">
|
||||||
|
<span>예수금 합계</span>
|
||||||
|
<strong>{formatNumber(pf.totalCash)}원</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.totalAssets != null && (
|
||||||
|
<div className="pf-total-summary__card is-assets">
|
||||||
|
<span>총 자산</span>
|
||||||
|
<strong>{formatNumber(pf.totalAssets)}원</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자산 추이 차트 */}
|
||||||
|
<div className="pf-asset-history">
|
||||||
|
<div className="pf-asset-history__head">
|
||||||
|
<p className="pf-asset-history__title">총 자산 추이</p>
|
||||||
|
<div className="pf-asset-history__controls">
|
||||||
|
{[
|
||||||
|
{ label: '7일', value: 7 },
|
||||||
|
{ label: '30일', value: 30 },
|
||||||
|
{ label: '90일', value: 90 },
|
||||||
|
{ label: '전체', value: 0 },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`pf-asset-period-btn ${asset.assetHistoryDays === value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => asset.setAssetHistoryDays(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={handleSaveSnapshot}
|
||||||
|
disabled={asset.snapshotSaving || pf.totalAssets == null}
|
||||||
|
title="현재 총 자산을 오늘 날짜로 저장"
|
||||||
|
>
|
||||||
|
{asset.snapshotSaving ? '저장 중...' : '📸 스냅샷'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.assetHistoryLoading ? (
|
||||||
|
<div className="pf-asset-history__empty">
|
||||||
|
<Loading type="spinner" message="" />
|
||||||
|
</div>
|
||||||
|
) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart
|
||||||
|
data={asset.assetHistory}
|
||||||
|
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="assetGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.25} />
|
||||||
|
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
|
||||||
|
tickFormatter={(v) => v?.slice(5)}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis hide domain={['auto', 'auto']} />
|
||||||
|
<ChartTooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: 'var(--text-dim)', marginBottom: 4 }}
|
||||||
|
formatter={(v) => [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total_assets"
|
||||||
|
stroke="#38bdf8"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#assetGrad)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: '#38bdf8' }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="pf-asset-history__empty">
|
||||||
|
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 예수금 패널 */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">예수금 관리</p>
|
||||||
|
<h3>증권사별 예수금</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pf.cashList.length > 0 && (
|
||||||
|
<div className="pf-cash-table">
|
||||||
|
{pf.cashList.map((item) => {
|
||||||
|
const isEditing = pf.cashEditingBroker === item.broker;
|
||||||
|
return (
|
||||||
|
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||||
|
<span className="pf-cash-broker">{item.broker}</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="pf-cash-edit-input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.cashEditingValue}
|
||||||
|
onChange={(e) => pf.setCashEditingValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') pf.handleCashInlineSave(item.broker);
|
||||||
|
if (e.key === 'Escape') pf.handleCashInlineCancel();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<strong className="pf-cash-amount">
|
||||||
|
{formatNumber(item.cash)}원
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
<span className="pf-cash-date">
|
||||||
|
{item.updated_at
|
||||||
|
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => pf.handleCashInlineSave(item.broker)}
|
||||||
|
disabled={pf.cashEditSaving}
|
||||||
|
>
|
||||||
|
{pf.cashEditSaving ? '저장 중' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={pf.handleCashInlineCancel}
|
||||||
|
disabled={pf.cashEditSaving}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.handleCashInlineEdit(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => pf.handleCashDelete(item.broker)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.cashList.length === 0 && (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||||
|
등록된 예수금이 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="pf-cash-form" onSubmit={pf.handleCashSave}>
|
||||||
|
<label>
|
||||||
|
증권사명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pf.cashForm.broker}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
예수금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={pf.cashForm.cash}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="1500000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={pf.cashSaving}
|
||||||
|
>
|
||||||
|
{pf.cashSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
{pf.cashError && <p className="stock-error">{pf.cashError}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Broker cards stacked */}
|
||||||
|
{pf.brokerGroups.map(([broker, items]) => {
|
||||||
|
const bSummary = pf.getBrokerSummary(items);
|
||||||
|
const color = pf.brokerColors[broker];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={broker}
|
||||||
|
className="stock-panel stock-panel--wide pf-broker-section"
|
||||||
|
style={{ borderColor: color?.border, background: color?.bg }}
|
||||||
|
>
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow" style={{ color: color?.border }}>
|
||||||
|
{broker}
|
||||||
|
</p>
|
||||||
|
<h3>{broker} 보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
{items.length}종목 · 평가{' '}
|
||||||
|
{formatNumber(bSummary.totalEval)} · 손익{' '}
|
||||||
|
<span className={`stock-profit ${profitColorClass(bSummary.totalProfit)}`}>
|
||||||
|
{formatNumber(bSummary.totalProfit)} (
|
||||||
|
{formatPercent(bSummary.totalProfitRate)})
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const bc = pf.cashList.find((c) => c.broker === broker);
|
||||||
|
return bc ? (
|
||||||
|
<span className="pf-cash-badge">
|
||||||
|
예수금 {formatNumber(bc.cash)}원
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{items.map((item) => {
|
||||||
|
const profitAmt = item.profit_amount;
|
||||||
|
const profitRate = item.profit_rate;
|
||||||
|
const profitAmtN = toNumeric(profitAmt);
|
||||||
|
const profitRateN = toNumeric(profitRate);
|
||||||
|
const isEditing = pf.editingId === item.id;
|
||||||
|
const isDeleting = pf.deleteConfirmId === item.id;
|
||||||
|
const isSelling = pf.sellConfirmId === item.id;
|
||||||
|
const sellPrice = item.current_price ?? item.avg_price;
|
||||||
|
const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="stock-holdings__item pf-item">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="pf-edit-row">
|
||||||
|
<div className="pf-edit-fields">
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pf.editForm.quantity ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setEditForm((p) => ({
|
||||||
|
...p,
|
||||||
|
quantity: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균매입가
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={pf.editForm.avg_price ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
pf.setEditForm((p) => ({
|
||||||
|
...p,
|
||||||
|
avg_price: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="pf-edit-actions">
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={() => pf.handleEditSave(item.id)}
|
||||||
|
disabled={pf.editLoading}
|
||||||
|
>
|
||||||
|
{pf.editLoading ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.setEditingId(null)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.ticker ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.ticker ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(item.quantity)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(item.avg_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong className={item.current_price == null ? 'pf-null-price' : ''}>
|
||||||
|
{item.current_price != null
|
||||||
|
? formatNumber(item.current_price)
|
||||||
|
: '조회 실패'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가금액</span>
|
||||||
|
<strong>
|
||||||
|
{item.current_price != null && item.quantity != null
|
||||||
|
? formatNumber(item.current_price * item.quantity)
|
||||||
|
: '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(profitRateN)}`}>
|
||||||
|
{profitRate != null ? formatPercent(profitRate) : '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(profitAmtN)}`}>
|
||||||
|
{profitAmt != null ? formatNumber(profitAmt) : '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="pf-item-actions">
|
||||||
|
{!isSelling && !isDeleting && (
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.handleEditStart(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSelling ? (
|
||||||
|
<div className="pf-sell-confirm">
|
||||||
|
<span className="pf-sell-confirm__msg">
|
||||||
|
{item.current_price == null && (
|
||||||
|
<small className="pf-sell-confirm__warn">현재가 미조회 — 매입가 기준</small>
|
||||||
|
)}
|
||||||
|
{saleAmount != null
|
||||||
|
? `${formatNumber(saleAmount)}원 매도 후 예수금 반영`
|
||||||
|
: '매도 처리'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="button small pf-btn-sell"
|
||||||
|
onClick={() => handleSell(item)}
|
||||||
|
disabled={pf.sellLoading}
|
||||||
|
>
|
||||||
|
{pf.sellLoading ? '처리 중...' : '매도 확인'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.setSellConfirmId(null)}
|
||||||
|
disabled={pf.sellLoading}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isDeleting ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => pf.handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => pf.setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button ghost small pf-btn-sell"
|
||||||
|
onClick={() => {
|
||||||
|
pf.setSellConfirmId(item.id);
|
||||||
|
pf.setDeleteConfirmId(null);
|
||||||
|
}}
|
||||||
|
title="매도"
|
||||||
|
>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => {
|
||||||
|
pf.setDeleteConfirmId(item.id);
|
||||||
|
pf.setSellConfirmId(null);
|
||||||
|
}}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
등록된 종목이 없습니다. 상단의 <strong>+ 종목 추가</strong> 버튼으로 보유 종목을 등록하세요.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PortfolioTab;
|
||||||
384
src/pages/stock/components/ReportTab.jsx
Normal file
384
src/pages/stock/components/ReportTab.jsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import {
|
||||||
|
PieChart, Pie, Cell,
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
formatNumber, formatPercent, toNumeric,
|
||||||
|
CHART_COLORS, profitColorClass, getVixLabel, getFgLabel,
|
||||||
|
} from '../stockUtils';
|
||||||
|
|
||||||
|
const ReportTab = ({ pf, report, ai, marketCtx }) => (
|
||||||
|
<>
|
||||||
|
{pf.portfolioLoading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pf.portfolioError && <p className="stock-error">{pf.portfolioError}</p>}
|
||||||
|
|
||||||
|
{/* 자산 배분 + 수익률 차트 */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
|
||||||
|
<h3>자산 배분 현황</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="report-charts-row">
|
||||||
|
<div className="report-chart-box">
|
||||||
|
<p className="report-chart-title">증권사별 자산 배분</p>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={report.brokerPieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={84}
|
||||||
|
dataKey="value"
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{report.brokerPieData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<ChartTooltip
|
||||||
|
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
|
||||||
|
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="report-chart-box">
|
||||||
|
<p className="report-chart-title">종목별 수익률 (%)</p>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={report.profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||||
|
angle={-40}
|
||||||
|
textAnchor="end"
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
|
||||||
|
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
|
||||||
|
{report.profitBarData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리스크 분산 분석 */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && pf.portfolioSummary.total_eval != null && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">리스크 관리</p>
|
||||||
|
<h3>분산 분석</h3>
|
||||||
|
<p className="stock-panel__sub">증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 시 주의.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="risk-grid">
|
||||||
|
<div className="risk-card">
|
||||||
|
<p className="risk-card__title">증권사별 집중도</p>
|
||||||
|
{report.brokerConcentration.length === 0 ? (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>평가금액 데이터 없음</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{report.brokerConcentration.some((b) => b.ratio > 40) && (
|
||||||
|
<div className="risk-warning">
|
||||||
|
⚠️ 단일 증권사 집중도가 40%를 초과합니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => {
|
||||||
|
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
|
||||||
|
return (
|
||||||
|
<div key={broker} className="risk-item">
|
||||||
|
<div className="risk-item__head">
|
||||||
|
<span className="risk-item__name">{broker}</span>
|
||||||
|
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-bar">
|
||||||
|
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{formatNumber(evalAmt)}원</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="risk-card">
|
||||||
|
<p className="risk-card__title">상위 5 종목 집중도</p>
|
||||||
|
{report.stockConcentration.length === 0 ? (
|
||||||
|
<p className="stock-empty" style={{ fontSize: 13 }}>현재가 데이터 없음</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{report.stockConcentration.some((s) => s.ratio > 40) && (
|
||||||
|
<div className="risk-warning">
|
||||||
|
⚠️ 단일 종목 집중도가 40%를 초과합니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => {
|
||||||
|
const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok';
|
||||||
|
return (
|
||||||
|
<div key={ticker || name} className="risk-item">
|
||||||
|
<div className="risk-item__head">
|
||||||
|
<span className="risk-item__name">{name}</span>
|
||||||
|
<span className={`risk-item__ratio ${level}`}>{ratio.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-bar">
|
||||||
|
<div className={`risk-bar__fill ${level}`} style={{ width: `${Math.min(ratio, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||||
|
{ticker && <span style={{ marginRight: 6 }}>{ticker}</span>}
|
||||||
|
{formatNumber(evalAmt)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수익률 랭킹 테이블 */}
|
||||||
|
{pf.portfolioHoldings.length > 0 && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">수익률 랭킹</p>
|
||||||
|
<h3>종목별 상세 현황</h3>
|
||||||
|
<p className="stock-panel__sub">헤더 클릭으로 정렬 · 비중은 총 평가금액 대비</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="report-table-wrapper">
|
||||||
|
<table className="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{[
|
||||||
|
{ key: 'name', label: '종목명' },
|
||||||
|
{ key: 'broker', label: '증권사' },
|
||||||
|
{ key: 'profit_rate', label: '수익률' },
|
||||||
|
{ key: 'profit_amount', label: '평가손익' },
|
||||||
|
{ key: 'eval_amount', label: '평가금액' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<th key={key} onClick={() => report.handleReportSort(key)}>
|
||||||
|
{label}{' '}
|
||||||
|
<span className="report-sort-icon">
|
||||||
|
{report.reportSortField === key
|
||||||
|
? report.reportSortDir === 'asc' ? '↑' : '↓'
|
||||||
|
: '↕'}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th style={{ cursor: 'default' }}>비중</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.sortedHoldings.map((item) => {
|
||||||
|
const rateN = toNumeric(item.profit_rate);
|
||||||
|
const pnlN = toNumeric(item.profit_amount);
|
||||||
|
const evalAmt = item.eval_amount != null
|
||||||
|
? item.eval_amount
|
||||||
|
: item.current_price != null
|
||||||
|
? item.current_price * item.quantity
|
||||||
|
: null;
|
||||||
|
const totalEvalVal = toNumeric(pf.portfolioSummary.total_eval);
|
||||||
|
const weight = evalAmt != null && totalEvalVal
|
||||||
|
? Math.round((evalAmt / totalEvalVal) * 1000) / 10
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
|
||||||
|
<span className="report-table-code">{item.ticker ?? ''}</span>
|
||||||
|
</td>
|
||||||
|
<td className="report-td-muted">{item.broker ?? '-'}</td>
|
||||||
|
<td className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||||
|
<div className="report-rate-cell">
|
||||||
|
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
|
||||||
|
{rateN != null && (
|
||||||
|
<div className="report-rate-bar">
|
||||||
|
<div
|
||||||
|
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
|
||||||
|
style={{ width: `${report.maxAbsRate > 0 ? Math.abs(rateN) / report.maxAbsRate * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
|
||||||
|
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="report-td-muted">
|
||||||
|
{evalAmt != null ? formatNumber(evalAmt) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="report-td-muted">
|
||||||
|
{weight != null ? `${weight.toFixed(1)}%` : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && (
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> 탭에서 종목을 먼저 등록하세요.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 투자 코치 */}
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">AI 투자 코치</p>
|
||||||
|
<h3>오늘의 투자 평가</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시장 컨텍스트 미니 패널 */}
|
||||||
|
{marketCtx && (
|
||||||
|
<div className="ai-market-ctx">
|
||||||
|
<span className="ai-market-ctx__label">시장 환경</span>
|
||||||
|
<div className="ai-market-ctx__chips">
|
||||||
|
{marketCtx.vix != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
VIX <strong>{marketCtx.vix}</strong>
|
||||||
|
<em>{getVixLabel(marketCtx.vix)}</em>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{marketCtx.fg != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
F&G <strong>{marketCtx.fg}</strong>
|
||||||
|
<em>{getFgLabel(marketCtx.fg)}</em>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{marketCtx.treasury != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
10년물 <strong>{marketCtx.treasury}%</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{marketCtx.wti != null && (
|
||||||
|
<span className="ai-market-chip">
|
||||||
|
WTI <strong>${marketCtx.wti}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모델 선택 */}
|
||||||
|
<div className="ai-coach-settings">
|
||||||
|
<label>
|
||||||
|
AI 모델
|
||||||
|
<select
|
||||||
|
value={ai.aiModel}
|
||||||
|
onChange={(e) => {
|
||||||
|
ai.setAiModel(e.target.value);
|
||||||
|
localStorage.setItem('ai_coach_model', e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
|
||||||
|
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ai-coach-actions">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="button"
|
||||||
|
onClick={ai.handleAiCoach}
|
||||||
|
disabled={ai.aiLoading || pf.portfolioHoldings.length === 0}
|
||||||
|
>
|
||||||
|
{ai.aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
|
||||||
|
</button>
|
||||||
|
{pf.portfolioHoldings.length === 0 && (
|
||||||
|
<span className="ai-coach-note">종목 등록 후 이용 가능합니다.</span>
|
||||||
|
)}
|
||||||
|
{ai.aiResult?.generated_at && (
|
||||||
|
<span className="ai-coach-note">
|
||||||
|
{ai.aiResult.cached ? '오늘 캐시 결과 · ' : ''}
|
||||||
|
{new Date(ai.aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ai.aiError && <p className="stock-error" style={{ marginTop: 8 }}>{ai.aiError}</p>}
|
||||||
|
|
||||||
|
{ai.aiResult && !ai.aiLoading && (
|
||||||
|
<div className="ai-coach-result">
|
||||||
|
<div className="ai-coach-header">
|
||||||
|
<div className={`ai-grade-badge grade-${(ai.aiResult.grade ?? 'c').toLowerCase()}`}>
|
||||||
|
{ai.aiResult.grade ?? '?'}
|
||||||
|
</div>
|
||||||
|
<div className="ai-score-wrap">
|
||||||
|
<span className="ai-score-num">{ai.aiResult.score ?? 0}</span>
|
||||||
|
<span className="ai-score-unit">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<p className="ai-summary-text">{ai.aiResult.summary}</p>
|
||||||
|
</div>
|
||||||
|
<p className="ai-evaluation-text">{ai.aiResult.evaluation}</p>
|
||||||
|
{ai.aiResult.advice?.length > 0 && (
|
||||||
|
<div className="ai-advice-list">
|
||||||
|
{ai.aiResult.advice.map((a, i) => (
|
||||||
|
<div key={i} className="ai-advice-card">
|
||||||
|
<p className="ai-advice-title">{a.title}</p>
|
||||||
|
<p className="ai-advice-body">{a.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
type="button"
|
||||||
|
style={{ marginTop: 16, fontSize: 11 }}
|
||||||
|
onClick={() => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
localStorage.removeItem(`ai_coach_${today}`);
|
||||||
|
ai.setAiResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다시 평가받기 (캐시 삭제)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReportTab;
|
||||||
354
src/pages/stock/components/SellHistoryDrawer.jsx
Normal file
354
src/pages/stock/components/SellHistoryDrawer.jsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Loading from '../../../components/Loading';
|
||||||
|
import { formatNumber, formatPercent, profitColorClass } from '../stockUtils';
|
||||||
|
|
||||||
|
const SellHistoryDrawer = ({
|
||||||
|
sell, sellHistoryBrokers, filteredSellHistory, sellHistorySummary,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{/* Floating 토글 버튼 */}
|
||||||
|
{!sell.sellDrawerOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sh-floating-toggle"
|
||||||
|
onClick={() => {
|
||||||
|
sell.setSellDrawerOpen(true);
|
||||||
|
sell.loadSellHistory();
|
||||||
|
}}
|
||||||
|
title="실현손익 내역"
|
||||||
|
>
|
||||||
|
<span className="sh-floating-toggle__icon">💹</span>
|
||||||
|
<span className="sh-floating-toggle__label">실현손익</span>
|
||||||
|
{sell.sellHistory.length > 0 && (
|
||||||
|
<span className="sh-floating-toggle__badge">{sell.sellHistory.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
{sell.sellDrawerOpen && (
|
||||||
|
<div
|
||||||
|
className="sh-backdrop"
|
||||||
|
onClick={() => { sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<aside className={`sh-drawer ${sell.sellDrawerOpen ? 'is-open' : ''}`}>
|
||||||
|
<div className="sh-drawer__header">
|
||||||
|
<div>
|
||||||
|
<p className="sh-drawer__eyebrow">실현손익</p>
|
||||||
|
<h3 className="sh-drawer__title">매도 거래 내역</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__header-actions">
|
||||||
|
{sell.sellHistoryLoading && <Loading type="spinner" message="" />}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={sell.loadSellHistory}
|
||||||
|
disabled={sell.sellHistoryLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button primary small"
|
||||||
|
onClick={sell.sellFormOpen && sell.sellEditId == null ? sell.handleSellFormClose : sell.handleSellFormOpen}
|
||||||
|
>
|
||||||
|
{sell.sellFormOpen && sell.sellEditId == null ? '취소' : '+ 추가'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sh-drawer__close"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }}
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수동 추가 / 수정 폼 */}
|
||||||
|
{sell.sellFormOpen && (
|
||||||
|
<form className="sh-form" onSubmit={sell.handleSellFormSubmit}>
|
||||||
|
<div className="sh-form__title">
|
||||||
|
{sell.sellEditId != null ? '거래 내역 수정' : '매도 내역 수동 추가'}
|
||||||
|
</div>
|
||||||
|
<div className="sh-form__grid">
|
||||||
|
<label>
|
||||||
|
증권사
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sell.sellForm.broker}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, broker: e.target.value }))}
|
||||||
|
placeholder="KB증권"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sell.sellForm.ticker}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, ticker: e.target.value }))}
|
||||||
|
placeholder="005930"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
종목명
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sell.sellForm.name}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.quantity}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, quantity: e.target.value }))}
|
||||||
|
placeholder="10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
평균 매입가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.avg_price}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, avg_price: e.target.value }))}
|
||||||
|
placeholder="58000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매도가 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.sell_price}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, sell_price: e.target.value }))}
|
||||||
|
placeholder="62000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수수료 & 세금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sell.sellForm.commission}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, commission: e.target.value }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="sh-form__datetime">
|
||||||
|
매도 일시
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={sell.sellForm.sold_at}
|
||||||
|
onChange={(e) => sell.setSellForm((p) => ({ ...p, sold_at: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{sell.sellForm.quantity && sell.sellForm.avg_price && sell.sellForm.sell_price && (() => {
|
||||||
|
const qty = Number(sell.sellForm.quantity);
|
||||||
|
const buy = Number(sell.sellForm.avg_price) * qty;
|
||||||
|
const sellAmt = Number(sell.sellForm.sell_price) * qty;
|
||||||
|
const commission = Number(sell.sellForm.commission) || 0;
|
||||||
|
const profit = sellAmt - buy - commission;
|
||||||
|
const rate = buy > 0 ? (profit / buy) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="sh-form__preview">
|
||||||
|
<span>매도금액 <strong>{formatNumber(Math.round(sellAmt))}원</strong></span>
|
||||||
|
{commission > 0 && (
|
||||||
|
<span>수수료 & 세금 <strong className="stock-profit is-negative">-{formatNumber(Math.round(commission))}원</strong></span>
|
||||||
|
)}
|
||||||
|
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}원</strong></span>
|
||||||
|
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className="sh-form__actions">
|
||||||
|
<button className="button primary" type="submit" disabled={sell.sellFormSaving}>
|
||||||
|
{sell.sellFormSaving ? '저장 중...' : (sell.sellEditId != null ? '수정 저장' : '추가')}
|
||||||
|
</button>
|
||||||
|
<button className="button ghost" type="button" onClick={sell.handleSellFormClose} disabled={sell.sellFormSaving}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
{sell.sellFormError && <p className="stock-error">{sell.sellFormError}</p>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 바 */}
|
||||||
|
<div className="sell-history__filters">
|
||||||
|
<div className="sell-history__filter-group">
|
||||||
|
<span className="sell-history__filter-label">계좌</span>
|
||||||
|
{sellHistoryBrokers.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
type="button"
|
||||||
|
className={`sell-history__filter-btn ${sell.sellHistoryBroker === b ? 'is-active' : ''}`}
|
||||||
|
onClick={() => sell.setSellHistoryBroker(b)}
|
||||||
|
>
|
||||||
|
{b === 'ALL' ? '전체' : b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__filter-group">
|
||||||
|
<span className="sell-history__filter-label">기간</span>
|
||||||
|
{[
|
||||||
|
{ label: '1개월', value: '1M' },
|
||||||
|
{ label: '3개월', value: '3M' },
|
||||||
|
{ label: '6개월', value: '6M' },
|
||||||
|
{ label: '1년', value: '1Y' },
|
||||||
|
{ label: '전체', value: 'ALL' },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`sell-history__filter-btn ${sell.sellHistoryPeriod === value ? 'is-active' : ''}`}
|
||||||
|
onClick={() => sell.setSellHistoryPeriod(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
{filteredSellHistory.length > 0 && (
|
||||||
|
<div className="sell-history__summary">
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>거래 횟수</span>
|
||||||
|
<strong>{sellHistorySummary.count}건</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>총 매도금액</span>
|
||||||
|
<strong>{formatNumber(sellHistorySummary.totalSell)}원</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>총 수수료 & 세금</span>
|
||||||
|
<strong className="stock-profit is-negative">
|
||||||
|
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>실현손익 합계</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
|
||||||
|
{formatNumber(Math.round(sellHistorySummary.totalProfit))}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>평균 수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.rate)}`}>
|
||||||
|
{formatPercent(sellHistorySummary.rate)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 거래 내역 목록 */}
|
||||||
|
{filteredSellHistory.length > 0 ? (
|
||||||
|
<div className="sh-drawer__list">
|
||||||
|
{filteredSellHistory.map((r) => {
|
||||||
|
const profitN = r.realized_profit ?? 0;
|
||||||
|
const rateN = r.realized_rate ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="sh-drawer__item">
|
||||||
|
<div className="sh-drawer__item-top">
|
||||||
|
<div className="sh-drawer__item-name">
|
||||||
|
<span>{r.name}</span>
|
||||||
|
{r.ticker && <code>{r.ticker}</code>}
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__item-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => sell.handleSellEditStart(r)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small pf-btn-danger"
|
||||||
|
onClick={() => sell.handleDeleteSellRecord(r.id)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__item-meta">
|
||||||
|
<span className="sell-history__broker">{r.broker}</span>
|
||||||
|
<span className="sell-history__date">
|
||||||
|
{new Date(r.sold_at).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sh-drawer__item-metrics">
|
||||||
|
<div>
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>{formatNumber(r.quantity)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>{formatNumber(r.avg_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>매도가</span>
|
||||||
|
<strong>{formatNumber(r.sell_price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>매도금액</span>
|
||||||
|
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
|
||||||
|
</div>
|
||||||
|
{(r.commission > 0) && (
|
||||||
|
<div>
|
||||||
|
<span>수수료 & 세금</span>
|
||||||
|
<strong className="stock-profit is-negative">
|
||||||
|
-{formatNumber(Math.round(r.commission))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span>실현손익</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
|
||||||
|
{formatNumber(Math.round(profitN))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||||
|
{formatPercent(rateN)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stock-empty sh-drawer__empty">
|
||||||
|
{sell.sellHistory.length === 0
|
||||||
|
? '아직 매도 기록이 없습니다.'
|
||||||
|
: '필터 조건에 맞는 기록이 없습니다.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SellHistoryDrawer;
|
||||||
108
src/pages/stock/hooks/useAdvisor.js
Normal file
108
src/pages/stock/hooks/useAdvisor.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { formatNumber, formatPercent, getVixLabel, getFgLabel } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAdvisor({ portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx }) {
|
||||||
|
const [advisorCopied, setAdvisorCopied] = useState(false);
|
||||||
|
|
||||||
|
const buildAdvisorPrompt = useCallback(() => {
|
||||||
|
const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
const holdingsLines = portfolioHoldings.map((h) => {
|
||||||
|
const cp = h.current_price != null ? `${formatNumber(h.current_price)}원` : '시세 미조회';
|
||||||
|
const rate = h.profit_rate != null ? formatPercent(h.profit_rate) : '미조회';
|
||||||
|
const profit = h.profit_amount != null ? `(${h.profit_amount >= 0 ? '+' : ''}${formatNumber(h.profit_amount)}원)` : '';
|
||||||
|
return `- **${h.name ?? h.ticker}** (${h.ticker ?? ''}) | 계좌: ${h.broker ?? '-'}
|
||||||
|
수량 ${h.quantity}주 | 평균매입가 ${formatNumber(h.avg_price)}원 | 현재가 ${cp} | 손익 ${rate} ${profit}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const cashLines = cashList.map((c) => `- ${c.broker}: ${formatNumber(c.cash)}원`).join('\n') || '- 없음';
|
||||||
|
|
||||||
|
const marketLines = marketCtx
|
||||||
|
? [
|
||||||
|
`VIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}`,
|
||||||
|
`공포탐욕지수: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}`,
|
||||||
|
`미 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}`,
|
||||||
|
`WTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`,
|
||||||
|
].join('\n')
|
||||||
|
: '시장 데이터 미로드';
|
||||||
|
|
||||||
|
return `당신은 15년 이상 경력의 한국 주식시장 전문 애널리스트입니다.
|
||||||
|
오늘은 ${today}입니다. 아래 포트폴리오 정보와 시장 환경을 바탕으로 전문가 분석을 제공해주세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 현재 시장 환경
|
||||||
|
|
||||||
|
${marketLines}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💼 보유 포트폴리오
|
||||||
|
|
||||||
|
### 보유 종목 (${portfolioHoldings.length}개)
|
||||||
|
|
||||||
|
${holdingsLines || '보유 종목 없음'}
|
||||||
|
|
||||||
|
### 포트폴리오 요약
|
||||||
|
|
||||||
|
- 총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||||
|
- 총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||||
|
- 총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
- 예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||||
|
- 총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||||
|
|
||||||
|
### 예수금 현황
|
||||||
|
|
||||||
|
${cashLines}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 분석 요청
|
||||||
|
|
||||||
|
다음 형식으로 명확하게 작성해주세요:
|
||||||
|
|
||||||
|
### 📈 오늘의 시장 환경
|
||||||
|
시장 환경 데이터를 바탕으로 오늘 한국 주식시장의 전반적인 분위기와 주요 이슈를 2-3문장으로 요약하세요.
|
||||||
|
|
||||||
|
### 🔍 종목별 분석 및 행동 지침
|
||||||
|
각 보유 종목에 대해 아래 형식으로 작성하세요:
|
||||||
|
|
||||||
|
**[종목명 (티커)]**
|
||||||
|
- 현황: 현재 손익 상태와 포지션 평가
|
||||||
|
- 분석: 업황·섹터 동향, 주요 리스크/기회
|
||||||
|
- 🎯 행동 지침: **[매도 / 보유 / 추가매수 / 분할매도]** — 구체적 이유와 목표 참고 가격대
|
||||||
|
|
||||||
|
### 💼 포트폴리오 종합 의견
|
||||||
|
전체 포트폴리오의 섹터 편중, 리밸런싱 필요 여부, 현금 비중 조언을 작성하세요.
|
||||||
|
|
||||||
|
### ⚠️ 오늘 주의해야 할 리스크
|
||||||
|
매크로·섹터·개별 종목 측면에서 오늘 특히 주의할 리스크를 2-3가지 나열하세요.
|
||||||
|
|
||||||
|
### 🚀 추가 매수 유망 섹터 추천
|
||||||
|
현재 시장 환경과 포트폴리오 구성을 고려하여 추가 매수를 검토할 만한 유망 섹터를 추천해주세요.
|
||||||
|
아래 형식으로 작성하세요:
|
||||||
|
|
||||||
|
**[섹터명]**
|
||||||
|
- 추천 이유: 현재 시장 환경에서 이 섹터가 유망한 근거 (매크로 환경, 정책, 업황 사이클 등)
|
||||||
|
- 대표 종목 예시: 국내 대표 종목 2-3개 (현재 포트폴리오와 중복 여부 언급)
|
||||||
|
- 주의사항: 이 섹터 투자 시 고려해야 할 리스크
|
||||||
|
|
||||||
|
(현재 포트폴리오에 없거나 비중이 낮은 섹터를 우선 추천하고, 2-3개 섹터를 제시해주세요.)
|
||||||
|
|
||||||
|
---
|
||||||
|
분석은 반드시 한국어로, 구체적인 수치와 근거를 들어 전문적으로 작성해주세요.
|
||||||
|
투자 결정은 최종적으로 투자자 본인이 판단함을 명시하세요.`;
|
||||||
|
}, [portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx]);
|
||||||
|
|
||||||
|
const handleCopyPrompt = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(buildAdvisorPrompt());
|
||||||
|
setAdvisorCopied(true);
|
||||||
|
setTimeout(() => setAdvisorCopied(false), 2500);
|
||||||
|
} catch {
|
||||||
|
alert('클립보드 복사에 실패했습니다. 텍스트를 직접 선택해 복사하세요.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { advisorCopied, buildAdvisorPrompt, handleCopyPrompt };
|
||||||
|
}
|
||||||
84
src/pages/stock/hooks/useAiBalance.js
Normal file
84
src/pages/stock/hooks/useAiBalance.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { getTradeBalance, createTradeOrder } from '../../../api';
|
||||||
|
import { getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAiBalance() {
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||||
|
const [balanceError, setBalanceError] = useState('');
|
||||||
|
const [balanceLoaded, setBalanceLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [manualForm, setManualForm] = useState({
|
||||||
|
code: '',
|
||||||
|
qty: 1,
|
||||||
|
price: 0,
|
||||||
|
type: 'buy',
|
||||||
|
});
|
||||||
|
const [manualLoading, setManualLoading] = useState(false);
|
||||||
|
const [manualError, setManualError] = useState('');
|
||||||
|
const [manualResult, setManualResult] = useState(null);
|
||||||
|
const [kisModal, setKisModal] = useState('');
|
||||||
|
|
||||||
|
const loadBalance = useCallback(async () => {
|
||||||
|
setBalanceLoading(true);
|
||||||
|
setBalanceError('');
|
||||||
|
try {
|
||||||
|
const data = await getTradeBalance();
|
||||||
|
setBalance(data);
|
||||||
|
setBalanceLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setBalanceError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setBalanceLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitManualOrder = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setManualLoading(true);
|
||||||
|
setManualError('');
|
||||||
|
setManualResult(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
ticker: manualForm.code.trim(),
|
||||||
|
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
||||||
|
quantity: Number(manualForm.qty),
|
||||||
|
price: Number(manualForm.price),
|
||||||
|
};
|
||||||
|
const result = await createTradeOrder(payload);
|
||||||
|
setManualResult(result ?? { ok: true });
|
||||||
|
if (result?.kis_result !== undefined) {
|
||||||
|
const message =
|
||||||
|
typeof result.kis_result === 'string'
|
||||||
|
? result.kis_result
|
||||||
|
: JSON.stringify(result.kis_result, null, 2);
|
||||||
|
setKisModal(message);
|
||||||
|
}
|
||||||
|
await loadBalance();
|
||||||
|
} catch (err) {
|
||||||
|
setManualError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setManualLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* derived */
|
||||||
|
const holdings = useMemo(() => {
|
||||||
|
if (!balance) return [];
|
||||||
|
if (Array.isArray(balance.holdings)) return balance.holdings;
|
||||||
|
if (Array.isArray(balance.positions)) return balance.positions;
|
||||||
|
if (Array.isArray(balance.items)) return balance.items;
|
||||||
|
return [];
|
||||||
|
}, [balance]);
|
||||||
|
|
||||||
|
const summary = balance?.summary ?? {};
|
||||||
|
const totalEval = summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
||||||
|
const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance, balanceLoading, balanceError, balanceLoaded, loadBalance,
|
||||||
|
holdings, summary, totalEval, deposit,
|
||||||
|
manualForm, setManualForm, manualLoading, manualError, manualResult,
|
||||||
|
kisModal, setKisModal, submitManualOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/pages/stock/hooks/useAiCoach.js
Normal file
92
src/pages/stock/hooks/useAiCoach.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { formatNumber, formatPercent, getVixLabel, getFgLabel } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAiCoach({ portfolioHoldings, portfolioSummary, totalCash, totalAssets, marketCtx }) {
|
||||||
|
const [aiModel, setAiModel] = useState(() => localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001');
|
||||||
|
const [aiResult, setAiResult] = useState(null);
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cached = localStorage.getItem(`ai_coach_${today}`);
|
||||||
|
if (cached) {
|
||||||
|
try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAiCoach = async () => {
|
||||||
|
if (portfolioHoldings.length === 0) return;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cacheKey = `ai_coach_${today}`;
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiLoading(true);
|
||||||
|
setAiError('');
|
||||||
|
|
||||||
|
const holdingsText = portfolioHoldings
|
||||||
|
.map((item) =>
|
||||||
|
`- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const marketText = marketCtx
|
||||||
|
? `\n[현재 시장 환경]\nVIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}\nFear & Greed: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}\n미국 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}\nWTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오와 시장 환경을 종합 분석하여 JSON으로만 답하세요.
|
||||||
|
|
||||||
|
분석 일자: ${today}
|
||||||
|
총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||||
|
총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||||
|
총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||||
|
총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||||
|
보유 종목 수: ${portfolioHoldings.length}개
|
||||||
|
보유 종목:
|
||||||
|
${holdingsText}${marketText}
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"grade": "A",
|
||||||
|
"summary": "30자 이내 한줄 평가",
|
||||||
|
"evaluation": "200자 이내 상세 평가",
|
||||||
|
"advice": [
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/stock/ai-coach', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: aiModel, prompt, max_tokens: 1024 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `AI Coach 오류 (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data.content?.[0]?.text ?? '';
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.');
|
||||||
|
const result = JSON.parse(jsonMatch[0]);
|
||||||
|
const final = { ...result, generated_at: new Date().toISOString(), cached: false };
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(final));
|
||||||
|
setAiResult(final);
|
||||||
|
} catch (err) {
|
||||||
|
setAiError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { aiModel, setAiModel, aiResult, setAiResult, aiLoading, aiError, handleAiCoach };
|
||||||
|
}
|
||||||
66
src/pages/stock/hooks/useAssetHistory.js
Normal file
66
src/pages/stock/hooks/useAssetHistory.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getAssetHistory, saveAssetSnapshot } from '../../../api';
|
||||||
|
|
||||||
|
export default function useAssetHistory() {
|
||||||
|
const [assetHistory, setAssetHistory] = useState(null);
|
||||||
|
const [assetHistoryLoading, setAssetHistoryLoading] = useState(false);
|
||||||
|
const [assetHistoryDays, setAssetHistoryDays] = useState(30);
|
||||||
|
const [snapshotSaving, setSnapshotSaving] = useState(false);
|
||||||
|
|
||||||
|
const loadAssetHistory = useCallback(async (days) => {
|
||||||
|
setAssetHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getAssetHistory(days);
|
||||||
|
const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []);
|
||||||
|
const byDate = {};
|
||||||
|
for (const item of raw) byDate[item.date] = item.total_assets ?? 0;
|
||||||
|
|
||||||
|
const toLocalDate = (d) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let filled;
|
||||||
|
if (days > 0) {
|
||||||
|
const today = new Date();
|
||||||
|
filled = Array.from({ length: days }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(today.getDate() - (days - 1 - i));
|
||||||
|
const dateStr = toLocalDate(d);
|
||||||
|
const val = byDate[dateStr];
|
||||||
|
return val > 0 ? { date: dateStr, total_assets: val } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
filled = Object.entries(byDate)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([date, total_assets]) => ({ date, total_assets }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
setAssetHistory(filled);
|
||||||
|
} catch {
|
||||||
|
setAssetHistory([]);
|
||||||
|
} finally {
|
||||||
|
setAssetHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveSnapshot = async (totalAssets, days) => {
|
||||||
|
setSnapshotSaving(true);
|
||||||
|
try {
|
||||||
|
await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined);
|
||||||
|
await loadAssetHistory(days);
|
||||||
|
} catch (err) {
|
||||||
|
alert('스냅샷 저장 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setSnapshotSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetHistory, assetHistoryLoading,
|
||||||
|
assetHistoryDays, setAssetHistoryDays,
|
||||||
|
snapshotSaving, loadAssetHistory, handleSaveSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/pages/stock/hooks/useMarketContext.js
Normal file
23
src/pages/stock/hooks/useMarketContext.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getFearAndGreed, getVix, getTreasury10Y, getWTI } from '../../../api';
|
||||||
|
|
||||||
|
export default function useMarketContext(shouldLoad) {
|
||||||
|
const [marketCtx, setMarketCtx] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLoad || marketCtx !== null) return;
|
||||||
|
Promise.allSettled([getFearAndGreed(), getVix(), getTreasury10Y(), getWTI()])
|
||||||
|
.then(([fg, vix, t, w]) => {
|
||||||
|
const fgRaw = fg.status === 'fulfilled' ? fg.value : null;
|
||||||
|
const fgScore = fgRaw?.fear_and_greed?.score ?? fgRaw?.score;
|
||||||
|
setMarketCtx({
|
||||||
|
fg: fgScore != null ? Math.round(Number(fgScore)) : null,
|
||||||
|
vix: vix.status === 'fulfilled' ? (vix.value?.value ?? null) : null,
|
||||||
|
treasury: t.status === 'fulfilled' ? (t.value?.value ?? null) : null,
|
||||||
|
wti: w.status === 'fulfilled' ? (w.value?.value ?? null) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [shouldLoad, marketCtx]);
|
||||||
|
|
||||||
|
return marketCtx;
|
||||||
|
}
|
||||||
269
src/pages/stock/hooks/usePortfolio.js
Normal file
269
src/pages/stock/hooks/usePortfolio.js
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
getPortfolio, addPortfolio, updatePortfolio, deletePortfolio,
|
||||||
|
upsertCash, deleteCash,
|
||||||
|
} from '../../../api';
|
||||||
|
import { emptyPortfolioForm } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function usePortfolio() {
|
||||||
|
const [portfolio, setPortfolio] = useState(null);
|
||||||
|
const [portfolioLoading, setPortfolioLoading] = useState(false);
|
||||||
|
const [portfolioError, setPortfolioError] = useState('');
|
||||||
|
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
|
||||||
|
|
||||||
|
/* add form */
|
||||||
|
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
|
||||||
|
const [addFormOpen, setAddFormOpen] = useState(false);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
|
||||||
|
/* edit */
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const editOrigRef = useRef({});
|
||||||
|
|
||||||
|
/* delete / sell confirm */
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||||
|
const [sellConfirmId, setSellConfirmId] = useState(null);
|
||||||
|
const [sellLoading, setSellLoading] = useState(false);
|
||||||
|
|
||||||
|
/* cash */
|
||||||
|
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||||
|
const [cashSaving, setCashSaving] = useState(false);
|
||||||
|
const [cashError, setCashError] = useState('');
|
||||||
|
const [cashEditingBroker, setCashEditingBroker] = useState(null);
|
||||||
|
const [cashEditingValue, setCashEditingValue] = useState('');
|
||||||
|
const [cashEditSaving, setCashEditSaving] = useState(false);
|
||||||
|
|
||||||
|
/* derived */
|
||||||
|
const portfolioHoldings = portfolio?.holdings ?? [];
|
||||||
|
const portfolioSummary = portfolio?.summary ?? {};
|
||||||
|
const cashList = portfolio?.cash ?? [];
|
||||||
|
const totalCash = portfolioSummary.total_cash ?? null;
|
||||||
|
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||||
|
|
||||||
|
const brokerGroups = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const item of portfolioHoldings) {
|
||||||
|
const broker = item.broker || '기타';
|
||||||
|
if (!map[broker]) map[broker] = [];
|
||||||
|
map[broker].push(item);
|
||||||
|
}
|
||||||
|
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [portfolioHoldings]);
|
||||||
|
|
||||||
|
const brokerColors = useMemo(() => {
|
||||||
|
const palette = [
|
||||||
|
{ border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' },
|
||||||
|
{ border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' },
|
||||||
|
{ border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' },
|
||||||
|
{ border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' },
|
||||||
|
{ border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' },
|
||||||
|
{ border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' },
|
||||||
|
];
|
||||||
|
const map = {};
|
||||||
|
brokerGroups.forEach(([broker], i) => {
|
||||||
|
map[broker] = palette[i % palette.length];
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [brokerGroups]);
|
||||||
|
|
||||||
|
const getBrokerSummary = (items) => {
|
||||||
|
let totalBuy = 0, totalEvalAmt = 0, hasNullPrice = false;
|
||||||
|
for (const item of items) {
|
||||||
|
totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0);
|
||||||
|
if (item.eval_amount != null) totalEvalAmt += item.eval_amount;
|
||||||
|
else hasNullPrice = true;
|
||||||
|
}
|
||||||
|
const totalProfit = totalEvalAmt - totalBuy;
|
||||||
|
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||||
|
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
|
||||||
|
};
|
||||||
|
|
||||||
|
/* loaders */
|
||||||
|
const loadPortfolio = useCallback(async () => {
|
||||||
|
setPortfolioLoading(true);
|
||||||
|
setPortfolioError('');
|
||||||
|
try {
|
||||||
|
const data = await getPortfolio();
|
||||||
|
setPortfolio(data);
|
||||||
|
setPortfolioLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setPortfolioError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setPortfolioLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* actions */
|
||||||
|
const handleAddSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setAddLoading(true);
|
||||||
|
setAddError('');
|
||||||
|
try {
|
||||||
|
await addPortfolio({
|
||||||
|
broker: addForm.broker.trim(),
|
||||||
|
ticker: addForm.ticker.trim(),
|
||||||
|
name: addForm.name.trim(),
|
||||||
|
quantity: Number(addForm.quantity),
|
||||||
|
avg_price: Number(addForm.avg_price),
|
||||||
|
});
|
||||||
|
setAddForm({ ...emptyPortfolioForm });
|
||||||
|
setAddFormOpen(false);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStart = (item) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
const data = { quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name };
|
||||||
|
setEditForm(data);
|
||||||
|
editOrigRef.current = { ...data };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async (id) => {
|
||||||
|
setEditLoading(true);
|
||||||
|
try {
|
||||||
|
const orig = editOrigRef.current ?? {};
|
||||||
|
const diff = {};
|
||||||
|
for (const key of Object.keys(editForm)) {
|
||||||
|
if (editForm[key] !== orig[key]) diff[key] = editForm[key];
|
||||||
|
}
|
||||||
|
if (Object.keys(diff).length === 0) { setEditingId(null); return; }
|
||||||
|
await updatePortfolio(id, diff);
|
||||||
|
setEditingId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message ?? String(err);
|
||||||
|
if (msg.includes('404') || msg.includes('not found')) {
|
||||||
|
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||||||
|
await loadPortfolio();
|
||||||
|
} else {
|
||||||
|
alert('수정 실패: ' + msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await deletePortfolio(id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message ?? String(err);
|
||||||
|
if (msg.includes('404') || msg.includes('not found')) {
|
||||||
|
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
} else {
|
||||||
|
alert('삭제 실패: ' + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* cash actions */
|
||||||
|
const handleCashSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!cashForm.broker.trim() || cashForm.cash === '') return;
|
||||||
|
setCashSaving(true);
|
||||||
|
setCashError('');
|
||||||
|
try {
|
||||||
|
await upsertCash(cashForm.broker.trim(), Number(cashForm.cash));
|
||||||
|
setCashForm({ broker: '', cash: '' });
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
setCashError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setCashSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashDelete = async (broker) => {
|
||||||
|
try {
|
||||||
|
await deleteCash(broker);
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
alert('예수금 삭제 실패: ' + (err?.message ?? String(err)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashInlineEdit = (item) => {
|
||||||
|
setCashEditingBroker(item.broker);
|
||||||
|
setCashEditingValue(String(item.cash ?? ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashInlineSave = async (broker) => {
|
||||||
|
if (cashEditingValue === '') return;
|
||||||
|
setCashEditSaving(true);
|
||||||
|
try {
|
||||||
|
await upsertCash(broker, Number(cashEditingValue));
|
||||||
|
setCashEditingBroker(null);
|
||||||
|
setCashEditingValue('');
|
||||||
|
await loadPortfolio();
|
||||||
|
} catch (err) {
|
||||||
|
alert('예수금 수정 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setCashEditSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCashInlineCancel = () => {
|
||||||
|
setCashEditingBroker(null);
|
||||||
|
setCashEditingValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* sell (현재가 매도) */
|
||||||
|
const handleSell = async (item, { cashList: cl, loadSellHistoryAfter }) => {
|
||||||
|
const sellPrice = item.current_price ?? item.avg_price;
|
||||||
|
const avgPrice = item.avg_price ?? 0;
|
||||||
|
const qty = item.quantity ?? 0;
|
||||||
|
const saleAmount = sellPrice * qty;
|
||||||
|
const buyAmount = avgPrice * qty;
|
||||||
|
const realizedProfit = saleAmount - buyAmount;
|
||||||
|
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||||
|
const broker = item.broker ?? '';
|
||||||
|
|
||||||
|
setSellLoading(true);
|
||||||
|
try {
|
||||||
|
const existing = cl.find((c) => c.broker === broker);
|
||||||
|
const newCash = (existing?.cash ?? 0) + saleAmount;
|
||||||
|
await upsertCash(broker, newCash);
|
||||||
|
await deletePortfolio(item.id);
|
||||||
|
setSellConfirmId(null);
|
||||||
|
await loadPortfolio();
|
||||||
|
if (loadSellHistoryAfter) {
|
||||||
|
await loadSellHistoryAfter({
|
||||||
|
broker, ticker: item.ticker ?? '', name: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
quantity: qty, avg_price: avgPrice, sell_price: sellPrice,
|
||||||
|
buy_amount: buyAmount, sell_amount: saleAmount,
|
||||||
|
realized_profit: realizedProfit, realized_rate: realizedRate,
|
||||||
|
sold_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setSellLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
portfolio, portfolioLoading, portfolioError, portfolioLoaded, loadPortfolio,
|
||||||
|
portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets,
|
||||||
|
addForm, setAddForm, addFormOpen, setAddFormOpen, addLoading, addError, handleAddSubmit,
|
||||||
|
editingId, setEditingId, editForm, setEditForm, editLoading, handleEditStart, handleEditSave,
|
||||||
|
deleteConfirmId, setDeleteConfirmId, handleDelete,
|
||||||
|
sellConfirmId, setSellConfirmId, sellLoading, handleSell,
|
||||||
|
cashForm, setCashForm, cashSaving, cashError, handleCashSave, handleCashDelete,
|
||||||
|
cashEditingBroker, cashEditingValue, setCashEditingValue, cashEditSaving,
|
||||||
|
handleCashInlineEdit, handleCashInlineSave, handleCashInlineCancel,
|
||||||
|
brokerGroups, brokerColors, getBrokerSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
src/pages/stock/hooks/useReportData.js
Normal file
111
src/pages/stock/hooks/useReportData.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { toNumeric } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useReportData({ portfolioHoldings, portfolioSummary, brokerGroups, getBrokerSummary }) {
|
||||||
|
const [reportSortField, setReportSortField] = useState('profit_rate');
|
||||||
|
const [reportSortDir, setReportSortDir] = useState('desc');
|
||||||
|
|
||||||
|
const handleReportSort = (field) => {
|
||||||
|
if (reportSortField === field) {
|
||||||
|
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setReportSortField(field);
|
||||||
|
setReportSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const brokerPieData = useMemo(() =>
|
||||||
|
brokerGroups
|
||||||
|
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
|
||||||
|
.filter((d) => d.value > 0),
|
||||||
|
[brokerGroups, getBrokerSummary]
|
||||||
|
);
|
||||||
|
|
||||||
|
const profitBarData = useMemo(() =>
|
||||||
|
portfolioHoldings
|
||||||
|
.filter((item) => item.profit_rate != null)
|
||||||
|
.map((item) => ({
|
||||||
|
name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5),
|
||||||
|
fullName: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
rate: toNumeric(item.profit_rate) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rate - a.rate),
|
||||||
|
[portfolioHoldings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxAbsRate = useMemo(() =>
|
||||||
|
Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))),
|
||||||
|
[portfolioHoldings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const brokerConcentration = useMemo(() => {
|
||||||
|
const totalEval = toNumeric(portfolioSummary.total_eval);
|
||||||
|
if (!totalEval || totalEval === 0) return [];
|
||||||
|
return brokerGroups
|
||||||
|
.map(([broker, items]) => {
|
||||||
|
const { totalEval: brokerEval } = getBrokerSummary(items);
|
||||||
|
const ratio = Math.round((brokerEval / totalEval) * 1000) / 10;
|
||||||
|
return { broker, eval: brokerEval, ratio };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.ratio - a.ratio);
|
||||||
|
}, [brokerGroups, portfolioSummary.total_eval, getBrokerSummary]);
|
||||||
|
|
||||||
|
const stockConcentration = useMemo(() => {
|
||||||
|
const totalEval = toNumeric(portfolioSummary.total_eval);
|
||||||
|
if (!totalEval || totalEval === 0) return [];
|
||||||
|
return portfolioHoldings
|
||||||
|
.map((item) => {
|
||||||
|
const evalAmt = item.eval_amount != null
|
||||||
|
? toNumeric(item.eval_amount)
|
||||||
|
: (item.current_price != null && item.quantity != null)
|
||||||
|
? toNumeric(item.current_price) * toNumeric(item.quantity)
|
||||||
|
: null;
|
||||||
|
if (!evalAmt) return null;
|
||||||
|
return {
|
||||||
|
name: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
ticker: item.ticker ?? '',
|
||||||
|
eval: evalAmt,
|
||||||
|
ratio: Math.round((evalAmt / totalEval) * 1000) / 10,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.ratio - a.ratio)
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [portfolioHoldings, portfolioSummary.total_eval]);
|
||||||
|
|
||||||
|
const sortedHoldings = useMemo(() => {
|
||||||
|
const getVal = (item) => {
|
||||||
|
switch (reportSortField) {
|
||||||
|
case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity;
|
||||||
|
case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity;
|
||||||
|
case 'eval_amount': {
|
||||||
|
const ea = toNumeric(item.eval_amount);
|
||||||
|
if (ea != null) return ea;
|
||||||
|
const cp = toNumeric(item.current_price);
|
||||||
|
const qty = toNumeric(item.quantity);
|
||||||
|
return cp != null && qty != null ? cp * qty : -Infinity;
|
||||||
|
}
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [...portfolioHoldings].sort((a, b) => {
|
||||||
|
if (reportSortField === 'name')
|
||||||
|
return reportSortDir === 'asc'
|
||||||
|
? (a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
: (b.name ?? '').localeCompare(a.name ?? '');
|
||||||
|
if (reportSortField === 'broker')
|
||||||
|
return reportSortDir === 'asc'
|
||||||
|
? (a.broker ?? '').localeCompare(b.broker ?? '')
|
||||||
|
: (b.broker ?? '').localeCompare(a.broker ?? '');
|
||||||
|
const av = getVal(a);
|
||||||
|
const bv = getVal(b);
|
||||||
|
return reportSortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
});
|
||||||
|
}, [portfolioHoldings, reportSortField, reportSortDir]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportSortField, reportSortDir, handleReportSort,
|
||||||
|
brokerPieData, profitBarData, maxAbsRate,
|
||||||
|
brokerConcentration, stockConcentration, sortedHoldings,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/pages/stock/hooks/useSellHistory.js
Normal file
131
src/pages/stock/hooks/useSellHistory.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getSellHistory, addSellHistory, updateSellHistory, deleteSellHistory } from '../../../api';
|
||||||
|
import { emptySellForm, toLocalDatetimeValue } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useSellHistory() {
|
||||||
|
const [sellHistory, setSellHistory] = useState([]);
|
||||||
|
const [sellHistoryLoading, setSellHistoryLoading] = useState(false);
|
||||||
|
const [sellHistoryBroker, setSellHistoryBroker] = useState('ALL');
|
||||||
|
const [sellHistoryPeriod, setSellHistoryPeriod] = useState('3M');
|
||||||
|
|
||||||
|
const [sellDrawerOpen, setSellDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const [sellFormOpen, setSellFormOpen] = useState(false);
|
||||||
|
const [sellEditId, setSellEditId] = useState(null);
|
||||||
|
const [sellForm, setSellForm] = useState(emptySellForm());
|
||||||
|
const [sellFormSaving, setSellFormSaving] = useState(false);
|
||||||
|
const [sellFormError, setSellFormError] = useState('');
|
||||||
|
|
||||||
|
const loadSellHistory = useCallback(async () => {
|
||||||
|
setSellHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSellHistory();
|
||||||
|
setSellHistory(data?.records ?? (Array.isArray(data) ? data : []));
|
||||||
|
} catch {
|
||||||
|
/* 백엔드 미구현 시 빈 배열 유지 */
|
||||||
|
} finally {
|
||||||
|
setSellHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 매도 후 실현손익 기록 추가 (usePortfolio.handleSell에서 호출) */
|
||||||
|
const addSellRecord = async (record) => {
|
||||||
|
try {
|
||||||
|
const saved = await addSellHistory(record);
|
||||||
|
setSellHistory((prev) => [saved ?? record, ...prev]);
|
||||||
|
} catch {
|
||||||
|
setSellHistory((prev) => [{ ...record, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSellRecord = async (id) => {
|
||||||
|
setSellHistory((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
try {
|
||||||
|
await deleteSellHistory(id);
|
||||||
|
} catch {
|
||||||
|
loadSellHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormOpen = () => {
|
||||||
|
setSellEditId(null);
|
||||||
|
setSellForm(emptySellForm());
|
||||||
|
setSellFormError('');
|
||||||
|
setSellFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellEditStart = (record) => {
|
||||||
|
setSellEditId(record.id);
|
||||||
|
setSellForm({
|
||||||
|
broker: record.broker ?? '',
|
||||||
|
ticker: record.ticker ?? '',
|
||||||
|
name: record.name ?? '',
|
||||||
|
quantity: String(record.quantity ?? ''),
|
||||||
|
avg_price: String(record.avg_price ?? ''),
|
||||||
|
sell_price: String(record.sell_price ?? ''),
|
||||||
|
commission: String(record.commission ?? ''),
|
||||||
|
sold_at: toLocalDatetimeValue(record.sold_at),
|
||||||
|
});
|
||||||
|
setSellFormError('');
|
||||||
|
setSellFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormClose = () => {
|
||||||
|
setSellFormOpen(false);
|
||||||
|
setSellEditId(null);
|
||||||
|
setSellFormError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSellFormSaving(true);
|
||||||
|
setSellFormError('');
|
||||||
|
|
||||||
|
const qty = Number(sellForm.quantity);
|
||||||
|
const avgPrice = Number(sellForm.avg_price);
|
||||||
|
const sellPrice = Number(sellForm.sell_price);
|
||||||
|
const commission = Number(sellForm.commission) || 0;
|
||||||
|
const buyAmount = avgPrice * qty;
|
||||||
|
const sellAmount = sellPrice * qty;
|
||||||
|
const realizedProfit = sellAmount - buyAmount - commission;
|
||||||
|
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
broker: sellForm.broker.trim(),
|
||||||
|
ticker: sellForm.ticker.trim(),
|
||||||
|
name: sellForm.name.trim(),
|
||||||
|
quantity: qty, avg_price: avgPrice, sell_price: sellPrice, commission,
|
||||||
|
buy_amount: buyAmount, sell_amount: sellAmount,
|
||||||
|
realized_profit: realizedProfit, realized_rate: realizedRate,
|
||||||
|
sold_at: sellForm.sold_at ? new Date(sellForm.sold_at).toISOString() : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sellEditId != null) {
|
||||||
|
const updated = await updateSellHistory(sellEditId, payload);
|
||||||
|
setSellHistory((prev) =>
|
||||||
|
prev.map((r) => (r.id === sellEditId ? (updated ?? { ...payload, id: sellEditId }) : r))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const saved = await addSellHistory(payload);
|
||||||
|
setSellHistory((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
handleSellFormClose();
|
||||||
|
} catch (err) {
|
||||||
|
setSellFormError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setSellFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sellHistory, sellHistoryLoading, loadSellHistory, addSellRecord,
|
||||||
|
sellHistoryBroker, setSellHistoryBroker,
|
||||||
|
sellHistoryPeriod, setSellHistoryPeriod,
|
||||||
|
sellDrawerOpen, setSellDrawerOpen,
|
||||||
|
sellFormOpen, sellEditId, sellForm, setSellForm,
|
||||||
|
sellFormSaving, sellFormError,
|
||||||
|
handleDeleteSellRecord,
|
||||||
|
handleSellFormOpen, handleSellEditStart, handleSellFormClose, handleSellFormSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/pages/stock/stockUtils.js
Normal file
125
src/pages/stock/stockUtils.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* ── 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 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: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 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()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── TAB IDs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const TAB_PORTFOLIO = 'portfolio';
|
||||||
|
export const TAB_AI = 'ai';
|
||||||
|
export const TAB_REPORT = 'report';
|
||||||
|
export const TAB_ADVISOR = 'advisor';
|
||||||
Reference in New Issue
Block a user