모의투자 관리 기능 추가

This commit is contained in:
Macbook
2026-05-31 03:47:07 +09:00
parent 9d7dddfa57
commit 16d0e2c226
78 changed files with 4688 additions and 972 deletions
+174 -123
View File
@@ -166,6 +166,8 @@ interface SettingsPageProps {
onVerificationIssueNotify?: (v: boolean) => void;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
initialCategory?: SettingsCategoryId;
}
// ── 카테고리 정의 ────────────────────────────────────────────────────────────
@@ -233,6 +235,19 @@ const IcPaper = () => (
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg>
);
const IcVirtual = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="6" width="8" height="10" rx="1.5"/>
<rect x="12" y="4" width="8" height="12" rx="1.5"/>
<path d="M10 11h2"/>
</svg>
);
const IcLive = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="9"/>
<path d="M8 11h6M11 8v6"/>
</svg>
);
const IcTrendSearch = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,17 9,11 13,14 18,6"/>
@@ -264,7 +279,9 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '가상매매', icon: <IcPaper />, desc: '가상 자본, 수수료, 투자대상 한도, 자동매매 ON/OFF 등 가상매매 설정' },
{ id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '모의투자 활성화, 자동매매, 초기 자본·수수료·슬리피지, 계좌 초기화' },
{ id: 'virtual', label: '가상매매', icon: <IcVirtual />, desc: '가상매매 화면 투자대상 목록 한도 등 가상매매 전용 설정' },
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
@@ -319,7 +336,7 @@ const StgCard: React.FC<{
// ── 각 카테고리 패널 ─────────────────────────────────────────────────────────
interface PaperPanelProps {
interface PaperTradingSettingsPanelProps {
paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number;
@@ -334,21 +351,10 @@ interface PaperPanelProps {
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
}
const PaperPanel: React.FC<PaperPanelProps> = ({
const PaperTradingSettingsPanel: React.FC<PaperTradingSettingsPanelProps> = ({
paperTradingEnabled = true,
onPaperTradingEnabled,
paperInitialCapital = 10_000_000,
@@ -363,63 +369,29 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
}) => (
<>
<SettingSection title="매매 운영 모드">
<SettingRow label="실행 대상" desc="가상투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
<option value="PAPER"></option>
<option value="LIVE"></option>
<option value="BOTH"> + </option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="가상매매">
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<SettingSection title="모의투자">
<SettingRow
label="모의투자 활성화"
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
>
<label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
<SettingSection title="자동매매">
<SettingRow
label="자동매매 ON/OFF"
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다."
label="자동매매"
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
>
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}>
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'}
</span>
<label className="stg-toggle">
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="자동 매수 예산 (%)"
@@ -436,9 +408,6 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
/>
</SettingRow>
{!paperAutoTradeEnabled && (
<p className="stg-hint"> ON/OFF는 . OFF Match· .</p>
)}
</SettingSection>
<SettingSection title="계좌·비용">
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
@@ -485,69 +454,135 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
</SettingRow>
</SettingSection>
<SettingSection title="계좌 관리">
<SettingRow label="계좌 초기화" desc="보유 종목·체결 이력을 삭제하고 초기 자본으로 현금을 되돌립니다.">
<SettingRow
label="계좌 초기화"
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
>
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
</button>
</SettingRow>
</SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</>
);
const VirtualTradingSettingsPanel: React.FC<{
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
}> = ({ virtualTargetMaxCount = 20, onVirtualTargetMaxCount }) => (
<SettingSection title="가상매매">
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
<p className="stg-hint">
·· .
</p>
</SettingSection>
);
const LiveTradingSettingsPanel: React.FC<{
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
}> = ({
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
<>
<SettingSection title="매매 운영 모드">
<SettingRow label="실행 대상" desc="모의투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
<option value="PAPER"></option>
<option value="LIVE"></option>
<option value="BOTH"> + </option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</>
);
};
@@ -1707,9 +1742,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onVerificationIssueNotify,
trendSearchSettings,
onTrendSearchSettingsChange,
initialCategory,
}) => {
const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general');
const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general');
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
useEffect(() => {
@@ -1719,6 +1755,13 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
}
}, [categories, active]);
useEffect(() => {
if (!initialCategory) return;
if (categories.some(c => c.id === initialCategory)) {
setActive(initialCategory);
}
}, [initialCategory, categories]);
useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null);
}, [active]);
@@ -1811,7 +1854,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
</AdminPasswordGate>
);
case 'paper': return (
<PaperPanel
<PaperTradingSettingsPanel
paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital}
@@ -1826,9 +1869,17 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'virtual': return (
<VirtualTradingSettingsPanel
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'live': return (
<LiveTradingSettingsPanel
tradingMode={tradingMode}
onTradingMode={onTradingMode}
liveAutoTradeEnabled={liveAutoTradeEnabled}