모의투자 화면수정

This commit is contained in:
Macbook
2026-05-31 04:34:32 +09:00
parent 16d0e2c226
commit 05d2de5234
12 changed files with 717 additions and 190 deletions
+6 -3
View File
@@ -250,15 +250,18 @@ const PaperTradingPage: React.FC<Props> = ({
rightStorageKey="ptd-right-width"
rightDefaultWidth={440}
rightCollapsedStorageKey="ptd-right-open"
headerCenter={!s?.enabled ? (
<span className="bps-header-status-text" role="status">
. .
</span>
) : undefined}
headerCenterMode="between"
headerActions={(
<>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}></button>
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleReset()}> </button>
</>
)}
banner={!s?.enabled ? (
<div className="bps-banner bps-banner--warn"> . .</div>
) : undefined}
leftTabs={leftTabs}
left={leftTab === 'strategy' ? (
<PaperLeftStrategyTab
@@ -268,9 +268,6 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
return (
<div className={`top-panel top-panel--upbit top-panel--${side}`}>
<div className="top-panel-fields">
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p>
)}
{showSymbolField && (
<div className="top-field">
<label className="top-label"></label>
@@ -27,6 +27,8 @@ export interface BuilderPageShellProps {
headerActions?: React.ReactNode;
/** 타이틀 우측·중앙 그리드 시작선 정렬 (가상투자 등) */
headerCenter?: React.ReactNode;
/** grid: 좌패널폭 기준 3열 / between: 타이틀·버튼 사이 중앙 (모의투자 안내 등) */
headerCenterMode?: 'grid' | 'between';
pageClassName?: string;
banner?: React.ReactNode;
leftTitle?: string;
@@ -103,6 +105,7 @@ export default function BuilderPageShell({
subtitle,
headerActions,
headerCenter,
headerCenterMode = 'grid',
pageClassName,
banner,
leftTitle,
@@ -252,21 +255,34 @@ export default function BuilderPageShell({
className={`bps-page se-page se-page--${theme}${pageClassName ? ` ${pageClassName}` : ''}`}
style={pageStyle}
>
<header className={`bps-header${headerCenter ? ' bps-header--vtd' : ''}`}>
<header
className={[
'bps-header',
headerCenter && headerCenterMode === 'between' ? 'bps-header--between' : '',
headerCenter && headerCenterMode === 'grid' ? 'bps-header--vtd' : '',
].filter(Boolean).join(' ')}
>
<div className="bps-header-left">
<h1 className="bps-title">{title}</h1>
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
</div>
{headerCenter ? (
<>
<div className="bps-header-center">{headerCenter}</div>
{headerActions && (
<>
<span className="header-toolbar-divider" aria-hidden="true" />
<div className="bps-header-actions">{headerActions}</div>
</>
)}
</>
headerCenterMode === 'between' ? (
<>
<div className="bps-header-center">{headerCenter}</div>
{headerActions && <div className="bps-header-actions">{headerActions}</div>}
</>
) : (
<>
<div className="bps-header-center">{headerCenter}</div>
{headerActions && (
<>
<span className="header-toolbar-divider" aria-hidden="true" />
<div className="bps-header-actions">{headerActions}</div>
</>
)}
</>
)
) : (
headerActions && <div className="bps-header-actions">{headerActions}</div>
)}
@@ -26,57 +26,101 @@ const PaperAllocationTable: React.FC<Props> = ({
}, [onChanged]);
if (!allocations.length) {
return <p className="ptd-muted"> . .</p>;
return (
<div className="ptd-item-card-section ptd-alloc-card-section">
<div className="vtd-target-list-head">
<span> </span>
<span className="vtd-target-count">0</span>
</div>
<p className="ptd-muted ptd-item-card-empty"> . .</p>
</div>
);
}
return (
<div className="ptd-alloc-table-wrap">
<table className="ptd-table ptd-alloc-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<div className="ptd-item-card-section ptd-alloc-card-section">
<div className="vtd-target-list-head">
<span> </span>
<span className="vtd-target-count">{allocations.length}</span>
</div>
<div className="ptd-item-card-scroll">
<div className="vtd-target-list ptd-item-card-list">
{allocations.map(row => {
const ret = row.symbolReturnPct ?? 0;
const selected = row.symbol === selectedMarket;
const name = row.koreanName || coinCode(row.symbol);
const code = coinCode(row.symbol);
const up = ret >= 0;
return (
<tr
<div
key={row.symbol}
className={selected ? 'ptd-alloc-row--selected' : ''}
onClick={() => onSelectMarket?.(row.symbol)}
className={[
'vtd-target-item',
'ptd-item-card',
'ptd-alloc-card',
selected ? 'vtd-target-item--active' : '',
row.isActive === false ? 'ptd-alloc-card--inactive' : '',
].filter(Boolean).join(' ')}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') onSelectMarket?.(row.symbol); }}
onClick={() => onSelectMarket?.(row.symbol)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectMarket?.(row.symbol);
}
}}
>
<td>{row.koreanName || coinCode(row.symbol)}</td>
<td>{(row.weightPct ?? 0).toFixed(1)}%</td>
<td>{fmtKrw(row.maxInvestKrw)}</td>
<td>{fmtKrw(row.investedKrw ?? 0)}</td>
<td>{fmtKrw(row.evalAmount ?? 0)}</td>
<td className={ret >= 0 ? 'up' : 'down'}>{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%</td>
<td onClick={e => e.stopPropagation()}>
<button
type="button"
className={`ptd-alloc-toggle${row.isActive !== false ? ' ptd-alloc-toggle--on' : ''}`}
onClick={() => void toggleActive(row)}
title={row.isActive !== false ? '신규 매수 허용' : '신규 매수 차단'}
>
{row.isActive !== false ? 'ON' : 'OFF'}
</button>
</td>
</tr>
<div className="vtd-target-item-main">
<div className="vtd-target-names">
<span className="vtd-target-ko">{name}</span>
<span className="vtd-target-en">{code}/KRW</span>
</div>
<div className="vtd-target-item-actions">
<button
type="button"
className={`ptd-alloc-toggle${row.isActive !== false ? ' ptd-alloc-toggle--on' : ''}`}
onClick={e => {
e.stopPropagation();
void toggleActive(row);
}}
title={row.isActive !== false ? '신규 매수 허용' : '신규 매수 차단'}
>
{row.isActive !== false ? 'ON' : 'OFF'}
</button>
</div>
</div>
<div className="ptd-item-card-metrics">
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{(row.weightPct ?? 0).toFixed(1)}%</span>
</div>
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{fmtKrw(row.maxInvestKrw)}</span>
</div>
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{fmtKrw(row.investedKrw ?? 0)}</span>
</div>
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{fmtKrw(row.evalAmount ?? 0)}</span>
</div>
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className={`vtd-trade-meta-value ${up ? 'vtd-target-up' : 'vtd-target-dn'}`}>
{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%
</span>
</div>
</div>
</div>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
@@ -9,6 +9,13 @@ interface Props {
onOpenSettings?: () => void;
}
const SettingsFieldRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label">{label}</span>
{children}
</div>
);
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => {
const { defaults, save, isLoaded } = useAppSettings();
@@ -28,101 +35,100 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
};
const top = (
<>
<div className="ptd-settings-panel">
{onOpenSettings && (
<button type="button" className="bps-btn bps-btn--ghost ptd-left-settings-link" onClick={onOpenSettings}>
<button type="button" className="bps-btn bps-btn--ghost ptd-settings-link" onClick={onOpenSettings}>
</button>
)}
<div className="ptd-left-section">
<label className="ptd-left-row">
<span> </span>
<input
type="checkbox"
checked={defaults.paperTradingEnabled}
onChange={e => patch({ paperTradingEnabled: e.target.checked })}
/>
</label>
<div className="vtd-target-item ptd-item-card ptd-settings-card">
<div className="ptd-item-card-metrics">
<SettingsFieldRow label="모의투자 활성화">
<label className="vtd-trade-meta-value ptd-settings-value-box ptd-settings-value-box--check">
<input
type="checkbox"
checked={defaults.paperTradingEnabled}
onChange={e => patch({ paperTradingEnabled: e.target.checked })}
/>
</label>
</SettingsFieldRow>
<SettingsFieldRow label="자동매매">
<label className="vtd-trade-meta-value ptd-settings-value-box ptd-settings-value-box--check">
<input
type="checkbox"
checked={defaults.paperAutoTradeEnabled}
onChange={e => patch({ paperAutoTradeEnabled: e.target.checked })}
/>
</label>
</SettingsFieldRow>
<SettingsFieldRow label="매수 예산 (%)">
<input
type="number"
className="vtd-trade-meta-value ptd-settings-input"
min={1}
max={100}
value={defaults.paperAutoTradeBudgetPct}
disabled={!defaults.paperAutoTradeEnabled}
onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })}
/>
</SettingsFieldRow>
</div>
</div>
<div className="ptd-left-section">
<label className="ptd-left-row">
<span></span>
<input
type="checkbox"
checked={defaults.paperAutoTradeEnabled}
onChange={e => patch({ paperAutoTradeEnabled: e.target.checked })}
/>
</label>
<label className="ptd-left-field">
<span> (%)</span>
<input
type="number"
className="ptd-left-input"
min={1}
max={100}
value={defaults.paperAutoTradeBudgetPct}
disabled={!defaults.paperAutoTradeEnabled}
onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })}
/>
</label>
</div>
</>
</div>
);
const bottom = (
<>
<div className="ptd-left-section">
<label className="ptd-left-field">
<span> (KRW)</span>
<input
type="number"
className="ptd-left-input"
min={100000}
step={100000}
value={defaults.paperInitialCapital}
onChange={e => patch({ paperInitialCapital: Number(e.target.value) })}
/>
</label>
<label className="ptd-left-field">
<span> (%)</span>
<input
type="number"
className="ptd-left-input"
min={0}
max={1}
step={0.01}
value={defaults.paperFeeRatePct}
onChange={e => patch({ paperFeeRatePct: Number(e.target.value) })}
/>
</label>
<label className="ptd-left-field">
<span> (%)</span>
<input
type="number"
className="ptd-left-input"
min={0}
max={5}
step={0.01}
value={defaults.paperSlippagePct}
onChange={e => patch({ paperSlippagePct: Number(e.target.value) })}
/>
</label>
<label className="ptd-left-field">
<span> (KRW)</span>
<input
type="number"
className="ptd-left-input"
min={1000}
step={1000}
value={defaults.paperMinOrderKrw}
onChange={e => patch({ paperMinOrderKrw: Number(e.target.value) })}
/>
</label>
<div className="ptd-settings-panel ptd-settings-panel--account">
<div className="vtd-target-item ptd-item-card ptd-settings-card">
<div className="ptd-item-card-metrics">
<SettingsFieldRow label="초기 자본 (KRW)">
<input
type="number"
className="vtd-trade-meta-value ptd-settings-input"
min={100000}
step={100000}
value={defaults.paperInitialCapital}
onChange={e => patch({ paperInitialCapital: Number(e.target.value) })}
/>
</SettingsFieldRow>
<SettingsFieldRow label="수수료율 (%)">
<input
type="number"
className="vtd-trade-meta-value ptd-settings-input"
min={0}
max={1}
step={0.01}
value={defaults.paperFeeRatePct}
onChange={e => patch({ paperFeeRatePct: Number(e.target.value) })}
/>
</SettingsFieldRow>
<SettingsFieldRow label="슬리피지 (%)">
<input
type="number"
className="vtd-trade-meta-value ptd-settings-input"
min={0}
max={5}
step={0.01}
value={defaults.paperSlippagePct}
onChange={e => patch({ paperSlippagePct: Number(e.target.value) })}
/>
</SettingsFieldRow>
<SettingsFieldRow label="최소 주문 (KRW)">
<input
type="number"
className="vtd-trade-meta-value ptd-settings-input"
min={1000}
step={1000}
value={defaults.paperMinOrderKrw}
onChange={e => patch({ paperMinOrderKrw: Number(e.target.value) })}
/>
</SettingsFieldRow>
</div>
</div>
<button type="button" className="bps-btn bps-btn--danger ptd-left-reset" onClick={() => void handleReset()}>
<button type="button" className="bps-btn bps-btn--danger ptd-settings-reset" onClick={() => void handleReset()}>
</button>
</>
</div>
);
return (
@@ -91,33 +91,90 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
</>
);
const positions = summary?.positions ?? [];
const bottom = (
<table className="ptd-table ptd-table--compact">
<thead>
<tr><th></th><th></th><th></th></tr>
</thead>
<tbody>
{(summary?.positions ?? []).length === 0 ? (
<tr><td colSpan={3} className="ptd-muted"> </td></tr>
) : summary!.positions.map(p => {
const code = p.symbol.replace(/^KRW-/, '');
const up = (p.profitLoss ?? 0) >= 0;
return (
<tr
key={p.symbol}
className={market === p.symbol ? 'ptd-row--sel' : ''}
onClick={() => onMarketSelect?.(p.symbol)}
>
<td>{code}</td>
<td>{p.quantity.toFixed(4)}</td>
<td className={up ? 'up' : 'down'}>
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="ptd-item-card-section ptd-holdings-card-section">
<div className="vtd-target-list-head">
<span> </span>
<span className="vtd-target-count">{positions.length}</span>
</div>
{positions.length === 0 ? (
<p className="ptd-muted ptd-item-card-empty"> .</p>
) : (
<div className="ptd-item-card-scroll">
<div className="vtd-target-list ptd-item-card-list">
{positions.map(p => {
const code = p.symbol.replace(/^KRW-/, '');
const name = p.koreanName || code;
const up = (p.profitLoss ?? 0) >= 0;
const selected = market === p.symbol;
const retPct = p.profitLossPct;
return (
<div
key={p.symbol}
className={[
'vtd-target-item',
'ptd-item-card',
'ptd-holding-card',
selected ? 'vtd-target-item--active' : '',
].filter(Boolean).join(' ')}
role="button"
tabIndex={0}
onClick={() => onMarketSelect?.(p.symbol)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onMarketSelect?.(p.symbol);
}
}}
>
<div className="vtd-target-item-main">
<div className="vtd-target-names">
<span className="vtd-target-ko">{name}</span>
<span className="vtd-target-en">{code}/KRW</span>
</div>
{p.profitLoss != null && (
<div className="vtd-target-item-actions">
<span className={`vtd-target-price ${up ? 'vtd-target-up' : 'vtd-target-dn'}`}>
{up ? '+' : ''}{fmtKrw(p.profitLoss)}
</span>
</div>
)}
</div>
<div className="ptd-item-card-metrics">
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{p.quantity.toFixed(4)}</span>
</div>
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{fmtKrw(p.avgPrice)}</span>
</div>
{p.evalAmount != null && (
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value">{fmtKrw(p.evalAmount)}</span>
</div>
)}
{retPct != null && (
<div className="vtd-target-strategy-field">
<span className="vtd-target-strategy-label"></span>
<span className={`vtd-trade-meta-value ${retPct >= 0 ? 'vtd-target-up' : 'vtd-target-dn'}`}>
{retPct >= 0 ? '+' : ''}{retPct.toFixed(2)}%
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
return (
@@ -2,21 +2,42 @@ import React from 'react';
interface Props {
topTitle?: string;
/** 타이틀바 우측 (매수·매도 안내 등) */
topTitleAside?: React.ReactNode;
bottomTitle?: string;
bottomTitleAside?: React.ReactNode;
top: React.ReactNode;
bottom: React.ReactNode;
className?: string;
}
/** 좌·우 패널 상·하단 카드 분리 */
const PaperSplitPanel: React.FC<Props> = ({ topTitle, bottomTitle, top, bottom, className = '' }) => (
const PaperSplitPanel: React.FC<Props> = ({
topTitle,
topTitleAside,
bottomTitle,
bottomTitleAside,
top,
bottom,
className = '',
}) => (
<div className={`ptd-split-panel${className ? ` ${className}` : ''}`}>
<section className="ptd-split-card ptd-split-card--top">
{topTitle && <div className="ptd-split-card-head">{topTitle}</div>}
{topTitle && (
<div className="ptd-split-card-head">
<span className="ptd-split-card-head-label">{topTitle}</span>
{topTitleAside}
</div>
)}
<div className="ptd-split-card-body">{top}</div>
</section>
<section className="ptd-split-card ptd-split-card--bottom">
{bottomTitle && <div className="ptd-split-card-head">{bottomTitle}</div>}
{bottomTitle && (
<div className="ptd-split-card-head">
<span className="ptd-split-card-head-label">{bottomTitle}</span>
{bottomTitleAside}
</div>
)}
<div className="ptd-split-card-body">{bottom}</div>
</section>
</div>
@@ -45,11 +45,20 @@ const TradeSplitOrderPanel: React.FC<TradeSplitOrderPanelProps> = ({
const resolvedBuyFill = fillBuy ?? (fillRequest?.side === 'buy' ? fillRequest : null);
const resolvedSellFill = fillSell ?? (fillRequest?.side === 'sell' ? fillRequest : null);
const paperAutoOffHint =
paperTradingEnabled && !paperAutoTradeEnabled ? (
<span className="ptd-split-card-head-hint" title="자동매매 OFF · 수동 주문만 모의 계좌에 체결됩니다.">
OFF · .
</span>
) : null;
return (
<PaperSplitPanel
className={`ptd-split-panel--right${className ? ` ${className}` : ''}`}
topTitle="매수"
topTitleAside={paperAutoOffHint}
bottomTitle="매도"
bottomTitleAside={paperAutoOffHint}
top={(
<TradeBuyOrderPanel
market={market}