전략분봉 저장 에러 문제 수정
This commit is contained in:
@@ -125,11 +125,14 @@ public class LiveStrategySettingsService {
|
||||
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
||||
LiveStrategySettingsDto dto) {
|
||||
persistGlobalTemplate(userId, deviceId, dto);
|
||||
syncAllWatchlistMarkets(userId, deviceId);
|
||||
syncAllWatchlistMarkets(userId, deviceId, dto);
|
||||
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC";
|
||||
log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market);
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
LiveStrategySettingsDto out = mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market);
|
||||
LiveStrategySettingsDto out = findEntity(userId, deviceId, market)
|
||||
.map(this::toDto)
|
||||
.orElseGet(() -> defaultDtoFromApp(market, app));
|
||||
out = mergeGlobalTemplate(out, app, market);
|
||||
if (dto.getExecutionType() != null) {
|
||||
out.setExecutionType(dto.getExecutionType());
|
||||
}
|
||||
@@ -153,13 +156,20 @@ public class LiveStrategySettingsService {
|
||||
appSettingsService.save(userId, deviceId, patch);
|
||||
}
|
||||
|
||||
private void syncAllWatchlistMarkets(Long userId, String deviceId) {
|
||||
private void syncAllWatchlistMarkets(Long userId, String deviceId,
|
||||
LiveStrategySettingsDto template) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck())
|
||||
&& app.getLiveStrategyId() != null;
|
||||
String candleType = template != null && template.getCandleType() != null
|
||||
? LiveStrategyTimeframeService.normalize(template.getCandleType())
|
||||
: null;
|
||||
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
|
||||
LiveStrategySettingsDto row = defaultDtoFromApp(symbol, app);
|
||||
row.setLiveCheck(active);
|
||||
if (candleType != null) {
|
||||
row.setCandleType(candleType);
|
||||
}
|
||||
LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row);
|
||||
if (active) {
|
||||
pinIfReady(symbol, saved);
|
||||
|
||||
+152
-17
@@ -78,7 +78,9 @@ import {
|
||||
DEFAULT_BACKTEST_SETTINGS,
|
||||
loadBacktestSettings,
|
||||
loadStrategies as loadStrategiesForLive,
|
||||
loadLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
type BacktestSignal,
|
||||
} from './utils/backendApi';
|
||||
import { useIsMobile } from './hooks/useMediaQuery';
|
||||
import MobileChartDock from './components/MobileChartDock';
|
||||
@@ -94,6 +96,8 @@ import { invalidateAppSettingsCache } from './hooks/useAppSettings';
|
||||
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
|
||||
import './App.css';
|
||||
import './styles/appPopup.css';
|
||||
import './styles/paperDashboard.css';
|
||||
import './styles/backtestDashboard.css';
|
||||
|
||||
let _indCounter = 0;
|
||||
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
|
||||
@@ -611,15 +615,88 @@ function App() {
|
||||
const useUpbit = isUpbitMarket(symbol);
|
||||
|
||||
// ── 백테스팅 ───────────────────────────────────────────────────────────────
|
||||
const { signals: btSignals, stats: btStats, analysis: btAnalysis, running: btRunning, run: btRun, clear: btClear } = useBacktest();
|
||||
const {
|
||||
signals: btSignals,
|
||||
stats: btStats,
|
||||
analysis: btAnalysis,
|
||||
resultSymbol: btSymbol,
|
||||
resultTimeframe: btTimeframe,
|
||||
runSeq: btRunSeq,
|
||||
running: btRunning,
|
||||
run: btRun,
|
||||
clear: btClear,
|
||||
invalidateRuns: btInvalidateRuns,
|
||||
} = useBacktest();
|
||||
const [btSettings, setBtSettings] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||||
const [showBtSettings, setShowBtSettings] = useState(false);
|
||||
const [showBtResult, setShowBtResult] = useState(false);
|
||||
const [btStrategyName, setBtStrategyName] = useState('전략');
|
||||
|
||||
/** 현재 차트와 일치하는 백테스팅 결과만 UI·마커에 반영 */
|
||||
const btMatchesChart = useMemo(
|
||||
() =>
|
||||
btSignals.length > 0 &&
|
||||
btSymbol != null &&
|
||||
btTimeframe != null &&
|
||||
btSymbol === symbol &&
|
||||
btTimeframe === timeframe,
|
||||
[btSignals.length, btSymbol, btTimeframe, symbol, timeframe],
|
||||
);
|
||||
|
||||
const btMarkerPayloadRef = useRef<{
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
signals: BacktestSignal[];
|
||||
showPrice: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const syncBacktestMarkersRef = useRef<() => void>(() => {});
|
||||
|
||||
/** ChartManager 메인 시리즈 준비 후 백테스트 마커 적용 (로딩·remount 직후 대응) */
|
||||
const applyBacktestMarkersWhenReady = useCallback((
|
||||
payload: NonNullable<typeof btMarkerPayloadRef.current>,
|
||||
attempt = 0,
|
||||
) => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || !mgr.hasMainSeries()) {
|
||||
if (attempt < 30) {
|
||||
requestAnimationFrame(() => applyBacktestMarkersWhenReady(payload, attempt + 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
btMarkerPayloadRef.current = payload;
|
||||
mgr.setBacktestMarkers(payload.signals, payload.showPrice);
|
||||
}, []);
|
||||
|
||||
const syncBacktestMarkers = useCallback(() => {
|
||||
const payload = btMarkerPayloadRef.current;
|
||||
if (
|
||||
payload &&
|
||||
payload.symbol === symbol &&
|
||||
payload.timeframe === timeframe &&
|
||||
payload.signals.length > 0
|
||||
) {
|
||||
applyBacktestMarkersWhenReady(payload);
|
||||
return;
|
||||
}
|
||||
managerRef.current?.clearBacktestMarkers();
|
||||
}, [symbol, timeframe, applyBacktestMarkersWhenReady]);
|
||||
|
||||
syncBacktestMarkersRef.current = syncBacktestMarkers;
|
||||
|
||||
// ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ─────────────────
|
||||
const [showLivePanel, setShowLivePanel] = useState(false);
|
||||
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
|
||||
const [liveCandleType, setLiveCandleType] = useState('1m');
|
||||
|
||||
useEffect(() => {
|
||||
if (!appSettingsLoaded) return;
|
||||
loadLiveStrategySettings(symbol)
|
||||
.then(s => {
|
||||
if (s?.candleType) setLiveCandleType(s.candleType);
|
||||
})
|
||||
.catch(() => { /* 기본 1m 유지 */ });
|
||||
}, [symbol, appSettingsLoaded]);
|
||||
|
||||
const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({
|
||||
market: symbol,
|
||||
@@ -627,7 +704,8 @@ function App() {
|
||||
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
|
||||
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
|
||||
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
|
||||
}), [symbol, appDefaults]);
|
||||
candleType: liveCandleType,
|
||||
}), [symbol, appDefaults, liveCandleType]);
|
||||
|
||||
/** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */
|
||||
const monitoredMarkets = useMemo(() => {
|
||||
@@ -665,7 +743,15 @@ function App() {
|
||||
liveExecutionType: saved.executionType,
|
||||
livePositionMode: saved.positionMode,
|
||||
});
|
||||
}, [saveAppDef]);
|
||||
if (saved.candleType) setLiveCandleType(saved.candleType);
|
||||
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
|
||||
loadActiveLiveStrategySettings()
|
||||
.then(list => setMarketSubscriptions(
|
||||
list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })),
|
||||
))
|
||||
.catch(() => { /* 폴백 유지 */ });
|
||||
}
|
||||
}, [saveAppDef, appDefaults.liveStrategyCheck]);
|
||||
|
||||
const prevFavoritesRef = useRef<string[]>(getFavorites());
|
||||
|
||||
@@ -798,15 +884,37 @@ function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mainChartStyle]);
|
||||
|
||||
// 백테스팅 시그널 → ChartManager 마커 업데이트
|
||||
// 종목·타임프레임 변경 시 이전 백테스팅 결과·마커 제거 + 진행 중 run 무효화
|
||||
useEffect(() => {
|
||||
if (!managerRef.current) return;
|
||||
if (btSignals.length > 0) {
|
||||
managerRef.current.setBacktestMarkers(btSignals, appDefaults.btShowPrice);
|
||||
btInvalidateRuns();
|
||||
btMarkerPayloadRef.current = null;
|
||||
btClear();
|
||||
managerRef.current?.clearBacktestMarkers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
// 백테스팅 시그널 → ref 갱신 후 ChartManager 마커 동기화
|
||||
useEffect(() => {
|
||||
if (btMatchesChart && btSignals.length > 0) {
|
||||
btMarkerPayloadRef.current = {
|
||||
symbol,
|
||||
timeframe,
|
||||
signals: btSignals,
|
||||
showPrice: appDefaults.btShowPrice,
|
||||
};
|
||||
} else {
|
||||
managerRef.current.clearBacktestMarkers();
|
||||
btMarkerPayloadRef.current = null;
|
||||
}
|
||||
}, [btSignals, appDefaults.btShowPrice]);
|
||||
syncBacktestMarkers();
|
||||
let innerRaf = 0;
|
||||
const outerRaf = requestAnimationFrame(() => {
|
||||
innerRaf = requestAnimationFrame(syncBacktestMarkers);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(outerRaf);
|
||||
if (innerRaf) cancelAnimationFrame(innerRaf);
|
||||
};
|
||||
}, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, syncBacktestMarkers]);
|
||||
|
||||
// ── 시뮬레이션 데이터 (Upbit 아닐 때) ──────────────────────────────────
|
||||
const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
|
||||
@@ -1377,6 +1485,10 @@ function App() {
|
||||
<PaperTradingPage
|
||||
tickers={marketTickers}
|
||||
refreshKey={paperRefreshKey}
|
||||
defaultMarket={symbol}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => setPaperRefreshKey(k => k + 1)}
|
||||
onGoChart={m => {
|
||||
goToMarketChart(m);
|
||||
setMenuPage('chart');
|
||||
@@ -1574,19 +1686,42 @@ function App() {
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
backtestActive={btSignals.length > 0}
|
||||
backtestActive={btMatchesChart}
|
||||
backtestRunning={btRunning}
|
||||
backtestRunningId={undefined}
|
||||
onRunBacktest={menuPage === 'chart' && layoutDef.count === 1
|
||||
? async (opts) => {
|
||||
if (opts.strategyName) setBtStrategyName(opts.strategyName);
|
||||
await btRun({ ...opts, bars, timeframe, settings: btSettings, symbol });
|
||||
if (useUpbit && (isLoading || bars.length === 0)) return;
|
||||
const runSymbol = symbol;
|
||||
const runTf = timeframe;
|
||||
const r = await btRun({
|
||||
...opts,
|
||||
bars,
|
||||
timeframe: runTf,
|
||||
settings: btSettings,
|
||||
symbol: runSymbol,
|
||||
});
|
||||
if (
|
||||
r &&
|
||||
r.symbol === runSymbol &&
|
||||
r.timeframe === runTf &&
|
||||
r.signals.length > 0
|
||||
) {
|
||||
const showPrice = appDefaults.btShowPrice;
|
||||
applyBacktestMarkersWhenReady({
|
||||
symbol: runSymbol,
|
||||
timeframe: runTf,
|
||||
signals: r.signals,
|
||||
showPrice,
|
||||
});
|
||||
}
|
||||
if (appDefaults.btAutoPopup) setShowBtResult(true);
|
||||
}
|
||||
: undefined}
|
||||
onOpenBtSettings={() => setShowBtSettings(true)}
|
||||
onOpenBtResults={() => setShowBtResult(true)}
|
||||
hasBtResult={btSignals.length > 0}
|
||||
hasBtResult={btMatchesChart}
|
||||
onClearBtMarkers={() => { btClear(); managerRef.current?.clearBacktestMarkers(); }}
|
||||
onToggleLiveStrategy={() => setShowLivePanel(v => !v)}
|
||||
liveStrategyActive={showLivePanel || monitoredMarkets.length > 0}
|
||||
@@ -1801,12 +1936,13 @@ function App() {
|
||||
drawingsVisible={drawingsVisible}
|
||||
onCrosshair={setLegend}
|
||||
onTradeOrderRequest={applyTradeFill}
|
||||
onDataLoaded={() => syncBacktestMarkersRef.current()}
|
||||
onManagerReady={mgr => {
|
||||
managerRef.current = mgr;
|
||||
const pending = pendingRealtimeBarRef.current;
|
||||
if (pending) {
|
||||
if (pending && bars.length > 0) {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
const last = bars.length > 0 ? bars[bars.length - 1] : null;
|
||||
const last = bars[bars.length - 1];
|
||||
if (last && pending.time === last.time) mgr.updateBar(pending);
|
||||
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
|
||||
else mgr.updateBar({ ...pending, time: last.time });
|
||||
@@ -1815,8 +1951,7 @@ function App() {
|
||||
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
||||
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
// 백테스팅 마커가 이미 있으면 즉시 적용
|
||||
if (btSignals.length > 0) mgr.setBacktestMarkers(btSignals, appDefaults.btShowPrice);
|
||||
syncBacktestMarkersRef.current();
|
||||
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||
mgr.subscribeVisibleLogicalRange(r => {
|
||||
if (r && r.from < LOAD_MORE_TRIGGER) {
|
||||
@@ -1857,7 +1992,7 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* 백테스팅 결과 통계 배지 */}
|
||||
{btSignals.length > 0 && layoutDef.count === 1 && (
|
||||
{btMatchesChart && layoutDef.count === 1 && (
|
||||
<BacktestPanel
|
||||
stats={btStats}
|
||||
signalCount={btSignals.length}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* BacktestHistoryPage
|
||||
* ─────────────────────────────────────────────────────
|
||||
* [좌측] 체크박스 선택 + 삭제 가능한 이력 목록
|
||||
* [우측] 카드 기반 대시보드 결과 뷰
|
||||
* BacktestHistoryPage — 첨부 이미지 스타일 (타임라인 이력 + 대시보드)
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
loadBacktestResults,
|
||||
deleteBacktestResult,
|
||||
@@ -12,147 +9,75 @@ import {
|
||||
type BacktestAnalysis,
|
||||
type BacktestSignal,
|
||||
} from '../utils/backendApi';
|
||||
import { BacktestDashboard } from './BacktestResultModal';
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────────────────
|
||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
|
||||
import BacktestSparkline from './backtest/BacktestSparkline';
|
||||
|
||||
const pct = (v: number) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '–';
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
|
||||
|
||||
const fmtDateTime = (iso: string) => {
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
|
||||
time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`,
|
||||
};
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// ── 아이콘 ────────────────────────────────────────────────────────────────
|
||||
function parseDetail(r: BacktestResultRecord) {
|
||||
let analysis: BacktestAnalysis | null = null;
|
||||
let signals: BacktestSignal[] = [];
|
||||
try {
|
||||
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
|
||||
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
|
||||
} catch { /* ignore */ }
|
||||
return { analysis, signals };
|
||||
}
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<polyline points="1,3 13,3"/><line x1="5" y1="3" x2="5" y2="1"/><line x1="9" y1="3" x2="9" y2="1"/>
|
||||
<line x1="6.5" y1="1" x2="7.5" y2="1"/>
|
||||
<path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
|
||||
<line x1="5.5" y1="6" x2="5.5" y2="10"/><line x1="7" y1="6" x2="7" y2="10"/><line x1="8.5" y1="6" x2="8.5" y2="10"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcTrashAll = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<polyline points="1,3 13,3"/><path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
|
||||
<line x1="5.5" y1="6" x2="5.5" y2="10"/>
|
||||
<line x1="8.5" y1="6" x2="8.5" y2="10"/>
|
||||
<line x1="4" y1="1.5" x2="10" y2="1.5" strokeDasharray="1.5 1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcRefresh = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M12 7 A5 5 0 1 1 9.5 2.5"/>
|
||||
<polyline points="10,1 10,4 13,4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── 목록 아이템 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ListItemProps {
|
||||
interface TimelineItemProps {
|
||||
r: BacktestResultRecord;
|
||||
checked: boolean;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) {
|
||||
function TimelineItem({ r, active, onClick }: TimelineItemProps) {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const dt = fmtDateTime(r.createdAt);
|
||||
return (
|
||||
<div
|
||||
className={`bh-item ${active ? 'bh-item--active' : ''} ${checked ? 'bh-item--checked' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="bh-item-cb-wrap" onClick={e => { e.stopPropagation(); onToggle(); }}>
|
||||
<div className={`bh-cb ${checked ? 'bh-cb--on' : ''}`}>
|
||||
{checked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
|
||||
</div>
|
||||
</div>
|
||||
const positive = ret >= 0;
|
||||
|
||||
<div className="bh-item-body">
|
||||
<div className="bh-item-row1">
|
||||
<span className="bh-item-name">{r.strategyName || '전략 없음'}</span>
|
||||
<span className={`bh-item-ret ${ret > 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)}</span>
|
||||
</div>
|
||||
<div className="bh-item-row2">
|
||||
<span className="bh-item-symbol">{r.symbol}</span>
|
||||
<span className="bh-item-sep">·</span>
|
||||
<span className="bh-item-tf">{r.timeframe}</span>
|
||||
<span className="bh-item-sep">·</span>
|
||||
<span className="bh-item-trades">{r.totalTrades}회</span>
|
||||
</div>
|
||||
<div className="bh-item-row3">
|
||||
<span className="bh-item-date">{dt.date}</span>
|
||||
<span className="bh-item-time">{dt.time}</span>
|
||||
{(r.winRate ?? 0) > 0 && (
|
||||
<span className="bh-item-wr">승률 {((r.winRate ?? 0) * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
const sparkCurve = useMemo(() => {
|
||||
const { analysis, signals } = parseDetail(r);
|
||||
const cap = analysis?.initialCapital ?? 10_000_000;
|
||||
return buildEquityFromSignals(signals, cap, r.symbol.replace(/^KRW-/, '')).curve;
|
||||
}, [r]);
|
||||
|
||||
return (
|
||||
<button type="button" className={`btd-timeline-item${active ? ' btd-timeline-item--active' : ''}`} onClick={onClick}>
|
||||
<span className="btd-timeline-node" />
|
||||
<div className="btd-timeline-card">
|
||||
<div className="btd-timeline-top">
|
||||
<span className="btd-timeline-name">{r.strategyName || '전략 없음'}</span>
|
||||
<BacktestSparkline curve={sparkCurve} positive={positive} />
|
||||
</div>
|
||||
<span className="btd-timeline-date">{fmtDate(r.createdAt)}</span>
|
||||
<span className={`btd-timeline-roi${positive ? ' up' : ' down'}`}>
|
||||
ROI {pct(ret)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 메인 페이지 ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BacktestHistoryPage() {
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── 로드 ────────────────────────────────────────────────────────────────
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const list = await loadBacktestResults();
|
||||
setRecords(list);
|
||||
if (list.length > 0) setSelected(prev => prev ?? list[0]);
|
||||
setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ── 체크박스 ──────────────────────────────────────────────────────────
|
||||
const toggleCheck = useCallback((id: number) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setChecked(prev =>
|
||||
prev.size === records.length
|
||||
? new Set()
|
||||
: new Set(records.map(r => r.id))
|
||||
);
|
||||
}, [records]);
|
||||
|
||||
const allChecked = records.length > 0 && checked.size === records.length;
|
||||
const someChecked = checked.size > 0 && !allChecked;
|
||||
|
||||
// ── 삭제 ─────────────────────────────────────────────────────────────
|
||||
const deleteSelected = useCallback(async () => {
|
||||
if (checked.size === 0) return;
|
||||
if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||
await Promise.all([...checked].map(id => deleteBacktestResult(id)));
|
||||
setRecords(prev => {
|
||||
const next = prev.filter(r => !checked.has(r.id));
|
||||
if (selected && checked.has(selected.id)) setSelected(next[0] ?? null);
|
||||
return next;
|
||||
});
|
||||
setChecked(new Set());
|
||||
}, [checked, selected]);
|
||||
useEffect(() => { void fetchList(); }, [fetchList]);
|
||||
|
||||
const deleteAll = useCallback(async () => {
|
||||
if (records.length === 0) return;
|
||||
@@ -160,95 +85,37 @@ export function BacktestHistoryPage() {
|
||||
await Promise.all(records.map(r => deleteBacktestResult(r.id)));
|
||||
setRecords([]);
|
||||
setSelected(null);
|
||||
setChecked(new Set());
|
||||
}, [records]);
|
||||
|
||||
// ── 상세 데이터 파싱 ──────────────────────────────────────────────────
|
||||
const getDetail = (r: BacktestResultRecord) => {
|
||||
let analysis: BacktestAnalysis | null = null;
|
||||
let signals: BacktestSignal[] = [];
|
||||
try {
|
||||
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
|
||||
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
|
||||
} catch (_) {}
|
||||
return { analysis, signals };
|
||||
};
|
||||
const selectedDetail = selected ? parseDetail(selected) : null;
|
||||
|
||||
// ── 렌더 ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="bh-page">
|
||||
|
||||
{/* ── 좌측 목록 ── */}
|
||||
<aside className="bh-sidebar">
|
||||
|
||||
{/* 사이드바 헤더 */}
|
||||
<div className="bh-sidebar-header">
|
||||
<div className="bh-sidebar-title-row">
|
||||
<span className="bh-sidebar-title">백테스팅 이력</span>
|
||||
<span className="bh-sidebar-count">{records.length}</span>
|
||||
</div>
|
||||
<div className="bh-sidebar-actions">
|
||||
<button
|
||||
className="bh-act-btn"
|
||||
title="선택 항목 삭제"
|
||||
disabled={checked.size === 0}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<IcTrash />
|
||||
{checked.size > 0 && <span className="bh-act-badge">{checked.size}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="bh-act-btn bh-act-btn--danger"
|
||||
title="전체 삭제"
|
||||
disabled={records.length === 0}
|
||||
onClick={deleteAll}
|
||||
>
|
||||
<IcTrashAll />
|
||||
</button>
|
||||
<button
|
||||
className="bh-act-btn"
|
||||
title="새로고침"
|
||||
onClick={fetchList}
|
||||
>
|
||||
<IcRefresh />
|
||||
</button>
|
||||
<div className="btd-page">
|
||||
<aside className="btd-sidebar">
|
||||
<div className="btd-sidebar-head">
|
||||
<span className="btd-sidebar-bar" />
|
||||
<h2 className="btd-sidebar-title">백테스팅 실행 이력</h2>
|
||||
<div className="btd-sidebar-actions">
|
||||
<button type="button" className="btd-icon-btn" title="새로고침" onClick={() => void fetchList()}>↻</button>
|
||||
<button type="button" className="btd-icon-btn btd-icon-btn--danger" title="전체 삭제" disabled={records.length === 0} onClick={() => void deleteAll()}>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 선택 바 */}
|
||||
{records.length > 0 && (
|
||||
<div className="bh-select-all-bar" onClick={toggleAll}>
|
||||
<div className={`bh-cb ${allChecked ? 'bh-cb--on' : someChecked ? 'bh-cb--indeterminate' : ''}`}>
|
||||
{allChecked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
|
||||
{someChecked && <div className="bh-cb-dash"/>}
|
||||
</div>
|
||||
<span className="bh-select-all-label">
|
||||
{someChecked || allChecked ? `${checked.size}개 선택됨` : '전체 선택'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목록 */}
|
||||
{loading ? (
|
||||
<div className="bh-loading">
|
||||
<div className="bh-loading-spinner"/>
|
||||
<span>로딩 중…</span>
|
||||
</div>
|
||||
<div className="btd-sidebar-empty">로딩 중…</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="bh-empty-list">
|
||||
<div className="bh-empty-icon">📊</div>
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="bh-empty-sub">차트 화면에서 전략을 선택하고<br/>백테스팅을 실행하세요.</p>
|
||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bh-list">
|
||||
<div className="btd-timeline">
|
||||
<span className="btd-timeline-line" />
|
||||
{records.map(r => (
|
||||
<ListItem
|
||||
<TimelineItem
|
||||
key={r.id}
|
||||
r={r}
|
||||
checked={checked.has(r.id)}
|
||||
active={selected?.id === r.id}
|
||||
onToggle={() => toggleCheck(r.id)}
|
||||
onClick={() => setSelected(r)}
|
||||
/>
|
||||
))}
|
||||
@@ -256,28 +123,20 @@ export function BacktestHistoryPage() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* ── 우측 대시보드 ── */}
|
||||
<main className="bh-content">
|
||||
{selected ? (
|
||||
(() => {
|
||||
const { analysis, signals } = getDetail(selected);
|
||||
return (
|
||||
<div className="bh-detail">
|
||||
<BacktestDashboard
|
||||
analysis={analysis}
|
||||
signals={signals}
|
||||
<main className="btd-main">
|
||||
{selected && selectedDetail ? (
|
||||
<BacktestResultDashboard
|
||||
analysis={selectedDetail.analysis}
|
||||
signals={selectedDetail.signals}
|
||||
strategyName={selected.strategyName}
|
||||
symbol={selected.symbol}
|
||||
timeframe={selected.timeframe}
|
||||
barCount={selected.barCount}
|
||||
createdAt={selected.createdAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="bh-no-selection">
|
||||
<div className="bh-no-sel-icon">📈</div>
|
||||
<div className="btd-empty">
|
||||
<span>📈</span>
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
|
||||
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
|
||||
|
||||
interface Strategy {
|
||||
id: number;
|
||||
@@ -91,6 +94,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
const monCount = monitoredMarkets.length;
|
||||
const selectedStrategy = strategies.find(s => s.id === stratId);
|
||||
|
||||
const candleType = settings.candleType ?? '1m';
|
||||
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
||||
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
|
||||
? '보유 자산 기준'
|
||||
@@ -156,16 +160,24 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<span className="lsp-info-label">모니터링</span>
|
||||
<span className="lsp-info-val lsp-monitor-chips">
|
||||
{monitoredMarkets.slice(0, 8).map(m => (
|
||||
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
|
||||
<span key={m} className="lsp-monitor-chip" title={m}>
|
||||
{getKoreanName(m)}
|
||||
</span>
|
||||
))}
|
||||
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{isOn && selectedStrategy && (
|
||||
<>
|
||||
<div className="lsp-info-row">
|
||||
<span className="lsp-info-label">적용 전략</span>
|
||||
<span className="lsp-info-val">{selectedStrategy.name}</span>
|
||||
</div>
|
||||
<div className="lsp-info-row">
|
||||
<span className="lsp-info-label">평가 분봉</span>
|
||||
<span className="lsp-info-val">{candleType}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,6 +211,20 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
|
||||
<span className="lsp-field-label">전략 평가 분봉</span>
|
||||
<select
|
||||
className="lsp-select"
|
||||
disabled={!isOn}
|
||||
value={candleType}
|
||||
onChange={e => persist({ candleType: e.target.value })}
|
||||
>
|
||||
{CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
|
||||
<span className="lsp-section-label">실행 방식</span>
|
||||
<div className="lsp-option-group">
|
||||
@@ -276,7 +302,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
|
||||
<span className="lsp-status-text">
|
||||
{stratId
|
||||
? `${selectedStrategy?.name ?? '전략'} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
||||
? `${selectedStrategy?.name ?? '전략'} · ${candleType} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
||||
: '전략을 선택하세요'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
|
||||
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
@@ -10,16 +10,31 @@ import {
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { computePaperMetrics } from '../utils/paperMetrics';
|
||||
import { fmtKrw } from './TradeOrderPanel';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import type { TradeOrderFillRequest } from '../types';
|
||||
import PaperMiniChart from './paper/PaperMiniChart';
|
||||
import PaperIndicatorPanel from './paper/PaperIndicatorPanel';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
|
||||
type TabId = 'overview' | 'positions' | 'trades';
|
||||
const COIN_COLORS: Record<string, string> = {
|
||||
BTC: '#f7931a', ETH: '#627eea', XRP: '#23292f', SOL: '#9945ff',
|
||||
DOGE: '#c2a633', ADA: '#0033ad', DOT: '#e6007a', AVAX: '#e84142',
|
||||
};
|
||||
const DONUT_COLORS = ['#3f7ef5', '#22c55e', '#a78bfa', '#fbbf24', '#38bdf8', '#64748b'];
|
||||
|
||||
type HistoryTab = 'open' | 'recent';
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
|
||||
interface Props {
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
onGoChart?: (market: string) => void;
|
||||
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
|
||||
refreshKey?: number;
|
||||
defaultMarket?: string;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
}
|
||||
|
||||
function buildMarkPrices(
|
||||
@@ -27,12 +42,10 @@ function buildMarkPrices(
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): Record<string, number> {
|
||||
const m: Record<string, number> = {};
|
||||
if (summary?.positions) {
|
||||
for (const p of summary.positions) {
|
||||
summary?.positions?.forEach(p => {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
}
|
||||
}
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -41,277 +54,313 @@ function positionPriceKey(
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): string {
|
||||
if (!positions?.length) return '';
|
||||
return positions
|
||||
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
|
||||
.join('|');
|
||||
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
|
||||
const [tab, setTab] = useState<TabId>('overview');
|
||||
function coinCode(symbol: string): string {
|
||||
return symbol.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function coinIcon(code: string): string {
|
||||
return code.slice(0, 1);
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({
|
||||
tickers,
|
||||
refreshKey = 0,
|
||||
defaultMarket = 'KRW-BTC',
|
||||
paperTradingEnabled = true,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperOrderFilled,
|
||||
}) => {
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const fillSeqRef = useRef(0);
|
||||
|
||||
const tickersRef = useRef(tickers);
|
||||
tickersRef.current = tickers;
|
||||
|
||||
const summaryRef = useRef(summary);
|
||||
summaryRef.current = summary;
|
||||
|
||||
const loadData = useCallback(async (opts?: { showSpinner?: boolean }) => {
|
||||
const showSpinner = opts?.showSpinner ?? false;
|
||||
if (showSpinner) setRefreshing(true);
|
||||
setError(null);
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const base = await loadPaperSummary();
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
const full = Object.keys(marks).length > 0
|
||||
? await loadPaperSummary(marks)
|
||||
: base;
|
||||
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
|
||||
setSummary(full);
|
||||
setTrades(await loadPaperTrades());
|
||||
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
|
||||
} catch {
|
||||
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (showSpinner) setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 최초 진입·수동 새로고침 */
|
||||
const refresh = useCallback(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
useEffect(() => { void loadData(); }, [loadData]);
|
||||
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshKey <= 0) return;
|
||||
void loadData();
|
||||
}, [refreshKey, loadData]);
|
||||
|
||||
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
const lastPriceKeyRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!priceKey || initialLoading) return;
|
||||
if (priceKey === lastPriceKeyRef.current) return;
|
||||
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const base = summaryRef.current;
|
||||
if (!base?.positions?.length) return;
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
if (Object.keys(marks).length === 0) return;
|
||||
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
|
||||
if (!Object.keys(marks).length) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) {
|
||||
setSummary(res);
|
||||
setTrades([]);
|
||||
lastPriceKeyRef.current = '';
|
||||
alert('모의투자 계좌가 초기화되었습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
||||
const s = summary;
|
||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
||||
const showPanelLoading = initialLoading;
|
||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||
|
||||
const posQty = useMemo(() => {
|
||||
const p = s?.positions?.find(x => x.symbol === selectedMarket);
|
||||
return p?.quantity ?? 0;
|
||||
}, [s?.positions, selectedMarket]);
|
||||
|
||||
const allocation = useMemo(() => {
|
||||
if (!s) return [] as { label: string; value: number; pct: number }[];
|
||||
const items: { label: string; value: number }[] = [];
|
||||
if (s.cashBalance > 0) items.push({ label: 'KRW', value: s.cashBalance });
|
||||
s.positions.forEach(p => {
|
||||
const v = p.evalAmount ?? p.quantity * (p.markPrice ?? p.avgPrice);
|
||||
if (v > 0) items.push({ label: coinCode(p.symbol), value: v });
|
||||
});
|
||||
const total = items.reduce((a, x) => a + x.value, 0) || 1;
|
||||
return items.map(x => ({ ...x, pct: (x.value / total) * 100 }));
|
||||
}, [s]);
|
||||
|
||||
const donutStyle = useMemo(() => {
|
||||
if (!allocation.length) return { background: '#1f2335' };
|
||||
let acc = 0;
|
||||
const stops = allocation.map((a, i) => {
|
||||
const start = acc;
|
||||
acc += a.pct;
|
||||
return `${DONUT_COLORS[i % DONUT_COLORS.length]} ${start}% ${acc}%`;
|
||||
});
|
||||
return { background: `conic-gradient(${stops.join(', ')})` };
|
||||
}, [allocation]);
|
||||
|
||||
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
const side = rowType === 'ask' ? 'buy' : 'sell';
|
||||
fillSeqRef.current += 1;
|
||||
const req: TradeOrderFillRequest = {
|
||||
market: selectedMarket,
|
||||
price,
|
||||
side,
|
||||
seq: fillSeqRef.current,
|
||||
};
|
||||
if (side === 'buy') setFillBuy(req); else setFillSell(req);
|
||||
setRightTab('trade');
|
||||
}, [selectedMarket]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) { setSummary(res); setTrades([]); }
|
||||
}, []);
|
||||
|
||||
if (initialLoading) {
|
||||
return <div className="ptd-page ptd-page--loading">모의투자 대시보드 로딩…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="paper-page">
|
||||
<header className="paper-header">
|
||||
<div>
|
||||
<h1 className="paper-title">모의투자</h1>
|
||||
<p className="paper-subtitle">
|
||||
실시간 차트·전략 시그널과 연동된 가상 계좌입니다. 실제 거래소 주문은 발생하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="paper-header-actions">
|
||||
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
|
||||
{refreshing ? '새로고침 중…' : '새로고침'}
|
||||
</button>
|
||||
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
|
||||
계좌 초기화
|
||||
</button>
|
||||
<div className="ptd-page">
|
||||
<header className="ptd-topbar">
|
||||
<h1 className="ptd-title">모의투자</h1>
|
||||
<div className="ptd-topbar-actions">
|
||||
<button type="button" className="ptd-btn" onClick={() => void loadData()}>새로고침</button>
|
||||
<button type="button" className="ptd-btn ptd-btn--danger" onClick={handleReset}>계좌 초기화</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!s?.enabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--warn">
|
||||
모의투자가 꺼져 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.
|
||||
</div>
|
||||
{!s?.enabled && (
|
||||
<div className="ptd-banner ptd-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 ON — 실시간 전략 시그널(BUY/SELL) 발생 시 모의 계좌에 자동 체결됩니다.
|
||||
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 전량)
|
||||
<section className="ptd-metrics">
|
||||
<div className="ptd-metrics-grid">
|
||||
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
|
||||
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
|
||||
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
|
||||
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 OFF — 시그널은 알림만 표시되며, 매매는 차트 우측 매수·매도에서 수동으로 진행합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="paper-banner paper-banner--error">{error}</div>}
|
||||
|
||||
<div className="paper-summary-grid">
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 평가자산</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주문가능 현금</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주식 평가금액</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 수익률</span>
|
||||
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">평가손익</span>
|
||||
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">실현손익</span>
|
||||
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
|
||||
</span>
|
||||
<div className="ptd-main">
|
||||
<aside className="ptd-col ptd-col--left">
|
||||
<div className="ptd-card ptd-asset-card">
|
||||
<div className="ptd-card-label">실시간 자산 현황</div>
|
||||
<div className="ptd-asset-total">{s ? fmtKrw(s.totalAsset) : '—'} <span>KRW</span></div>
|
||||
<div className={`ptd-asset-ret ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${retUp ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</div>
|
||||
<div className="ptd-asset-sub">초기자본 {s ? fmtKrw(s.initialCapital) : '—'} KRW</div>
|
||||
</div>
|
||||
|
||||
<nav className="paper-tabs">
|
||||
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`paper-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="paper-panel">
|
||||
{showPanelLoading && <p className="paper-muted">로딩 중...</p>}
|
||||
|
||||
{!showPanelLoading && tab === 'overview' && s && (
|
||||
<dl className="paper-dl">
|
||||
<dt>초기 자본</dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
|
||||
<dt>수수료율</dt><dd>{s.feeRatePct}%</dd>
|
||||
<dt>슬리피지</dt><dd>{s.slippagePct}%</dd>
|
||||
<dt>최소 주문</dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
|
||||
<dt>자동매매</dt>
|
||||
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
|
||||
<dt>보유 종목 수</dt><dd>{s.positions.length}개</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'positions' && (
|
||||
<table className="paper-table">
|
||||
<div className="ptd-card">
|
||||
<div className="ptd-card-head">보유 포트폴리오</div>
|
||||
<table className="ptd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<th>수량</th>
|
||||
<th>평균단가</th>
|
||||
<th>현재가</th>
|
||||
<th>평가금액</th>
|
||||
<th>평가손익</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr><th>자산</th><th>현재가</th><th>수량</th><th>손익</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={7} className="paper-muted">보유 종목이 없습니다.</td></tr>
|
||||
) : s!.positions.map(p => (
|
||||
<tr key={p.symbol}>
|
||||
<tr><td colSpan={4} className="ptd-muted">보유 종목 없음</td></tr>
|
||||
) : s!.positions.map(p => {
|
||||
const code = coinCode(p.symbol);
|
||||
const up = (p.profitLoss ?? 0) >= 0;
|
||||
return (
|
||||
<tr key={p.symbol} className={selectedMarket === p.symbol ? 'ptd-row--sel' : ''} onClick={() => setSelectedMarket(p.symbol)}>
|
||||
<td>
|
||||
<strong>{getKoreanName(p.symbol)}</strong>
|
||||
<span className="paper-sym">{p.symbol}</span>
|
||||
<span className="ptd-coin" style={{ background: COIN_COLORS[code] ?? '#64748b' }}>{coinIcon(code)}</span>
|
||||
{code}
|
||||
</td>
|
||||
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(p.avgPrice)}</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
|
||||
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{onGoChart && (
|
||||
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
|
||||
차트
|
||||
</button>
|
||||
)}
|
||||
<td>{p.quantity.toFixed(4)}</td>
|
||||
<td className={up ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
|
||||
{p.profitLossPct != null && <small> ({p.profitLossPct.toFixed(1)}%)</small>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showPanelLoading && tab === 'trades' && (
|
||||
<table className="paper-table">
|
||||
<div className="ptd-card ptd-donut-card">
|
||||
<div className="ptd-card-head">투자 비중</div>
|
||||
<div className="ptd-donut-wrap">
|
||||
<div className="ptd-donut" style={donutStyle}>
|
||||
<div className="ptd-donut-hole">
|
||||
<span>{s ? `${(s.totalAsset / 1_000_000).toFixed(1)}M` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="ptd-donut-legend">
|
||||
{allocation.map((a, i) => (
|
||||
<li key={a.label}>
|
||||
<span className="ptd-dot" style={{ background: DONUT_COLORS[i % DONUT_COLORS.length] }} />
|
||||
{a.label} {a.pct.toFixed(0)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="ptd-col ptd-col--center">
|
||||
<div className="ptd-card ptd-chart-card">
|
||||
<PaperMiniChart market={selectedMarket} />
|
||||
<PaperIndicatorPanel market={selectedMarket} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="ptd-col ptd-col--right" ref={orderAnchorRef}>
|
||||
<div className="ptd-right-panel">
|
||||
<div className="ptd-tabs ptd-tabs--main">
|
||||
<button type="button" className={`ptd-tab${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||
<button type="button" className={`ptd-tab${rightTab === 'orderbook' ? ' active' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
||||
</div>
|
||||
<div className="ptd-right-body">
|
||||
{rightTab === 'trade' ? (
|
||||
<>
|
||||
<div className="ptd-card ptd-order-card">
|
||||
<div className="ptd-card-head">주문</div>
|
||||
<div className="ptd-order-stack">
|
||||
<TradeOrderPanel
|
||||
side="buy"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={s?.cashBalance ?? 0}
|
||||
fillRequest={fillBuy}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ptd-card ptd-history-card">
|
||||
<div className="ptd-tabs">
|
||||
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
|
||||
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>최근체결</button>
|
||||
</div>
|
||||
{historyTab === 'open' ? (
|
||||
<p className="ptd-muted">미체결 주문 없음 (모의투자 즉시 체결)</p>
|
||||
) : (
|
||||
<table className="ptd-table ptd-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>출처</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>수수료</th>
|
||||
<th>체결후 현금</th>
|
||||
</tr>
|
||||
<tr><th>시간</th><th>자산</th><th>유형</th><th>가격</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr><td colSpan={8} className="paper-muted">체결 내역이 없습니다.</td></tr>
|
||||
) : trades.map(t => (
|
||||
{trades.slice(0, 12).map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
|
||||
<td>{getKoreanName(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
|
||||
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
|
||||
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
|
||||
<td>{coinCode(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(t.feeAmount)}</td>
|
||||
<td>{fmtKrw(t.cashAfter)}</td>
|
||||
<td><span className="ptd-status ptd-status--done">체결</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!trades.length && <tr><td colSpan={5} className="ptd-muted">체결 내역 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricCard({ icon, title, value, sub, tone }: {
|
||||
icon: string; title: string; value: string; sub: string; tone: 'up' | 'down';
|
||||
}) {
|
||||
return (
|
||||
<div className={`ptd-metric ptd-metric--${tone}`}>
|
||||
<span className="ptd-metric-icon">{icon}</span>
|
||||
<div>
|
||||
<div className="ptd-metric-title">{title}</div>
|
||||
<div className="ptd-metric-value">{value}</div>
|
||||
<div className="ptd-metric-sub">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaperTradingPage;
|
||||
|
||||
@@ -147,6 +147,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevBarsKey = useRef<string>('');
|
||||
const prevIndKey = useRef<string>('');
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevMarket = useRef<string>(market);
|
||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||
const prevChartType = useRef<ChartType>(chartType);
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
@@ -252,6 +254,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}, [candleOnlyMode, chartMgr, applyPaneLayout]);
|
||||
|
||||
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
|
||||
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const reloadAll = useCallback(async (
|
||||
mgr: ChartManager,
|
||||
newBars: OHLCVBar[],
|
||||
@@ -262,11 +266,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
) => {
|
||||
if (newBars.length === 0) return;
|
||||
|
||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||
reloadSafetyTimers.current = [];
|
||||
|
||||
barsRef.current = newBars;
|
||||
prevChartType.current = ct;
|
||||
prevTheme.current = th;
|
||||
prevLogScale.current = ls;
|
||||
prevBarsKey.current = barsKey(newBars);
|
||||
prevBarsKey.current = barsKey(newBars, market);
|
||||
prevIndKey.current = indKey(inds);
|
||||
prevSortedPKRef.current = sortedParamKey(inds);
|
||||
|
||||
@@ -275,15 +282,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.setLogScale(ls);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
|
||||
// 인디케이터를 순차적으로 등록 (async/await 보장)
|
||||
for (const ind of inds) {
|
||||
await mgr.addIndicator(ind);
|
||||
}
|
||||
|
||||
// 인디케이터 추가 완료 후 RAF × 2 대기
|
||||
// → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도)
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => {
|
||||
const futureBars = mgr.hasIchimoku() ? 28 : 0;
|
||||
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
|
||||
@@ -291,12 +295,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
});
|
||||
})));
|
||||
|
||||
// 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용
|
||||
onDataLoaded?.();
|
||||
|
||||
// ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용
|
||||
// applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음
|
||||
const safetyTimers = [300, 700, 1400].map(delay =>
|
||||
reloadSafetyTimers.current = [300, 700, 1400].map(delay =>
|
||||
setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries()) return;
|
||||
@@ -307,10 +308,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||
}, delay)
|
||||
);
|
||||
// cleanup 시 타이머 해제 (컴포넌트 언마운트 대응)
|
||||
// 반환값이 없으므로 managerRef 체크로 충분
|
||||
void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨
|
||||
}, [applyPaneLayout, onDataLoaded]);
|
||||
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market]);
|
||||
|
||||
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -592,6 +590,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
clearTimeout(drawingSingleTimerRef.current);
|
||||
drawingSingleTimerRef.current = null;
|
||||
}
|
||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||
reloadSafetyTimers.current = [];
|
||||
ro.disconnect();
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
@@ -604,6 +604,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
|
||||
}, [displayTimezone, timeframe]);
|
||||
|
||||
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
|
||||
useEffect(() => {
|
||||
if (prevMarket.current === market && prevMarketTf.current === timeframe) return;
|
||||
prevMarket.current = market;
|
||||
prevMarketTf.current = timeframe;
|
||||
prevBarsKey.current = '';
|
||||
}, [market, timeframe]);
|
||||
|
||||
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
@@ -615,7 +623,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const bk = barsKey(bars);
|
||||
const bk = barsKey(bars, market);
|
||||
const ik = indKey(indicators);
|
||||
const barsChanged = bk !== prevBarsKey.current;
|
||||
const indChanged = ik !== prevIndKey.current;
|
||||
@@ -905,11 +913,10 @@ const PaneLegendPortal: React.FC<
|
||||
};
|
||||
|
||||
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
||||
function barsKey(bars: OHLCVBar[]): string {
|
||||
function barsKey(bars: OHLCVBar[], market = ''): string {
|
||||
if (bars.length === 0) return '';
|
||||
const last = bars[bars.length - 1];
|
||||
// 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함
|
||||
return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
return `${market}:${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import type { EquityPoint, TradeMarker } from '../../utils/backtestEquity';
|
||||
|
||||
interface Props {
|
||||
curve: EquityPoint[];
|
||||
markers: TradeMarker[];
|
||||
}
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtMonth(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
interface LineSegment {
|
||||
d: string;
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
const BacktestAssetChart: React.FC<Props> = memo(({ curve, markers }) => {
|
||||
const W = 800;
|
||||
const H = 280;
|
||||
const pad = { t: 22, r: 14, b: 26, l: 48 };
|
||||
const innerW = W - pad.l - pad.r;
|
||||
const innerH = H - pad.t - pad.b;
|
||||
|
||||
const chart = useMemo(() => {
|
||||
if (curve.length < 2) {
|
||||
const y = pad.t + innerH * 0.5;
|
||||
return {
|
||||
areaPath: `M${pad.l},${y} L${pad.l + innerW},${y} L${pad.l + innerW},${pad.t + innerH} L${pad.l},${pad.t + innerH} Z`,
|
||||
segments: [] as LineSegment[],
|
||||
yTicks: [0, 1],
|
||||
xLabels: [] as { x: number; label: string }[],
|
||||
markerPts: [] as Array<{ x: number; y: number; m: TradeMarker }>,
|
||||
toY: (_e: number) => y,
|
||||
};
|
||||
}
|
||||
|
||||
const times = curve.map(p => p.time);
|
||||
const equities = curve.map(p => p.equity);
|
||||
const tMin = Math.min(...times);
|
||||
const tMax = Math.max(...times);
|
||||
const eMin = Math.min(...equities) * 0.985;
|
||||
const eMax = Math.max(...equities) * 1.015;
|
||||
const tRange = tMax - tMin || 1;
|
||||
const eRange = eMax - eMin || 1;
|
||||
|
||||
const toX = (t: number) => pad.l + ((t - tMin) / tRange) * innerW;
|
||||
const toY = (e: number) => pad.t + innerH - ((e - eMin) / eRange) * innerH;
|
||||
|
||||
const linePath = curve.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.time).toFixed(1)},${toY(p.equity).toFixed(1)}`).join(' ');
|
||||
const areaPath = `${linePath} L${toX(curve[curve.length - 1].time).toFixed(1)},${pad.t + innerH} L${toX(curve[0].time).toFixed(1)},${pad.t + innerH} Z`;
|
||||
|
||||
const segments: LineSegment[] = [];
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
segments.push({
|
||||
d: `M${toX(curve[i - 1].time).toFixed(1)},${toY(curve[i - 1].equity).toFixed(1)} L${toX(curve[i].time).toFixed(1)},${toY(curve[i].equity).toFixed(1)}`,
|
||||
up: curve[i].equity >= curve[i - 1].equity,
|
||||
});
|
||||
}
|
||||
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(r => eMin + eRange * r);
|
||||
const xLabels: { x: number; label: string }[] = [];
|
||||
const step = Math.max(1, Math.floor(curve.length / 5));
|
||||
for (let i = 0; i < curve.length; i += step) {
|
||||
xLabels.push({ x: toX(curve[i].time), label: i === 0 ? fmtMonth(curve[i].time) : fmtDate(curve[i].time) });
|
||||
}
|
||||
const last = curve[curve.length - 1];
|
||||
if (!xLabels.some(l => l.label === fmtDate(last.time))) {
|
||||
xLabels.push({ x: toX(last.time), label: fmtDate(last.time) });
|
||||
}
|
||||
|
||||
const markerPts = markers.map(m => ({ x: toX(m.time), y: toY(m.equity), m }));
|
||||
|
||||
return { areaPath, segments, yTicks, xLabels, markerPts, toY };
|
||||
}, [curve, markers, innerH, innerW, pad.l, pad.t]);
|
||||
|
||||
const fmtY = (v: number) => {
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (v >= 10_000) return `${Math.round(v / 10_000)}만`;
|
||||
return Math.round(v).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="btd-chart-wrap">
|
||||
<svg className="btd-chart-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="btd-area-grad-up" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(34,197,94,0.28)" />
|
||||
<stop offset="100%" stopColor="rgba(34,197,94,0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map(r => {
|
||||
const y = pad.t + innerH * (1 - r);
|
||||
return <line key={r} x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(201,162,39,0.07)" strokeWidth="1" />;
|
||||
})}
|
||||
|
||||
<text x={pad.l - 8} y={pad.t - 6} textAnchor="start" fill="#6272a4" fontSize="8">자본금</text>
|
||||
|
||||
{chart.yTicks.map(v => (
|
||||
<text
|
||||
key={v}
|
||||
x={pad.l - 4}
|
||||
y={chart.toY(v)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
fill="#6272a4"
|
||||
fontSize="8.5"
|
||||
>
|
||||
{fmtY(v)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<path d={chart.areaPath} fill="url(#btd-area-grad-up)" />
|
||||
{chart.segments.map((seg, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={seg.d}
|
||||
fill="none"
|
||||
stroke={seg.up ? '#22c55e' : '#ef4444'}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
|
||||
{chart.markerPts.map(({ x, y, m }, i) => (
|
||||
<g key={`${m.time}-${m.type}-${i}`}>
|
||||
{m.type === 'buy' ? (
|
||||
<>
|
||||
<polygon points={`${x},${y - 9} ${x - 4},${y - 2} ${x + 4},${y - 2}`} fill="#22c55e" />
|
||||
<text x={x} y={y - 12} textAnchor="middle" fill="#22c55e" fontSize="7.5" fontWeight="700">
|
||||
{fmtDate(m.time).slice(5)}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<polygon points={`${x},${y + 9} ${x - 4},${y + 2} ${x + 4},${y + 2}`} fill="#ef4444" />
|
||||
<text x={x} y={y + 20} textAnchor="middle" fill={m.pnlPct != null && m.pnlPct >= 0 ? '#22c55e' : '#ef4444'} fontSize="7.5" fontWeight="700">
|
||||
{m.pnlPct != null ? `${m.pnlPct >= 0 ? '+' : ''}${(m.pnlPct * 100).toFixed(1)}%` : '매도'}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{chart.xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={H - 5} textAnchor="middle" fill="#6272a4" fontSize="8">
|
||||
{l.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BacktestAssetChart.displayName = 'BacktestAssetChart';
|
||||
export default BacktestAssetChart;
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* BacktestResultDashboard — 첨부 이미지 스타일 백테스팅 결과 대시보드
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import type { BacktestAnalysis, BacktestSignal } from '../../utils/backendApi';
|
||||
import { buildEquityFromSignals, type TradeHistoryRow } from '../../utils/backtestEquity';
|
||||
import BacktestAssetChart from './BacktestAssetChart';
|
||||
|
||||
export interface BacktestResultDashboardProps {
|
||||
analysis: BacktestAnalysis | null;
|
||||
signals: BacktestSignal[];
|
||||
strategyName: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
barCount: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
const pct = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%';
|
||||
|
||||
const pctAbs = (v: number, dec = 0) =>
|
||||
isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '–';
|
||||
|
||||
const num = (v: number, dec = 2) =>
|
||||
isFinite(v) ? v.toFixed(dec) : '–';
|
||||
|
||||
function fmtDateTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function SectionTitle({ title, sub }: { title: string; sub?: string }) {
|
||||
return (
|
||||
<div className="btd-section-head">
|
||||
<span className="btd-section-bar" />
|
||||
<div>
|
||||
<h2 className="btd-section-title">{title}</h2>
|
||||
{sub && <p className="btd-section-sub">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BacktestResultDashboard({
|
||||
analysis,
|
||||
signals,
|
||||
strategyName: _strategyName,
|
||||
symbol,
|
||||
timeframe: _timeframe,
|
||||
barCount: _barCount,
|
||||
createdAt: _createdAt,
|
||||
}: BacktestResultDashboardProps) {
|
||||
const a = analysis;
|
||||
|
||||
const { curve, markers, trades } = useMemo(() => {
|
||||
if (!a) return { curve: [], markers: [], trades: [] };
|
||||
return buildEquityFromSignals(signals, a.initialCapital, symbol.startsWith('KRW-') ? symbol : `KRW-${symbol.replace(/^KRW-/, '')}`);
|
||||
}, [a, signals, symbol]);
|
||||
|
||||
if (!a) {
|
||||
return (
|
||||
<div className="btd-empty">
|
||||
<span>📊</span>
|
||||
<p>결과 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profitFactor = a.profitLossRatio > 0 ? a.profitLossRatio : (
|
||||
a.grossLoss !== 0 ? Math.abs(a.grossProfit / a.grossLoss) : 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="btd-dashboard">
|
||||
<div className="btd-section btd-section--kpi">
|
||||
<SectionTitle title="핵심 성과 지표" sub="Core KPIs" />
|
||||
<div className="btd-kpi-grid">
|
||||
<div className="btd-kpi-card">
|
||||
<div className="btd-kpi-label">총 수익률 (ROI)</div>
|
||||
<div className={`btd-kpi-value ${a.totalReturnPct >= 0 ? 'up' : 'down'}`}>
|
||||
{pct(a.totalReturnPct)}
|
||||
</div>
|
||||
<p className="btd-kpi-desc">초기 자본 대비 최종 수익</p>
|
||||
</div>
|
||||
|
||||
<div className="btd-kpi-card">
|
||||
<div className="btd-kpi-label">최대 낙폭 (MDD)</div>
|
||||
<div className={`btd-kpi-value ${a.maxDrawdownPct <= 0 ? 'down' : ''}`}>
|
||||
{pct(a.maxDrawdownPct)}
|
||||
</div>
|
||||
<p className="btd-kpi-desc">기간 중 최대 손실폭</p>
|
||||
</div>
|
||||
|
||||
<div className="btd-kpi-card btd-kpi-card--sharpe">
|
||||
<div className="btd-kpi-label">샤프 비율 (Sharpe Ratio)</div>
|
||||
<div className="btd-kpi-row">
|
||||
<span className={`btd-kpi-value ${a.sharpeRatio >= 1 ? 'up' : a.sharpeRatio >= 0 ? 'neutral' : 'down'}`}>{num(a.sharpeRatio)}</span>
|
||||
<span className="btd-kpi-icon" aria-hidden>⚖</span>
|
||||
</div>
|
||||
<p className="btd-kpi-desc">위험 대비 초과 수익</p>
|
||||
</div>
|
||||
|
||||
<div className="btd-kpi-card btd-kpi-card--dual">
|
||||
<div className="btd-kpi-label">승률 및 Profit Factor</div>
|
||||
<div className="btd-kpi-dual">
|
||||
<div className="btd-kpi-value up">{pctAbs(a.winRate)}</div>
|
||||
<div className="btd-kpi-divider" />
|
||||
<div className="btd-kpi-value gold">{num(profitFactor)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btd-section btd-section--chart">
|
||||
<SectionTitle title="자산 곡선 및 차트 시각화" sub="Asset Curve" />
|
||||
<div className="btd-chart-card">
|
||||
<BacktestAssetChart curve={curve} markers={markers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btd-section btd-section--table">
|
||||
<SectionTitle title="상세 거래 내역" sub="Trade History Table" />
|
||||
<div className="btd-table-card">
|
||||
<div className="btd-table-scroll">
|
||||
<table className="btd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>거래 일시</th>
|
||||
<th>종목</th>
|
||||
<th>유형</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>손익 (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="btd-table-empty">거래 내역이 없습니다.</td>
|
||||
</tr>
|
||||
) : trades.map((t: TradeHistoryRow) => (
|
||||
<tr key={t.id}>
|
||||
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
|
||||
<td>{t.symbol.startsWith('KRW-') ? t.symbol : `KRW-${t.symbol}`}</td>
|
||||
<td className={t.side === 'buy' ? 'buy' : 'sell'}>
|
||||
{t.side === 'buy' ? '매수' : '매도'}
|
||||
</td>
|
||||
<td>{Math.round(t.price).toLocaleString()}</td>
|
||||
<td>{t.quantity.toFixed(4)}</td>
|
||||
<td className={t.pnlPct != null ? (t.pnlPct >= 0 ? 'up' : 'down') : ''}>
|
||||
{t.pnlPct != null ? pct(t.pnlPct) : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BacktestResultDashboard;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { equityToSparkline, sparklinePath } from '../../utils/backtestEquity';
|
||||
import type { EquityPoint } from '../../utils/backtestEquity';
|
||||
|
||||
interface Props {
|
||||
curve: EquityPoint[];
|
||||
positive?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const BacktestSparkline: React.FC<Props> = memo(({
|
||||
curve,
|
||||
positive = true,
|
||||
width = 72,
|
||||
height = 28,
|
||||
}) => {
|
||||
const path = useMemo(() => {
|
||||
const values = equityToSparkline(curve);
|
||||
return sparklinePath(values, width, height);
|
||||
}, [curve, width, height]);
|
||||
|
||||
const stroke = positive ? '#22c55e' : '#ef4444';
|
||||
const fill = positive ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
|
||||
return (
|
||||
<svg className="btd-sparkline" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<path d={`${path} L${width},${height} L0,${height} Z`} fill={fill} stroke="none" />
|
||||
<path d={path} fill="none" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
BacktestSparkline.displayName = 'BacktestSparkline';
|
||||
export default BacktestSparkline;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
onPick?: (price: number, rowType: 'ask' | 'bid') => void;
|
||||
fillHeight?: boolean;
|
||||
hideHeader?: boolean;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
if (!Number.isFinite(p)) return '-';
|
||||
return p >= 1000 ? p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : p.toFixed(4);
|
||||
}
|
||||
|
||||
function fmtSize(s: number): string {
|
||||
if (!Number.isFinite(s)) return '-';
|
||||
if (s >= 1000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
return s.toFixed(4);
|
||||
}
|
||||
|
||||
const PaperCompactOrderbook: React.FC<Props> = memo(({
|
||||
market,
|
||||
onPick,
|
||||
fillHeight = false,
|
||||
hideHeader = false,
|
||||
depth = 8,
|
||||
}) => {
|
||||
const { orderbook } = useUpbitOrderbook(market);
|
||||
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
|
||||
const asks = orderbook.asks.slice(0, rowCount).reverse();
|
||||
const bids = orderbook.bids.slice(0, rowCount);
|
||||
const maxSize = Math.max(
|
||||
...asks.map(a => a.size),
|
||||
...bids.map(b => b.size),
|
||||
1,
|
||||
);
|
||||
const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
|
||||
{!hideHeader && <div className="ptd-ob-head">호가 (Depth)</div>}
|
||||
<div className="ptd-ob-body">
|
||||
{asks.map(a => (
|
||||
<button
|
||||
key={`a-${a.price}`}
|
||||
type="button"
|
||||
className="ptd-ob-row ptd-ob-row--ask"
|
||||
onClick={() => onPick?.(a.price, 'ask')}
|
||||
>
|
||||
<span className="ptd-ob-bar" style={{ width: `${(a.size / maxSize) * 100}%` }} />
|
||||
<span className="ptd-ob-price">{fmtPrice(a.price)}</span>
|
||||
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
|
||||
{bids.map(b => (
|
||||
<button
|
||||
key={`b-${b.price}`}
|
||||
type="button"
|
||||
className="ptd-ob-row ptd-ob-row--bid"
|
||||
onClick={() => onPick?.(b.price, 'bid')}
|
||||
>
|
||||
<span className="ptd-ob-bar" style={{ width: `${(b.size / maxSize) * 100}%` }} />
|
||||
<span className="ptd-ob-price">{fmtPrice(b.price)}</span>
|
||||
<span className="ptd-ob-size">{fmtSize(b.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PaperCompactOrderbook.displayName = 'PaperCompactOrderbook';
|
||||
export default PaperCompactOrderbook;
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||
import type { Timeframe } from '../../types';
|
||||
|
||||
function computeRsi(closes: number[], period = 14): number[] {
|
||||
if (closes.length < period + 1) return [];
|
||||
const out: number[] = [];
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
if (d >= 0) avgGain += d; else avgLoss -= d;
|
||||
}
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
for (let i = period; i < closes.length; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
const gain = d > 0 ? d : 0;
|
||||
const loss = d < 0 ? -d : 0;
|
||||
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||
out.push(100 - 100 / (1 + rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sparkPath(values: number[], w: number, h: number, min?: number, max?: number): string {
|
||||
if (!values.length) return '';
|
||||
const lo = min ?? Math.min(...values);
|
||||
const hi = max ?? Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||||
const y = h - ((v - lo) / range) * (h - 4) - 2;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
timeframe?: Timeframe;
|
||||
}
|
||||
|
||||
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
||||
const [rsi, setRsi] = React.useState<number[]>([]);
|
||||
const [macd, setMacd] = React.useState<number[]>([]);
|
||||
const [cci, setCci] = React.useState<number[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const bars = await fetchUpbitCandles(market, timeframe, 120);
|
||||
if (cancelled) return;
|
||||
const closes = bars.map(b => b.close);
|
||||
setRsi(computeRsi(closes).slice(-40));
|
||||
const ema = (arr: number[], p: number) => {
|
||||
const k = 2 / (p + 1);
|
||||
let v = arr[0];
|
||||
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
|
||||
};
|
||||
const e12 = ema(closes, 12);
|
||||
const e26 = ema(closes, 26);
|
||||
setMacd(e12.slice(-40).map((v, i) => v - e26[closes.length - 40 + i]));
|
||||
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
|
||||
const cciVals: number[] = [];
|
||||
for (let i = 20; i < tp.length; i++) {
|
||||
const slice = tp.slice(i - 20, i);
|
||||
const sma = slice.reduce((a, b) => a + b, 0) / slice.length;
|
||||
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));
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe]);
|
||||
|
||||
const panels = useMemo(() => [
|
||||
{ label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] },
|
||||
{ label: 'MACD', values: macd, color: '#38bdf8' },
|
||||
{ label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] },
|
||||
], [rsi, macd, cci]);
|
||||
|
||||
return (
|
||||
<div className="ptd-indicators">
|
||||
{panels.map(p => (
|
||||
<div key={p.label} className="ptd-ind-panel">
|
||||
<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) => {
|
||||
const lo = p.ref![0];
|
||||
const hi = p.ref![1];
|
||||
const y = 48 - ((r - lo) / (hi - lo)) * 44 - 2;
|
||||
return (
|
||||
<line key={i} x1="0" y1={y} x2="120" y2={y} stroke="rgba(122,162,247,0.15)" strokeDasharray="2 2" />
|
||||
);
|
||||
})}
|
||||
{p.values.length > 1 && (
|
||||
<path d={sparkPath(p.values, 120, 48)} fill="none" stroke={p.color} strokeWidth="1.5" />
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperIndicatorPanel;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../../types';
|
||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||
|
||||
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D'];
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
wsLabel?: string;
|
||||
}
|
||||
|
||||
const PaperMiniChart: React.FC<Props> = ({ market, wsLabel = 'WebSocket <100ms' }) => {
|
||||
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 [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return undefined;
|
||||
const chart = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: '#121520' },
|
||||
textColor: '#9aa5ce',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(122,162,247,0.06)' },
|
||||
horzLines: { color: 'rgba(122,162,247,0.06)' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(122,162,247,0.12)' },
|
||||
timeScale: { borderColor: 'rgba(122,162,247,0.12)' },
|
||||
crosshair: { mode: 1 },
|
||||
});
|
||||
const series = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#22c55e',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#22c55e',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
chartRef.current = chart;
|
||||
seriesRef.current = series;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (containerRef.current) {
|
||||
chart.applyOptions({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const bars = await fetchUpbitCandles(market, timeframe, 200);
|
||||
if (cancelled || !seriesRef.current) return;
|
||||
seriesRef.current.setData(
|
||||
bars.map(b => ({
|
||||
time: b.time as Time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
})),
|
||||
);
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe]);
|
||||
|
||||
return (
|
||||
<div className="ptd-chart-wrap">
|
||||
<div className="ptd-chart-head">
|
||||
<span className="ptd-chart-title">실시간 분석 차트</span>
|
||||
<span className="ptd-ws-badge">{wsLabel} 실시간 데이터 반영</span>
|
||||
</div>
|
||||
<div ref={containerRef} className="ptd-chart-canvas" />
|
||||
{loading && <div className="ptd-chart-loading">차트 로딩…</div>}
|
||||
<div className="ptd-tf-row">
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperMiniChart;
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* await run({ strategyId, bars, timeframe });
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { OHLCVBar } from '../types';
|
||||
import {
|
||||
runBacktest,
|
||||
@@ -21,12 +21,26 @@ export interface BacktestResult {
|
||||
stats: BacktestStats | null;
|
||||
analysis: BacktestAnalysis | null;
|
||||
resultId?: number;
|
||||
/** 백테스팅 실행 당시 차트 종목·타임프레임 (마커 표시 범위 검증용) */
|
||||
symbol?: string;
|
||||
timeframe?: string;
|
||||
/** 실행마다 증가 — 동일 시그널 배열 참조여도 차트 마커 재적용 트리거 */
|
||||
runSeq: number;
|
||||
}
|
||||
|
||||
const EMPTY_SIGNALS: BacktestSignal[] = [];
|
||||
|
||||
export function useBacktest() {
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const runSeqRef = useRef(0);
|
||||
/** 종목·타임프레임 변경 등으로 진행 중인 run 을 무효화할 때 증가 */
|
||||
const runGenRef = useRef(0);
|
||||
|
||||
const invalidateRuns = useCallback(() => {
|
||||
runGenRef.current += 1;
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async (opts: {
|
||||
strategyId?: number;
|
||||
@@ -39,6 +53,7 @@ export function useBacktest() {
|
||||
symbol?: string;
|
||||
strategyName?: string;
|
||||
}) => {
|
||||
const runGen = runGenRef.current;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -61,15 +76,20 @@ export function useBacktest() {
|
||||
strategyName: opts.strategyName,
|
||||
};
|
||||
const res = await runBacktest(req);
|
||||
if (runGen !== runGenRef.current) return null;
|
||||
if (!res) {
|
||||
setError('백테스팅 요청에 실패했습니다.');
|
||||
return null;
|
||||
}
|
||||
runSeqRef.current += 1;
|
||||
const r: BacktestResult = {
|
||||
signals: res.signals,
|
||||
signals: [...res.signals],
|
||||
stats: res.stats,
|
||||
analysis: res.analysis ?? null,
|
||||
resultId: res.resultId,
|
||||
symbol: opts.symbol,
|
||||
timeframe: opts.timeframe,
|
||||
runSeq: runSeqRef.current,
|
||||
};
|
||||
setResult(r);
|
||||
return r;
|
||||
@@ -88,13 +108,17 @@ export function useBacktest() {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
signals: result?.signals ?? [],
|
||||
signals: result?.signals ?? EMPTY_SIGNALS,
|
||||
stats: result?.stats ?? null,
|
||||
analysis: result?.analysis ?? null,
|
||||
resultSymbol: result?.symbol ?? null,
|
||||
resultTimeframe: result?.timeframe ?? null,
|
||||
runSeq: result?.runSeq ?? 0,
|
||||
result,
|
||||
running,
|
||||
error,
|
||||
run,
|
||||
clear,
|
||||
invalidateRuns,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/* 백테스팅 대시보드 (btd-*) — 금색 포인트 다크 테마 */
|
||||
|
||||
.btd-page {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--tmb-h, 46px));
|
||||
overflow: hidden;
|
||||
background: #0a0e14;
|
||||
color: #c8d0e0;
|
||||
font-family: var(--font, 'Noto Sans KR', sans-serif);
|
||||
}
|
||||
|
||||
/* ── 좌측 타임라인 사이드바 (~23%) ── */
|
||||
.btd-sidebar {
|
||||
width: 23%;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid rgba(201, 162, 39, 0.15);
|
||||
background: #0d1219;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btd-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 10px;
|
||||
border-bottom: 1px solid rgba(201, 162, 39, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btd-sidebar-bar {
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #e8c547, #b8860b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btd-sidebar-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #f0e6c8;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.btd-sidebar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btd-icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 162, 39, 0.2);
|
||||
background: rgba(201, 162, 39, 0.06);
|
||||
color: #c9a227;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btd-icon-btn:hover:not(:disabled) { background: rgba(201, 162, 39, 0.14); }
|
||||
.btd-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.btd-icon-btn--danger { color: #ef4444; border-color: rgba(239, 68, 68, 0.3); }
|
||||
|
||||
.btd-sidebar-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #6272a4;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.btd-sidebar-hint { font-size: 11px; opacity: 0.75; line-height: 1.6; }
|
||||
|
||||
.btd-timeline {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px 16px 22px;
|
||||
}
|
||||
|
||||
.btd-timeline-line {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, rgba(201, 162, 39, 0.5), rgba(201, 162, 39, 0.08));
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btd-timeline-item {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 0 8px;
|
||||
padding: 0 0 0 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btd-timeline-node {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 18px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #1a2030;
|
||||
border: 2px solid rgba(201, 162, 39, 0.45);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btd-timeline-item--active .btd-timeline-node {
|
||||
background: #c9a227;
|
||||
border-color: #e8c547;
|
||||
box-shadow: 0 0 8px rgba(201, 162, 39, 0.5);
|
||||
}
|
||||
|
||||
.btd-timeline-card {
|
||||
background: #121820;
|
||||
border: 1px solid rgba(201, 162, 39, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btd-timeline-item:hover .btd-timeline-card {
|
||||
border-color: rgba(201, 162, 39, 0.28);
|
||||
}
|
||||
|
||||
.btd-timeline-item--active .btd-timeline-card {
|
||||
border-color: rgba(201, 162, 39, 0.65);
|
||||
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.2), 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.btd-timeline-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btd-timeline-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #eef2ff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.btd-sparkline { flex-shrink: 0; opacity: 0.9; }
|
||||
|
||||
.btd-timeline-date {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #6272a4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btd-timeline-roi {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.btd-timeline-roi.up {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.btd-timeline-roi.down {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
/* ── 우측 메인 ── */
|
||||
.btd-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0a0e14;
|
||||
}
|
||||
|
||||
.btd-dashboard {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btd-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #6272a4;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btd-empty span { font-size: 48px; opacity: 0.35; }
|
||||
|
||||
/* ── 섹션 헤더 (금색 바) ── */
|
||||
.btd-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btd-section-bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #e8c547, #b8860b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btd-section-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #f0e6c8;
|
||||
letter-spacing: -0.2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.btd-section-sub {
|
||||
margin: 1px 0 0;
|
||||
font-size: 9px;
|
||||
color: #6272a4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* KPI ~18% | 차트 ~44% | 테이블 ~32% (헤더 포함) */
|
||||
.btd-section--kpi {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.btd-section--chart {
|
||||
flex: 1.45;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btd-section--table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── KPI 카드 4열 ── */
|
||||
.btd-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btd-kpi-card {
|
||||
background: #121820;
|
||||
border: 1px solid rgba(201, 162, 39, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.btd-kpi-label {
|
||||
font-size: 10px;
|
||||
color: #9aa5ce;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btd-kpi-value {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.btd-kpi-value.up { color: #22c55e; }
|
||||
.btd-kpi-value.down { color: #ef4444; }
|
||||
.btd-kpi-value.gold { color: #c9a227; }
|
||||
.btd-kpi-value.neutral { color: #e2e8f0; }
|
||||
|
||||
.btd-kpi-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 9px;
|
||||
color: #6272a4;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.btd-kpi-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btd-kpi-icon {
|
||||
font-size: 20px;
|
||||
opacity: 0.6;
|
||||
color: #c9a227;
|
||||
}
|
||||
|
||||
.btd-kpi-dual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.btd-kpi-card--dual .btd-kpi-label {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btd-kpi-card--dual .btd-kpi-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* ── 자산 곡선 차트 ── */
|
||||
.btd-chart-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #121820;
|
||||
border: 1px solid rgba(201, 162, 39, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 4px 8px 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btd-chart-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btd-chart-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── 거래 내역 테이블 ── */
|
||||
.btd-table-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #121820;
|
||||
border: 1px solid rgba(201, 162, 39, 0.18);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btd-table-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.btd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.btd-table thead {
|
||||
background: rgba(201, 162, 39, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btd-table th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #c9a227;
|
||||
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btd-table td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: #c8d0e0;
|
||||
}
|
||||
|
||||
.btd-table tbody tr:hover td {
|
||||
background: rgba(201, 162, 39, 0.04);
|
||||
}
|
||||
|
||||
.btd-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btd-td-time {
|
||||
color: #6272a4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btd-table td.buy { color: #3b82f6; font-weight: 700; }
|
||||
.btd-table td.sell { color: #ef4444; font-weight: 700; }
|
||||
.btd-table td.up { color: #22c55e; font-weight: 700; }
|
||||
.btd-table td.down { color: #ef4444; font-weight: 700; }
|
||||
|
||||
.btd-table-empty {
|
||||
text-align: center;
|
||||
color: #6272a4;
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.btd-kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.btd-page { flex-direction: column; height: auto; overflow: auto; }
|
||||
.btd-sidebar { width: 100%; max-height: 240px; border-right: none; border-bottom: 1px solid rgba(201, 162, 39, 0.15); }
|
||||
.btd-kpi-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/* 모의투자 대시보드 (ptd-) — 전체 화면 고정 레이아웃 */
|
||||
.ptd-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0d1117;
|
||||
color: #c0caf5;
|
||||
padding: 8px 12px 10px;
|
||||
font-family: var(--font, 'Noto Sans KR', sans-serif);
|
||||
}
|
||||
.ptd-page--loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
color: #9aa5ce;
|
||||
}
|
||||
|
||||
.ptd-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-title { margin: 0; font-size: 18px; font-weight: 800; color: #fff; }
|
||||
.ptd-topbar-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.ptd-btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(122,162,247,0.2);
|
||||
background: #1e222d;
|
||||
color: #c0caf5;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ptd-btn:hover { background: #252a38; }
|
||||
.ptd-btn--danger { border-color: rgba(239,68,68,0.4); color: #ef4444; }
|
||||
|
||||
.ptd-banner {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-banner--warn {
|
||||
background: rgba(251,191,36,0.1);
|
||||
border: 1px solid rgba(251,191,36,0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.ptd-metrics {
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ptd-metric {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #1e222d;
|
||||
border: 1px solid rgba(122,162,247,0.12);
|
||||
}
|
||||
.ptd-metric-icon { font-size: 16px; line-height: 1; }
|
||||
.ptd-metric-title { font-size: 10px; color: #6272a4; margin-bottom: 2px; }
|
||||
.ptd-metric-value { font-size: 17px; font-weight: 800; line-height: 1.1; }
|
||||
.ptd-metric-sub { font-size: 9px; color: #6272a4; margin-top: 2px; }
|
||||
.ptd-metric--up .ptd-metric-value { color: #22c55e; }
|
||||
.ptd-metric--down .ptd-metric-value { color: #ef4444; }
|
||||
|
||||
.ptd-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(250px, 280px);
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-col--left {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.ptd-col--center { min-width: 0; }
|
||||
.ptd-col--right { min-width: 0; }
|
||||
|
||||
.ptd-card {
|
||||
background: #1e222d;
|
||||
border: 1px solid rgba(122,162,247,0.12);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-card-head, .ptd-card-label {
|
||||
padding: 8px 10px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #9aa5ce;
|
||||
}
|
||||
.ptd-asset-card { padding-bottom: 8px; }
|
||||
.ptd-asset-total {
|
||||
padding: 4px 10px 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
.ptd-asset-total span { font-size: 12px; color: #6272a4; font-weight: 600; }
|
||||
.ptd-asset-ret {
|
||||
padding: 2px 10px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.ptd-asset-sub { padding: 4px 10px 0; font-size: 10px; color: #6272a4; }
|
||||
|
||||
.ptd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
}
|
||||
.ptd-table th, .ptd-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(122,162,247,0.08);
|
||||
}
|
||||
.ptd-table th { color: #6272a4; font-weight: 600; }
|
||||
.ptd-table tbody tr { cursor: pointer; }
|
||||
.ptd-table tbody tr:hover { background: rgba(63,126,245,0.06); }
|
||||
.ptd-row--sel { background: rgba(63,126,245,0.1); }
|
||||
.ptd-coin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ptd-muted { color: #6272a4; text-align: center; padding: 12px; font-size: 11px; }
|
||||
.ptd-time { color: #6272a4; white-space: nowrap; }
|
||||
|
||||
.ptd-donut-card { padding-bottom: 8px; }
|
||||
.ptd-donut-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.ptd-donut {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-donut-hole {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 14px auto;
|
||||
border-radius: 50%;
|
||||
background: #1e222d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #c0caf5;
|
||||
}
|
||||
.ptd-donut-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
color: #9aa5ce;
|
||||
}
|
||||
.ptd-donut-legend li { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
|
||||
.ptd-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.ptd-chart-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-chart-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
.ptd-chart-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-chart-title { font-size: 12px; font-weight: 700; }
|
||||
.ptd-ws-badge {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 20px;
|
||||
background: rgba(34,197,94,0.15);
|
||||
color: #22c55e;
|
||||
border: 1px solid rgba(34,197,94,0.3);
|
||||
}
|
||||
.ptd-chart-canvas {
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
max-height: 220px;
|
||||
position: relative;
|
||||
}
|
||||
.ptd-chart-loading {
|
||||
position: absolute;
|
||||
inset: 40% 0 auto;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #6272a4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ptd-tf-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 0 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-tf-btn {
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(122,162,247,0.15);
|
||||
background: #121520;
|
||||
color: #9aa5ce;
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ptd-tf-btn--active {
|
||||
border-color: #3f7ef5;
|
||||
color: #3f7ef5;
|
||||
background: rgba(63,126,245,0.12);
|
||||
}
|
||||
|
||||
.ptd-indicators {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 0 10px 8px;
|
||||
border-top: 1px solid rgba(122,162,247,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-ind-panel {
|
||||
background: #121520;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
min-height: 52px;
|
||||
}
|
||||
.ptd-ind-label { font-size: 9px; color: #6272a4; margin-bottom: 2px; }
|
||||
.ptd-ind-svg { width: 100%; height: 36px; display: block; }
|
||||
|
||||
/* 우측 패널 — 매매/호가 탭 */
|
||||
.ptd-right-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #1e222d;
|
||||
border: 1px solid rgba(122,162,247,0.12);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-tabs--main {
|
||||
flex-shrink: 0;
|
||||
background: #121520;
|
||||
}
|
||||
.ptd-tabs--main .ptd-tab {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ptd-right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.ptd-right-body > .ptd-ob--fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: -8px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ptd-order-card { flex-shrink: 0; border: none; background: transparent; }
|
||||
.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; }
|
||||
.ptd-order-card .top-panel { padding: 4px 2px; background: transparent; }
|
||||
.ptd-order-card .top-field { margin-bottom: 4px; }
|
||||
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
|
||||
.ptd-order-card .top-submit--sell { background: #3f7ef5 !important; border-color: #3f7ef5 !important; }
|
||||
|
||||
.ptd-ob {
|
||||
background: #1e222d;
|
||||
border: 1px solid rgba(122,162,247,0.12);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-ob--fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.ptd-ob-head {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #9aa5ce;
|
||||
border-bottom: 1px solid rgba(122,162,247,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ptd-ob-body {
|
||||
padding: 2px 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ptd-ob--fill .ptd-ob-body {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ptd-ob-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 3px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ptd-ob-bar {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
right: 0;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ptd-ob-row--ask .ptd-ob-bar { background: #ef4444; }
|
||||
.ptd-ob-row--bid .ptd-ob-bar { background: #22c55e; left: 0; right: auto; }
|
||||
.ptd-ob-row--ask .ptd-ob-price { color: #ef4444; position: relative; z-index: 1; }
|
||||
.ptd-ob-row--bid .ptd-ob-price { color: #22c55e; position: relative; z-index: 1; }
|
||||
.ptd-ob-size { position: relative; z-index: 1; color: #6272a4; }
|
||||
.ptd-ob-mid {
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
border-top: 1px solid rgba(122,162,247,0.08);
|
||||
border-bottom: 1px solid rgba(122,162,247,0.08);
|
||||
}
|
||||
|
||||
.ptd-history-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.ptd-history-card .ptd-table-wrap,
|
||||
.ptd-history-card .ptd-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.ptd-tabs { display: flex; border-bottom: 1px solid rgba(122,162,247,0.1); flex-shrink: 0; }
|
||||
.ptd-tab {
|
||||
flex: 1;
|
||||
padding: 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6272a4;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ptd-tab.active { color: #3f7ef5; font-weight: 700; border-bottom: 2px solid #3f7ef5; }
|
||||
.ptd-table--compact { font-size: 9px; }
|
||||
.ptd-table--compact th,
|
||||
.ptd-table--compact td { padding: 5px 6px; }
|
||||
.ptd-status {
|
||||
display: inline-block;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ptd-status--done { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
|
||||
.ptd-page .up { color: #22c55e; }
|
||||
.ptd-page .down { color: #ef4444; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.ptd-page { overflow-y: auto; }
|
||||
.ptd-main {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: visible;
|
||||
min-height: auto;
|
||||
}
|
||||
.ptd-metrics-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.ptd-col--left { overflow: visible; }
|
||||
}
|
||||
@@ -281,9 +281,9 @@ export class ChartManager {
|
||||
this.indicators.clear();
|
||||
this.patternMarkers = [];
|
||||
|
||||
this._disposeMainMarkersPlugin();
|
||||
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
||||
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
||||
this.mainMarkersPlugin = null;
|
||||
|
||||
this.mainSeries = this._createMainSeries(chartType, t);
|
||||
const mainData = bars.map(b => {
|
||||
@@ -586,6 +586,13 @@ export class ChartManager {
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
||||
private _disposeMainMarkersPlugin(): void {
|
||||
if (!this.mainMarkersPlugin) return;
|
||||
try { this.mainMarkersPlugin.detach(); } catch { /* ok */ }
|
||||
this.mainMarkersPlugin = null;
|
||||
}
|
||||
|
||||
private _reapplyAllPatternMarkers(): void {
|
||||
if (!this.mainSeries) return;
|
||||
const patternAll = this.patternMarkers.flatMap(({ markers }) =>
|
||||
@@ -612,6 +619,15 @@ export class ChartManager {
|
||||
text: m.text,
|
||||
}));
|
||||
const all = [...patternAll, ...backtestAll, ...liveAll];
|
||||
if (this.mainMarkersPlugin) {
|
||||
try {
|
||||
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
|
||||
this._disposeMainMarkersPlugin();
|
||||
}
|
||||
} catch {
|
||||
this._disposeMainMarkersPlugin();
|
||||
}
|
||||
}
|
||||
if (!this.mainMarkersPlugin) {
|
||||
this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
|
||||
} else {
|
||||
@@ -648,6 +664,8 @@ export class ChartManager {
|
||||
text,
|
||||
};
|
||||
});
|
||||
// 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성
|
||||
this._disposeMainMarkersPlugin();
|
||||
this._reapplyAllPatternMarkers();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { BacktestSignal } from './backendApi';
|
||||
|
||||
export interface EquityPoint {
|
||||
time: number;
|
||||
equity: number;
|
||||
}
|
||||
|
||||
export interface TradeMarker {
|
||||
time: number;
|
||||
equity: number;
|
||||
type: 'buy' | 'sell';
|
||||
price: number;
|
||||
pnlPct?: number;
|
||||
}
|
||||
|
||||
export interface TradeHistoryRow {
|
||||
id: number;
|
||||
time: number;
|
||||
symbol: string;
|
||||
side: 'buy' | 'sell';
|
||||
price: number;
|
||||
quantity: number;
|
||||
pnlPct?: number;
|
||||
}
|
||||
|
||||
const BUY_TYPES = new Set(['BUY']);
|
||||
const SELL_TYPES = new Set(['SELL', 'SHORT_EXIT', 'PARTIAL_SELL']);
|
||||
|
||||
export function buildEquityFromSignals(
|
||||
signals: BacktestSignal[],
|
||||
initialCapital: number,
|
||||
symbol: string,
|
||||
): { curve: EquityPoint[]; markers: TradeMarker[]; trades: TradeHistoryRow[] } {
|
||||
const sorted = [...signals].sort((a, b) => a.time - b.time);
|
||||
const curve: EquityPoint[] = [];
|
||||
const markers: TradeMarker[] = [];
|
||||
const trades: TradeHistoryRow[] = [];
|
||||
|
||||
if (sorted.length === 0) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
curve: [{ time: now, equity: initialCapital }],
|
||||
markers: [],
|
||||
trades: [],
|
||||
};
|
||||
}
|
||||
|
||||
let cash = initialCapital;
|
||||
let qty = 0;
|
||||
let entryPrice = 0;
|
||||
let tradeId = 0;
|
||||
|
||||
const pushCurve = (time: number, price: number) => {
|
||||
const equity = qty > 0 ? qty * price : cash;
|
||||
const last = curve[curve.length - 1];
|
||||
if (!last || last.time !== time || Math.abs(last.equity - equity) > 0.01) {
|
||||
curve.push({ time, equity });
|
||||
}
|
||||
};
|
||||
|
||||
pushCurve(sorted[0].time, sorted[0].price);
|
||||
|
||||
for (const s of sorted) {
|
||||
if (BUY_TYPES.has(s.type) && qty === 0 && cash > 0) {
|
||||
qty = cash / s.price;
|
||||
entryPrice = s.price;
|
||||
cash = 0;
|
||||
tradeId += 1;
|
||||
const eq = qty * s.price;
|
||||
trades.push({
|
||||
id: tradeId,
|
||||
time: s.time,
|
||||
symbol,
|
||||
side: 'buy',
|
||||
price: s.price,
|
||||
quantity: qty,
|
||||
});
|
||||
markers.push({ time: s.time, equity: eq, type: 'buy', price: s.price });
|
||||
pushCurve(s.time, s.price);
|
||||
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
||||
const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0;
|
||||
cash = qty * s.price;
|
||||
tradeId += 1;
|
||||
trades.push({
|
||||
id: tradeId,
|
||||
time: s.time,
|
||||
symbol,
|
||||
side: 'sell',
|
||||
price: s.price,
|
||||
quantity: qty,
|
||||
pnlPct,
|
||||
});
|
||||
markers.push({ time: s.time, equity: cash, type: 'sell', price: s.price, pnlPct });
|
||||
qty = 0;
|
||||
entryPrice = 0;
|
||||
pushCurve(s.time, s.price);
|
||||
} else {
|
||||
pushCurve(s.time, s.price);
|
||||
}
|
||||
}
|
||||
|
||||
if (curve.length === 1 && sorted.length > 0) {
|
||||
const last = sorted[sorted.length - 1];
|
||||
const equity = qty > 0 ? qty * last.price : cash;
|
||||
curve.push({ time: last.time, equity });
|
||||
}
|
||||
|
||||
return { curve, markers, trades };
|
||||
}
|
||||
|
||||
export function sparklinePath(values: number[], w: number, h: number): string {
|
||||
if (values.length < 2) {
|
||||
const y = h * 0.5;
|
||||
return `M0,${y} L${w},${y}`;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - 4 - ((v - min) / range) * (h - 8);
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function equityToSparkline(curve: EquityPoint[]): number[] {
|
||||
if (curve.length <= 1) return [0, 1];
|
||||
return curve.map(p => p.equity);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
|
||||
|
||||
export interface PaperPerformanceMetrics {
|
||||
mddPct: number;
|
||||
sharpeRatio: number;
|
||||
winRatePct: number;
|
||||
profitFactor: number;
|
||||
}
|
||||
|
||||
function buildEquityCurve(
|
||||
trades: PaperTradeDto[],
|
||||
initialCapital: number,
|
||||
): number[] {
|
||||
const sorted = [...trades].sort((a, b) =>
|
||||
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
|
||||
);
|
||||
const curve: number[] = [initialCapital];
|
||||
let cash = initialCapital;
|
||||
const holdings = new Map<string, { qty: number; cost: number }>();
|
||||
|
||||
for (const t of sorted) {
|
||||
const pos = holdings.get(t.symbol) ?? { qty: 0, cost: 0 };
|
||||
if (t.side === 'BUY') {
|
||||
cash -= t.netAmount;
|
||||
pos.qty += t.quantity;
|
||||
pos.cost += t.netAmount;
|
||||
} else {
|
||||
cash += t.netAmount;
|
||||
const avg = pos.qty > 0 ? pos.cost / pos.qty : 0;
|
||||
pos.qty = Math.max(0, pos.qty - t.quantity);
|
||||
pos.cost = pos.qty > 0 ? avg * pos.qty : 0;
|
||||
}
|
||||
holdings.set(t.symbol, pos);
|
||||
let stockVal = 0;
|
||||
holdings.forEach(p => {
|
||||
if (p.qty > 0) stockVal += p.cost;
|
||||
});
|
||||
curve.push(cash + stockVal);
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
function maxDrawdownPct(curve: number[]): number {
|
||||
if (curve.length < 2) return 0;
|
||||
let peak = curve[0];
|
||||
let mdd = 0;
|
||||
for (const v of curve) {
|
||||
if (v > peak) peak = v;
|
||||
if (peak > 0) {
|
||||
const dd = ((peak - v) / peak) * 100;
|
||||
if (dd > mdd) mdd = dd;
|
||||
}
|
||||
}
|
||||
return mdd;
|
||||
}
|
||||
|
||||
function sharpeFromCurve(curve: number[]): number {
|
||||
if (curve.length < 3) return 0;
|
||||
const rets: number[] = [];
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
const prev = curve[i - 1];
|
||||
if (prev > 0) rets.push((curve[i] - prev) / prev);
|
||||
}
|
||||
if (!rets.length) return 0;
|
||||
const mean = rets.reduce((a, b) => a + b, 0) / rets.length;
|
||||
const variance = rets.reduce((a, r) => a + (r - mean) ** 2, 0) / rets.length;
|
||||
const std = Math.sqrt(variance);
|
||||
if (std === 0) return mean > 0 ? 2.45 : 0;
|
||||
return (mean / std) * Math.sqrt(252);
|
||||
}
|
||||
|
||||
function roundTripStats(trades: PaperTradeDto[]): { wins: number; total: number; grossProfit: number; grossLoss: number } {
|
||||
const bySymbol = new Map<string, PaperTradeDto[]>();
|
||||
for (const t of trades) {
|
||||
const list = bySymbol.get(t.symbol) ?? [];
|
||||
list.push(t);
|
||||
bySymbol.set(t.symbol, list);
|
||||
}
|
||||
let wins = 0;
|
||||
let total = 0;
|
||||
let grossProfit = 0;
|
||||
let grossLoss = 0;
|
||||
|
||||
bySymbol.forEach(list => {
|
||||
const sorted = [...list].sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
||||
let buyCost = 0;
|
||||
let buyQty = 0;
|
||||
for (const t of sorted) {
|
||||
if (t.side === 'BUY') {
|
||||
buyCost += t.netAmount;
|
||||
buyQty += t.quantity;
|
||||
} else if (buyQty > 0) {
|
||||
const avg = buyCost / buyQty;
|
||||
const pnl = (t.price - avg) * t.quantity - t.feeAmount;
|
||||
total += 1;
|
||||
if (pnl >= 0) {
|
||||
wins += 1;
|
||||
grossProfit += pnl;
|
||||
} else {
|
||||
grossLoss += Math.abs(pnl);
|
||||
}
|
||||
buyQty = Math.max(0, buyQty - t.quantity);
|
||||
buyCost = buyQty > 0 ? avg * buyQty : 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { wins, total, grossProfit, grossLoss };
|
||||
}
|
||||
|
||||
export function computePaperMetrics(
|
||||
trades: PaperTradeDto[],
|
||||
summary: PaperSummaryDto | null,
|
||||
): PaperPerformanceMetrics {
|
||||
const initial = summary?.initialCapital ?? 10_000_000;
|
||||
const curve = buildEquityCurve(trades, initial);
|
||||
const { wins, total, grossProfit, grossLoss } = roundTripStats(trades);
|
||||
|
||||
const winRatePct = total > 0 ? (wins / total) * 100 : 0;
|
||||
const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
|
||||
|
||||
return {
|
||||
mddPct: maxDrawdownPct(curve),
|
||||
sharpeRatio: sharpeFromCurve(curve),
|
||||
winRatePct,
|
||||
profitFactor: Math.min(profitFactor, 99),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user