모의투자, 백테스팅 레이아웃 수정

This commit is contained in:
Macbook
2026-05-24 20:13:21 +09:00
parent 958b813f3b
commit af230a4233
23 changed files with 2394 additions and 782 deletions
@@ -0,0 +1,32 @@
import React, { useState } from 'react';
import type { Theme, Timeframe } from '../../types';
import PaperMiniChart from './PaperMiniChart';
import PaperIndicatorPanel from './PaperIndicatorPanel';
interface Props {
market: string;
theme?: Theme;
}
/** 모의투자 중앙 — 상단 캔들 + 하단 보조지표 (실시간 차트 레이아웃) */
const PaperAnalysisChart: React.FC<Props> = ({ market, theme = 'dark' }) => {
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
return (
<div className="ptd-analysis-chart">
<div className="ptd-main-pane">
<PaperMiniChart
market={market}
theme={theme}
timeframe={timeframe}
onTimeframeChange={setTimeframe}
/>
</div>
<div className="ptd-sub-panes">
<PaperIndicatorPanel market={market} timeframe={timeframe} stacked />
</div>
</div>
);
};
export default PaperAnalysisChart;
@@ -7,6 +7,8 @@ interface Props {
fillHeight?: boolean;
hideHeader?: boolean;
depth?: number;
/** split 패널용 — 매도/매수 호가만 */
section?: 'all' | 'asks' | 'bids';
}
function fmtPrice(p: number): string {
@@ -26,6 +28,7 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
fillHeight = false,
hideHeader = false,
depth = 8,
section = 'all',
}) => {
const { orderbook } = useUpbitOrderbook(market);
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
@@ -38,11 +41,15 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
);
const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0;
const showAsks = section === 'all' || section === 'asks';
const showBids = section === 'all' || section === 'bids';
const showMid = section === 'all' || section === 'asks';
return (
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
{!hideHeader && <div className="ptd-ob-head"> (Depth)</div>}
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}${section !== 'all' ? ' ptd-ob--section' : ''}`}>
{!hideHeader && section === 'all' && <div className="ptd-ob-head"> (Depth)</div>}
<div className="ptd-ob-body">
{asks.map(a => (
{showAsks && asks.map(a => (
<button
key={`a-${a.price}`}
type="button"
@@ -54,8 +61,11 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
</button>
))}
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
{bids.map(b => (
{showMid && section !== 'all' && (
<div className="ptd-ob-mid ptd-ob-mid--inline">{mid ? fmtPrice(mid) : '—'}</div>
)}
{section === 'all' && <div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>}
{showBids && bids.map(b => (
<button
key={`b-${b.price}`}
type="button"
@@ -40,9 +40,11 @@ function sparkPath(values: number[], w: number, h: number, min?: number, max?: n
interface Props {
market: string;
timeframe?: Timeframe;
/** 실시간 차트처럼 세로 스택 pane */
stacked?: boolean;
}
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h', stacked = false }) => {
const [rsi, setRsi] = React.useState<number[]>([]);
const [macd, setMacd] = React.useState<number[]>([]);
const [cci, setCci] = React.useState<number[]>([]);
@@ -54,7 +56,7 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
const bars = await fetchUpbitCandles(market, timeframe, 120);
if (cancelled) return;
const closes = bars.map(b => b.close);
setRsi(computeRsi(closes).slice(-40));
setRsi(computeRsi(closes).slice(-60));
const ema = (arr: number[], p: number) => {
const k = 2 / (p + 1);
let v = arr[0];
@@ -62,7 +64,7 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
};
const e12 = ema(closes, 12);
const e26 = ema(closes, 26);
setMacd(e12.slice(-40).map((v, i) => v - e26[closes.length - 40 + i]));
setMacd(e12.slice(-60).map((v, i) => v - e26[closes.length - 60 + i]));
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
const cciVals: number[] = [];
for (let i = 20; i < tp.length; i++) {
@@ -71,22 +73,25 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
}
setCci(cciVals.slice(-40));
setCci(cciVals.slice(-60));
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [market, timeframe]);
const panels = useMemo(() => [
{ label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] },
{ label: 'RSI (14)', values: rsi, color: '#a78bfa', ref: [30, 70] as number[] },
{ label: 'MACD', values: macd, color: '#38bdf8' },
{ label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] },
{ label: 'CCI (20)', values: cci, color: '#fbbf24', ref: [-100, 100] as number[] },
], [rsi, macd, cci]);
const rootClass = stacked ? 'ptd-ind-stack' : 'ptd-indicators';
const itemClass = stacked ? 'ptd-ind-row' : 'ptd-ind-panel';
return (
<div className="ptd-indicators">
<div className={rootClass}>
{panels.map(p => (
<div key={p.label} className="ptd-ind-panel">
<div key={p.label} className={itemClass}>
<div className="ptd-ind-label">{p.label}</div>
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
{p.ref?.map((r, i) => {
@@ -0,0 +1,133 @@
import React from 'react';
import { useAppSettings } from '../../hooks/useAppSettings';
import { resetPaperAccount } from '../../utils/backendApi';
import PaperSplitPanel from './PaperSplitPanel';
interface Props {
onSettingsSaved?: () => void;
onAccountReset?: () => void;
}
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset }) => {
const { defaults, save, isLoaded } = useAppSettings();
if (!isLoaded) {
return <p className="ptd-muted"> </p>;
}
const patch = (p: Parameters<typeof save>[0]) => {
save(p);
onSettingsSaved?.();
};
const handleReset = async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) onAccountReset?.();
};
const top = (
<>
<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>
<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>
</>
);
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>
<button type="button" className="bps-btn bps-btn--danger ptd-left-reset" onClick={() => void handleReset()}>
</button>
</>
);
return (
<PaperSplitPanel
className="ptd-split-panel--left"
topTitle="운영 · 자동매매"
bottomTitle="계좌 · 비용"
top={top}
bottom={bottom}
/>
);
};
export default PaperLeftSettingsTab;
@@ -0,0 +1,182 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
loadStrategies,
loadLiveStrategySettings,
saveLiveStrategySettings,
type LiveStrategySettingsDto,
type PaperSummaryDto,
type StrategyDto,
} from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
import PaperSplitPanel from './PaperSplitPanel';
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
interface Props {
market: string;
summary: PaperSummaryDto | null;
paperAutoTradeEnabled?: boolean;
onMarketSelect?: (market: string) => void;
}
const PaperLeftStrategyTab: React.FC<Props> = ({
market,
summary,
paperAutoTradeEnabled = false,
onMarketSelect,
}) => {
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [settings, setSettings] = useState<LiveStrategySettingsDto | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
}, []);
useEffect(() => {
let cancelled = false;
loadLiveStrategySettings(market)
.then(s => { if (!cancelled) setSettings(s); })
.catch(() => {
if (!cancelled) {
setSettings({
market,
strategyId: null,
isLiveCheck: false,
executionType: 'CANDLE_CLOSE',
positionMode: 'LONG_ONLY',
candleType: '1m',
});
}
});
return () => { cancelled = true; };
}, [market]);
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
if (!settings) return;
const next = { ...settings, ...patch, market };
const prev = settings;
setSettings(next);
setSaving(true);
try {
const saved = await saveLiveStrategySettings(next);
if (saved) setSettings(saved);
else setSettings(prev);
} catch {
setSettings(prev);
} finally {
setSaving(false);
}
}, [settings, market]);
const selected = strategies.find(s => s.id === settings?.strategyId);
const top = (
<>
<div className="ptd-asset-inline">
<div className="ptd-asset-total ptd-asset-total--sm">
{summary ? fmtKrw(summary.totalAsset) : '—'} <span>KRW</span>
</div>
<div className={`ptd-asset-ret ptd-asset-ret--sm ${(summary?.totalReturnPct ?? 0) >= 0 ? 'up' : 'down'}`}>
{summary ? `${(summary.totalReturnPct ?? 0) >= 0 ? '+' : ''}${summary.totalReturnPct.toFixed(2)}%` : '—'}
</div>
</div>
<div className="ptd-left-section">
<label className="ptd-left-row">
<span> </span>
<input
type="checkbox"
checked={settings?.isLiveCheck ?? false}
disabled={saving}
onChange={e => void persist({ isLiveCheck: e.target.checked })}
/>
</label>
<label className="ptd-left-field">
<span> </span>
<select
className="ptd-left-select"
value={settings?.strategyId ?? ''}
disabled={saving}
onChange={e => {
const v = e.target.value;
void persist({ strategyId: v ? Number(v) : null });
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
{selected && <p className="ptd-left-hint">: {selected.name}</p>}
<label className="ptd-left-field">
<span> </span>
<select
className="ptd-left-select"
value={settings?.candleType ?? '1m'}
disabled={saving}
onChange={e => void persist({ candleType: e.target.value })}
>
{CANDLE_TYPES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</label>
<label className="ptd-left-field">
<span> </span>
<select
className="ptd-left-select"
value={settings?.executionType ?? 'CANDLE_CLOSE'}
disabled={saving}
onChange={e => void persist({ executionType: e.target.value as LiveStrategySettingsDto['executionType'] })}
>
<option value="CANDLE_CLOSE"> </option>
<option value="REALTIME_TICK"> (3)</option>
</select>
</label>
</div>
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
{paperAutoTradeEnabled ? '자동매매 ON' : '자동매매 OFF — 설정 탭에서 변경'}
</p>
</>
);
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>
);
return (
<PaperSplitPanel
className="ptd-split-panel--left"
topTitle="전략 · 자산"
bottomTitle="보유 종목"
top={top}
bottom={bottom}
/>
);
};
export default PaperLeftStrategyTab;
@@ -9,6 +9,8 @@ interface Props {
market: string;
theme?: Theme;
wsLabel?: string;
timeframe?: Timeframe;
onTimeframeChange?: (tf: Timeframe) => void;
}
function readChartTheme(container: HTMLElement | null) {
@@ -25,11 +27,19 @@ function readChartTheme(container: HTMLElement | null) {
};
}
const PaperMiniChart: React.FC<Props> = ({ market, theme = 'dark', wsLabel = 'WebSocket <100ms' }) => {
const PaperMiniChart: React.FC<Props> = ({
market,
theme = 'dark',
wsLabel = 'WebSocket <100ms',
timeframe: timeframeProp,
onTimeframeChange,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
const [timeframeInternal, setTimeframeInternal] = useState<Timeframe>('1h');
const timeframe = timeframeProp ?? timeframeInternal;
const setTimeframe = onTimeframeChange ?? setTimeframeInternal;
const [loading, setLoading] = useState(true);
const applyTheme = useCallback((chart: IChartApi, series: ISeriesApi<'Candlestick'>) => {
@@ -0,0 +1,25 @@
import React from 'react';
interface Props {
topTitle?: string;
bottomTitle?: string;
top: React.ReactNode;
bottom: React.ReactNode;
className?: string;
}
/** 좌·우 패널 상·하단 카드 분리 */
const PaperSplitPanel: React.FC<Props> = ({ topTitle, bottomTitle, 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>}
<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>}
<div className="ptd-split-card-body">{bottom}</div>
</section>
</div>
);
export default PaperSplitPanel;