5개 탭/드로어 컴포넌트를 components/ 디렉토리로 추출: - PortfolioTab: 포트폴리오 관리, 예수금, 자산추이 차트 - AiTradeTab: AI 모의투자 잔고, 수동주문, KIS 모달 - ReportTab: 차트, 리스크 분석, 수익률 랭킹, AI 코치 - AdvisorTab: 프롬프트 빌더, 클립보드 복사 - SellHistoryDrawer: 실현손익 드로어, 필터, 폼 StockTrade.jsx는 210줄 오케스트레이터로 축소 (hooks 호출 + lazy load + 헤더 + 탭 바 + 탭 렌더) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
9.8 KiB
JavaScript
221 lines
9.8 KiB
JavaScript
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;
|