모의투자, 백테스팅 레이아웃 수정
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* BacktestHistoryPage — 첨부 이미지 스타일 (타임라인 이력 + 대시보드)
|
||||
* BacktestHistoryPage — 전략편집기와 동일한 3열 + 하단 패널 레이아웃
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
type BacktestAnalysis,
|
||||
type BacktestSignal,
|
||||
} from '../utils/backendApi';
|
||||
import type { Theme } from '../types';
|
||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
|
||||
import BacktestSparkline from './backtest/BacktestSparkline';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
|
||||
const pct = (v: number) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
|
||||
@@ -64,7 +66,11 @@ function TimelineItem({ r, active, onClick }: TimelineItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function BacktestHistoryPage() {
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -89,59 +95,124 @@ export function BacktestHistoryPage() {
|
||||
|
||||
const selectedDetail = selected ? parseDetail(selected) : null;
|
||||
|
||||
const signalStats = useMemo(() => {
|
||||
const sigs = selectedDetail?.signals ?? [];
|
||||
if (!sigs.length) return null;
|
||||
const buy = sigs.filter(s => s.type === 'BUY' || s.type === 'SHORT_EXIT').length;
|
||||
const sell = sigs.filter(s => s.type === 'SELL' || s.type === 'SHORT_ENTRY' || s.type === 'PARTIAL_SELL').length;
|
||||
return { buy, sell, total: sigs.length };
|
||||
}, [selectedDetail]);
|
||||
|
||||
const leftContent = records.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="btd-timeline">
|
||||
<span className="btd-timeline-line" />
|
||||
{records.map(r => (
|
||||
<TimelineItem
|
||||
key={r.id}
|
||||
r={r}
|
||||
active={selected?.id === r.id}
|
||||
onClick={() => setSelected(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="백테스팅"
|
||||
subtitle="Backtest History"
|
||||
loading={loading}
|
||||
loadingText="백테스팅 이력 로딩…"
|
||||
leftStorageKey="btd-left-width"
|
||||
footerStorageKey="btd-footer-height"
|
||||
headerActions={(
|
||||
<>
|
||||
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void fetchList()}>새로고침</button>
|
||||
<button type="button" className="bps-btn bps-btn--danger" disabled={records.length === 0} onClick={() => void deleteAll()}>전체 삭제</button>
|
||||
</>
|
||||
)}
|
||||
leftTitle="실행 이력"
|
||||
left={leftContent}
|
||||
centerHead={selected ? (
|
||||
<span className="bps-center-head-title">{selected.strategyName || '전략 없음'} · {selected.symbol.replace(/^KRW-/, '')}</span>
|
||||
) : undefined}
|
||||
center={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 className="btd-empty">
|
||||
<span>📈</span>
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
rightTitle="결과 요약"
|
||||
right={selected ? (
|
||||
<div className="btd-meta">
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">전략</div>
|
||||
<div className="bps-card-body btd-meta-value">{selected.strategyName || '전략 없음'}</div>
|
||||
</div>
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">종목 / 타임프레임</div>
|
||||
<div className="bps-card-body btd-meta-value">{selected.symbol.replace(/^KRW-/, '')} · {selected.timeframe}</div>
|
||||
</div>
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">실행일 / 봉 수</div>
|
||||
<div className="bps-card-body btd-meta-value">{fmtDate(selected.createdAt)} · {selected.barCount.toLocaleString()}봉</div>
|
||||
</div>
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">총 수익률</div>
|
||||
<div className={`bps-card-body btd-meta-value ${(selected.totalReturn ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{pct(selected.totalReturn ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDetail?.analysis && (
|
||||
<div className="bps-card">
|
||||
<div className="bps-card-head">MDD / Sharpe</div>
|
||||
<div className="bps-card-body btd-meta-value">
|
||||
{pct(selectedDetail.analysis.maxDrawdownPct)} · {selectedDetail.analysis.sharpeRatio.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bps-empty">선택된 결과가 없습니다.</div>
|
||||
)}
|
||||
footerLabel={signalStats ? '시그널 요약' : undefined}
|
||||
footer={signalStats ? (
|
||||
<div className="btd-footer-stats">
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">매수 시그널</span>
|
||||
<span className="btd-footer-stat-value up">{signalStats.buy}</span>
|
||||
</div>
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">매도 시그널</span>
|
||||
<span className="btd-footer-stat-value down">{signalStats.sell}</span>
|
||||
</div>
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">총 시그널</span>
|
||||
<span className="btd-footer-stat-value">{signalStats.total}</span>
|
||||
</div>
|
||||
<div className="btd-footer-stat">
|
||||
<span className="btd-footer-stat-label">분석 봉 수</span>
|
||||
<span className="btd-footer-stat-value">{selected?.barCount.toLocaleString() ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="btd-sidebar-empty">로딩 중…</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="btd-timeline">
|
||||
<span className="btd-timeline-line" />
|
||||
{records.map(r => (
|
||||
<TimelineItem
|
||||
key={r.id}
|
||||
r={r}
|
||||
active={selected?.id === r.id}
|
||||
onClick={() => setSelected(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<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 className="btd-empty">
|
||||
<span>📈</span>
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,17 @@ import {
|
||||
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 type { Theme, TradeOrderFillRequest } from '../types';
|
||||
import PaperAnalysisChart from './paper/PaperAnalysisChart';
|
||||
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
|
||||
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
import type { Theme } from '../types';
|
||||
|
||||
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'];
|
||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
|
||||
type HistoryTab = 'open' | 'recent';
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
type RightTab = 'trade' | 'orderbook' | 'history';
|
||||
type LeftTab = 'strategy' | 'settings';
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
@@ -63,10 +60,6 @@ function coinCode(symbol: string): string {
|
||||
return symbol.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function coinIcon(code: string): string {
|
||||
return code.slice(0, 1);
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
tickers,
|
||||
@@ -82,6 +75,7 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -126,7 +120,6 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
|
||||
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
||||
const s = summary;
|
||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||
|
||||
const posQty = useMemo(() => {
|
||||
@@ -134,29 +127,6 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
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;
|
||||
@@ -176,178 +146,170 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
if (res) { setSummary(res); setTrades([]); }
|
||||
}, []);
|
||||
|
||||
if (initialLoading) {
|
||||
return <div className="ptd-page ptd-page--loading">모의투자 대시보드 로딩…</div>;
|
||||
}
|
||||
const rightTabs = (
|
||||
<div className="bps-right-tabs">
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
||||
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>체결</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const leftTabs = (
|
||||
<div className="bps-left-tabs">
|
||||
<button type="button" className={`bps-left-tab${leftTab === 'strategy' ? ' bps-left-tab--on' : ''}`} onClick={() => setLeftTab('strategy')}>전략</button>
|
||||
<button type="button" className={`bps-left-tab${leftTab === 'settings' ? ' bps-left-tab--on' : ''}`} onClick={() => setLeftTab('settings')}>설정</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="ptd-banner ptd-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="모의투자"
|
||||
subtitle="Paper Trading"
|
||||
loading={initialLoading}
|
||||
loadingText="모의투자 대시보드 로딩…"
|
||||
leftStorageKey="ptd-left-width"
|
||||
footerStorageKey="ptd-footer-height"
|
||||
headerActions={(
|
||||
<>
|
||||
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}>새로고침</button>
|
||||
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleReset()}>계좌 초기화</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="ptd-metrics">
|
||||
banner={!s?.enabled ? (
|
||||
<div className="bps-banner bps-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
) : undefined}
|
||||
leftTabs={leftTabs}
|
||||
left={leftTab === 'strategy' ? (
|
||||
<PaperLeftStrategyTab
|
||||
market={selectedMarket}
|
||||
summary={s}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
/>
|
||||
) : (
|
||||
<PaperLeftSettingsTab
|
||||
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
|
||||
/>
|
||||
)}
|
||||
centerHead={(
|
||||
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
|
||||
)}
|
||||
center={(
|
||||
<div className="ptd-card ptd-chart-card ptd-center-fill">
|
||||
<PaperAnalysisChart market={selectedMarket} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
right={(
|
||||
<div className="ptd-right-body" ref={orderAnchorRef}>
|
||||
{rightTab === 'trade' ? (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="매수"
|
||||
bottomTitle="매도"
|
||||
top={(
|
||||
<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(); }}
|
||||
/>
|
||||
)}
|
||||
bottom={(
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : rightTab === 'orderbook' ? (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="매도 호가"
|
||||
bottomTitle="매수 호가"
|
||||
top={(
|
||||
<PaperCompactOrderbook
|
||||
market={selectedMarket}
|
||||
onPick={handleObPick}
|
||||
section="asks"
|
||||
fillHeight
|
||||
hideHeader
|
||||
depth={10}
|
||||
/>
|
||||
)}
|
||||
bottom={(
|
||||
<PaperCompactOrderbook
|
||||
market={selectedMarket}
|
||||
onPick={handleObPick}
|
||||
section="bids"
|
||||
fillHeight
|
||||
hideHeader
|
||||
depth={10}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--right"
|
||||
topTitle="체결 구분"
|
||||
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'}
|
||||
top={(
|
||||
<div className="ptd-tabs ptd-tabs--in-card">
|
||||
<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>
|
||||
)}
|
||||
bottom={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></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.slice(0, 20).map(t => (
|
||||
<tr key={t.id}>
|
||||
<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><span className="ptd-status ptd-status--done">체결</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!trades.length && <tr><td colSpan={5} className="ptd-muted">체결 내역 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
footerLabel="성과 지표"
|
||||
footer={(
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="ptd-card">
|
||||
<div className="ptd-card-head">보유 포트폴리오</div>
|
||||
<table className="ptd-table">
|
||||
<thead>
|
||||
<tr><th>자산</th><th>현재가</th><th>수량</th><th>손익</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<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>
|
||||
<span className="ptd-coin" style={{ background: COIN_COLORS[code] ?? '#64748b' }}>{coinIcon(code)}</span>
|
||||
{code}
|
||||
</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<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>
|
||||
|
||||
<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} theme={theme} />
|
||||
<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></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.slice(0, 12).map(t => (
|
||||
<tr key={t.id}>
|
||||
<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><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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* 서비스 진입 스플래시 — 로그인 또는 게스트 모드로 메인 화면 진입
|
||||
* 서비스 진입 스플래시 — 글래스모피즘 로그인
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../utils/backendApi';
|
||||
import SplashChartBackground from './splash/SplashChartBackground';
|
||||
import '../styles/splashScreen.css';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
const APP_VERSION = '1.0.3';
|
||||
|
||||
interface Props {
|
||||
onLoginSuccess: (res: LoginResponse) => void;
|
||||
@@ -35,118 +38,50 @@ const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
|
||||
return (
|
||||
<div className="splash">
|
||||
<div className="splash-bg" aria-hidden />
|
||||
<div className="splash-chart-deco" aria-hidden>
|
||||
<svg className="splash-chart-svg" viewBox="0 0 400 120" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="splashLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#2196f3" stopOpacity="0.2" />
|
||||
<stop offset="50%" stopColor="#42a5f5" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#ffd54f" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
className="splash-line"
|
||||
d="M0,90 L40,70 L80,75 L120,45 L160,55 L200,25 L240,40 L280,15 L320,30 L360,10 L400,20"
|
||||
fill="none"
|
||||
stroke="url(#splashLineGrad)"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
className="splash-area"
|
||||
d="M0,90 L40,70 L80,75 L120,45 L160,55 L200,25 L240,40 L280,15 L320,30 L360,10 L400,20 L400,120 L0,120 Z"
|
||||
fill="url(#splashLineGrad)"
|
||||
opacity="0.12"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="splash-chart-layer" aria-hidden>
|
||||
<SplashChartBackground />
|
||||
</div>
|
||||
<div className="splash-chart-fade" aria-hidden />
|
||||
|
||||
<div className="splash-inner">
|
||||
<header className="splash-brand">
|
||||
<div className="splash-logo">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect width="48" height="48" rx="10" fill="#2196f3" />
|
||||
<polyline
|
||||
points="8,32 16,22 24,26 32,14 40,18"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="splash-title">GoldenChart</h1>
|
||||
<p className="splash-tagline">
|
||||
암호화폐 실시간 차트 · 투자전략 · 백테스팅 · 모의투자
|
||||
</p>
|
||||
</header>
|
||||
<div className="splash-center">
|
||||
<div className="splash-glass">
|
||||
<h1 className="splash-brand">GoldenChart</h1>
|
||||
|
||||
<ul className="splash-features">
|
||||
<li><span className="splash-feat-icon">📈</span> 업비트 연동 실시간 캔들·호가</li>
|
||||
<li><span className="splash-feat-icon">⚙️</span> 커스텀 전략 & 백테스트</li>
|
||||
<li><span className="splash-feat-icon">🔔</span> 매매 시그널 알림</li>
|
||||
<li><span className="splash-feat-icon">🛡️</span> 역할별 메뉴·기능 권한 (관리자 설정)</li>
|
||||
</ul>
|
||||
|
||||
<div className="splash-card">
|
||||
<h2 className="splash-card-title">시작하기</h2>
|
||||
<p className="splash-card-desc">
|
||||
로그인하면 계정 설정이 기기 간 공유됩니다. 게스트 모드는 제한된 메뉴로 바로 체험할 수 있습니다.
|
||||
</p>
|
||||
<form className="splash-form" onSubmit={submitLogin}>
|
||||
<div className="splash-form-row">
|
||||
<label className="splash-field">
|
||||
<span>아이디</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</label>
|
||||
<label className="splash-field">
|
||||
<span>비밀번호</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="splash-field--visible"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="••••••"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
className="splash-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="ID"
|
||||
/>
|
||||
<input
|
||||
className="splash-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{error && <p className="splash-error">{error}</p>}
|
||||
<div className="splash-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="splash-btn splash-btn--primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="splash-btn splash-btn--guest"
|
||||
onClick={onGuest}
|
||||
disabled={loading}
|
||||
>
|
||||
게스트 모드
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="splash-footnote">
|
||||
기본 관리자: <code>admin</code> / <code>admin</code> · 권한은 설정 → 관리자 설정에서 변경
|
||||
|
||||
<p className="splash-version">
|
||||
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="splash-footer">
|
||||
<span>GoldenChart Trading Platform</span>
|
||||
</footer>
|
||||
<button type="button" className="splash-guest-link" onClick={onGuest} disabled={loading}>
|
||||
게스트로 체험하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
|
||||
const [stratName, setStratName] = useState('');
|
||||
const [stratDesc, setStratDesc] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -575,6 +576,13 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
const match = (label: string, desc?: string) =>
|
||||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||||
|
||||
const paletteKey = (type: 'operator' | 'indicator', value: string) => `${type}:${value}`;
|
||||
const selectPalette = (type: 'operator' | 'indicator', value: string) => {
|
||||
setSelectedPaletteKey(paletteKey(type, value));
|
||||
};
|
||||
const isPaletteSelected = (type: 'operator' | 'indicator', value: string) =>
|
||||
selectedPaletteKey === paletteKey(type, value);
|
||||
|
||||
return (
|
||||
<div className={`se-page se-page--${theme}`}>
|
||||
{saveToast && (
|
||||
@@ -683,7 +691,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||||
@@ -710,8 +718,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
onPointerDown={onLeftSplitter}
|
||||
/>
|
||||
|
||||
<main className="se-center">
|
||||
<div className="se-center-work">
|
||||
<div className="se-main">
|
||||
<div className="se-main-row">
|
||||
<main className="se-center">
|
||||
<div className="se-center-panel">
|
||||
<div className="se-center-work">
|
||||
<div className="se-center-head">
|
||||
<div className="se-signal-tabs">
|
||||
<button
|
||||
@@ -801,13 +812,16 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
def={DEF}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="se-right">
|
||||
<div className="se-palette-panel">
|
||||
<div className="se-right-tabs">
|
||||
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
|
||||
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
|
||||
</div>
|
||||
<div className="se-right-body">
|
||||
{rightTab === 'indicators' && (
|
||||
<>
|
||||
<input
|
||||
@@ -820,7 +834,13 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
<h3>논리 연산</h3>
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{operators.map(op => (
|
||||
<PaletteChip key={op.value} {...op} onAdd={() => applyPalette(op.type, op.value, op.label)} />
|
||||
<PaletteChip
|
||||
key={op.value}
|
||||
{...op}
|
||||
selected={isPaletteSelected(op.type, op.value)}
|
||||
onSelect={() => selectPalette(op.type, op.value)}
|
||||
onAdd={() => applyPalette(op.type, op.value, op.label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -832,6 +852,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
key={item.value}
|
||||
{...item}
|
||||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
||||
selected={isPaletteSelected(item.type, item.value)}
|
||||
onSelect={() => selectPalette(item.type, item.value)}
|
||||
onAdd={() => applyPalette(item.type, item.value, item.label)}
|
||||
/>
|
||||
))}
|
||||
@@ -849,6 +871,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
desc={item.desc}
|
||||
color={item.color}
|
||||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
||||
selected={isPaletteSelected('indicator', item.value)}
|
||||
onSelect={() => selectPalette('indicator', item.value)}
|
||||
onAdd={() => applyPalette('indicator', item.value, item.label)}
|
||||
/>
|
||||
))}
|
||||
@@ -869,7 +893,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveOpen && (
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
|
||||
import '../../styles/strategyEditorTheme.css';
|
||||
import '../../styles/builderPageShell.css';
|
||||
|
||||
const LEFT_MIN = 220;
|
||||
const LEFT_MAX = 520;
|
||||
const LEFT_DEFAULT = 280;
|
||||
const FOOTER_MIN = 88;
|
||||
const FOOTER_MAX = 420;
|
||||
const FOOTER_DEFAULT = 140;
|
||||
|
||||
export interface BuilderPageShellProps {
|
||||
theme: Theme;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
headerActions?: React.ReactNode;
|
||||
banner?: React.ReactNode;
|
||||
leftTitle?: string;
|
||||
leftTabs?: React.ReactNode;
|
||||
leftActions?: React.ReactNode;
|
||||
left: React.ReactNode;
|
||||
centerHead?: React.ReactNode;
|
||||
center: React.ReactNode;
|
||||
rightTitle?: string;
|
||||
rightTabs?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
footerLabel?: string;
|
||||
footer?: React.ReactNode;
|
||||
leftStorageKey?: string;
|
||||
footerStorageKey?: string;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export default function BuilderPageShell({
|
||||
theme,
|
||||
title,
|
||||
subtitle,
|
||||
headerActions,
|
||||
banner,
|
||||
leftTitle,
|
||||
leftTabs,
|
||||
leftActions,
|
||||
left,
|
||||
centerHead,
|
||||
center,
|
||||
rightTitle,
|
||||
rightTabs,
|
||||
right,
|
||||
footerLabel,
|
||||
footer,
|
||||
leftStorageKey = 'bps-left-width',
|
||||
footerStorageKey = 'bps-footer-height',
|
||||
loading = false,
|
||||
loadingText = '로딩 중…',
|
||||
}: BuilderPageShellProps) {
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, LEFT_DEFAULT));
|
||||
const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const footerHeightRef = useRef(footerHeight);
|
||||
leftWidthRef.current = leftWidth;
|
||||
footerHeightRef.current = footerHeight;
|
||||
|
||||
const onLeftSplitter = usePanelResize(
|
||||
'vertical',
|
||||
setLeftWidth,
|
||||
() => leftWidthRef.current,
|
||||
LEFT_MIN,
|
||||
LEFT_MAX,
|
||||
v => storeSize(leftStorageKey, v),
|
||||
);
|
||||
|
||||
const onFooterSplitter = usePanelResize(
|
||||
'horizontal',
|
||||
setFooterHeight,
|
||||
() => footerHeightRef.current,
|
||||
FOOTER_MIN,
|
||||
FOOTER_MAX,
|
||||
v => storeSize(footerStorageKey, v),
|
||||
);
|
||||
|
||||
const bodyStyle = useMemo(() => ({
|
||||
'--bps-footer-height': `${footerHeight}px`,
|
||||
}) as React.CSSProperties, [footerHeight]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`bps-page se-page se-page--${theme}`}>
|
||||
<header className="bps-header">
|
||||
<div className="bps-header-left">
|
||||
<h1 className="bps-title">{title}</h1>
|
||||
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="bps-loading">{loadingText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bps-page se-page se-page--${theme}`}>
|
||||
<header className="bps-header">
|
||||
<div className="bps-header-left">
|
||||
<h1 className="bps-title">{title}</h1>
|
||||
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
||||
</div>
|
||||
{headerActions && <div className="bps-header-actions">{headerActions}</div>}
|
||||
</header>
|
||||
|
||||
{banner}
|
||||
|
||||
<div className="bps-body" style={bodyStyle}>
|
||||
<aside className="bps-left" style={{ width: leftWidth }}>
|
||||
<div className="bps-panel">
|
||||
{leftTabs ? (
|
||||
<>
|
||||
{leftTabs}
|
||||
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bps-panel-head">
|
||||
<h2 className="bps-panel-title">{leftTitle}</h2>
|
||||
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
|
||||
</div>
|
||||
<div className="bps-panel-body">{left}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="좌측 패널 너비 조절"
|
||||
onPointerDown={onLeftSplitter}
|
||||
/>
|
||||
|
||||
<div className="bps-main">
|
||||
<div className="bps-main-row">
|
||||
<main className="bps-center">
|
||||
<div className="bps-center-work">
|
||||
{centerHead && <div className="bps-center-head">{centerHead}</div>}
|
||||
<div className="bps-center-content">{center}</div>
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<div
|
||||
className="bps-splitter bps-splitter--h se-splitter se-splitter--h"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="하단 패널 높이 조절"
|
||||
onPointerDown={onFooterSplitter}
|
||||
/>
|
||||
<footer className="bps-footer" style={{ height: footerHeight }}>
|
||||
{footerLabel && <div className="bps-footer-label">{footerLabel}</div>}
|
||||
<div className="bps-footer-body">{footer}</div>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{right && (
|
||||
<aside className="bps-right">
|
||||
{rightTabs ?? (rightTitle ? (
|
||||
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
|
||||
<h2 className="bps-panel-title">{rightTitle}</h2>
|
||||
</div>
|
||||
) : null)}
|
||||
<div className="bps-right-body">{right}</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Theme, Timeframe } from '../../types';
|
||||
import PaperMiniChart from './PaperMiniChart';
|
||||
import PaperIndicatorPanel from './PaperIndicatorPanel';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
/** 모의투자 중앙 — 상단 캔들 + 하단 보조지표 (실시간 차트 레이아웃) */
|
||||
const PaperAnalysisChart: React.FC<Props> = ({ market, theme = 'dark' }) => {
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
|
||||
|
||||
return (
|
||||
<div className="ptd-analysis-chart">
|
||||
<div className="ptd-main-pane">
|
||||
<PaperMiniChart
|
||||
market={market}
|
||||
theme={theme}
|
||||
timeframe={timeframe}
|
||||
onTimeframeChange={setTimeframe}
|
||||
/>
|
||||
</div>
|
||||
<div className="ptd-sub-panes">
|
||||
<PaperIndicatorPanel market={market} timeframe={timeframe} stacked />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperAnalysisChart;
|
||||
@@ -7,6 +7,8 @@ interface Props {
|
||||
fillHeight?: boolean;
|
||||
hideHeader?: boolean;
|
||||
depth?: number;
|
||||
/** split 패널용 — 매도/매수 호가만 */
|
||||
section?: 'all' | 'asks' | 'bids';
|
||||
}
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
@@ -26,6 +28,7 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
|
||||
fillHeight = false,
|
||||
hideHeader = false,
|
||||
depth = 8,
|
||||
section = 'all',
|
||||
}) => {
|
||||
const { orderbook } = useUpbitOrderbook(market);
|
||||
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
|
||||
@@ -38,11 +41,15 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
|
||||
);
|
||||
const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0;
|
||||
|
||||
const showAsks = section === 'all' || section === 'asks';
|
||||
const showBids = section === 'all' || section === 'bids';
|
||||
const showMid = section === 'all' || section === 'asks';
|
||||
|
||||
return (
|
||||
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
|
||||
{!hideHeader && <div className="ptd-ob-head">호가 (Depth)</div>}
|
||||
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}${section !== 'all' ? ' ptd-ob--section' : ''}`}>
|
||||
{!hideHeader && section === 'all' && <div className="ptd-ob-head">호가 (Depth)</div>}
|
||||
<div className="ptd-ob-body">
|
||||
{asks.map(a => (
|
||||
{showAsks && asks.map(a => (
|
||||
<button
|
||||
key={`a-${a.price}`}
|
||||
type="button"
|
||||
@@ -54,8 +61,11 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
|
||||
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
|
||||
{bids.map(b => (
|
||||
{showMid && section !== 'all' && (
|
||||
<div className="ptd-ob-mid ptd-ob-mid--inline">{mid ? fmtPrice(mid) : '—'}</div>
|
||||
)}
|
||||
{section === 'all' && <div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>}
|
||||
{showBids && bids.map(b => (
|
||||
<button
|
||||
key={`b-${b.price}`}
|
||||
type="button"
|
||||
|
||||
@@ -40,9 +40,11 @@ function sparkPath(values: number[], w: number, h: number, min?: number, max?: n
|
||||
interface Props {
|
||||
market: string;
|
||||
timeframe?: Timeframe;
|
||||
/** 실시간 차트처럼 세로 스택 pane */
|
||||
stacked?: boolean;
|
||||
}
|
||||
|
||||
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
||||
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h', stacked = false }) => {
|
||||
const [rsi, setRsi] = React.useState<number[]>([]);
|
||||
const [macd, setMacd] = React.useState<number[]>([]);
|
||||
const [cci, setCci] = React.useState<number[]>([]);
|
||||
@@ -54,7 +56,7 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
||||
const bars = await fetchUpbitCandles(market, timeframe, 120);
|
||||
if (cancelled) return;
|
||||
const closes = bars.map(b => b.close);
|
||||
setRsi(computeRsi(closes).slice(-40));
|
||||
setRsi(computeRsi(closes).slice(-60));
|
||||
const ema = (arr: number[], p: number) => {
|
||||
const k = 2 / (p + 1);
|
||||
let v = arr[0];
|
||||
@@ -62,7 +64,7 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
||||
};
|
||||
const e12 = ema(closes, 12);
|
||||
const e26 = ema(closes, 26);
|
||||
setMacd(e12.slice(-40).map((v, i) => v - e26[closes.length - 40 + i]));
|
||||
setMacd(e12.slice(-60).map((v, i) => v - e26[closes.length - 60 + i]));
|
||||
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
|
||||
const cciVals: number[] = [];
|
||||
for (let i = 20; i < tp.length; i++) {
|
||||
@@ -71,22 +73,25 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
||||
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
|
||||
cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
|
||||
}
|
||||
setCci(cciVals.slice(-40));
|
||||
setCci(cciVals.slice(-60));
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe]);
|
||||
|
||||
const panels = useMemo(() => [
|
||||
{ label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] },
|
||||
{ label: 'RSI (14)', values: rsi, color: '#a78bfa', ref: [30, 70] as number[] },
|
||||
{ label: 'MACD', values: macd, color: '#38bdf8' },
|
||||
{ label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] },
|
||||
{ label: 'CCI (20)', values: cci, color: '#fbbf24', ref: [-100, 100] as number[] },
|
||||
], [rsi, macd, cci]);
|
||||
|
||||
const rootClass = stacked ? 'ptd-ind-stack' : 'ptd-indicators';
|
||||
const itemClass = stacked ? 'ptd-ind-row' : 'ptd-ind-panel';
|
||||
|
||||
return (
|
||||
<div className="ptd-indicators">
|
||||
<div className={rootClass}>
|
||||
{panels.map(p => (
|
||||
<div key={p.label} className="ptd-ind-panel">
|
||||
<div key={p.label} className={itemClass}>
|
||||
<div className="ptd-ind-label">{p.label}</div>
|
||||
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
|
||||
{p.ref?.map((r, i) => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { resetPaperAccount } from '../../utils/backendApi';
|
||||
import PaperSplitPanel from './PaperSplitPanel';
|
||||
|
||||
interface Props {
|
||||
onSettingsSaved?: () => void;
|
||||
onAccountReset?: () => void;
|
||||
}
|
||||
|
||||
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset }) => {
|
||||
const { defaults, save, isLoaded } = useAppSettings();
|
||||
|
||||
if (!isLoaded) {
|
||||
return <p className="ptd-muted">설정 로딩…</p>;
|
||||
}
|
||||
|
||||
const patch = (p: Parameters<typeof save>[0]) => {
|
||||
save(p);
|
||||
onSettingsSaved?.();
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) onAccountReset?.();
|
||||
};
|
||||
|
||||
const top = (
|
||||
<>
|
||||
<div className="ptd-left-section">
|
||||
<label className="ptd-left-row">
|
||||
<span>모의투자 활성화</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaults.paperTradingEnabled}
|
||||
onChange={e => patch({ paperTradingEnabled: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="ptd-left-section">
|
||||
<label className="ptd-left-row">
|
||||
<span>자동매매</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaults.paperAutoTradeEnabled}
|
||||
onChange={e => patch({ paperAutoTradeEnabled: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>매수 예산 (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ptd-left-input"
|
||||
min={1}
|
||||
max={100}
|
||||
value={defaults.paperAutoTradeBudgetPct}
|
||||
disabled={!defaults.paperAutoTradeEnabled}
|
||||
onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const bottom = (
|
||||
<>
|
||||
<div className="ptd-left-section">
|
||||
<label className="ptd-left-field">
|
||||
<span>초기 자본 (KRW)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ptd-left-input"
|
||||
min={100000}
|
||||
step={100000}
|
||||
value={defaults.paperInitialCapital}
|
||||
onChange={e => patch({ paperInitialCapital: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>수수료율 (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ptd-left-input"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={defaults.paperFeeRatePct}
|
||||
onChange={e => patch({ paperFeeRatePct: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>슬리피지 (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ptd-left-input"
|
||||
min={0}
|
||||
max={5}
|
||||
step={0.01}
|
||||
value={defaults.paperSlippagePct}
|
||||
onChange={e => patch({ paperSlippagePct: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>최소 주문 (KRW)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ptd-left-input"
|
||||
min={1000}
|
||||
step={1000}
|
||||
value={defaults.paperMinOrderKrw}
|
||||
onChange={e => patch({ paperMinOrderKrw: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="bps-btn bps-btn--danger ptd-left-reset" onClick={() => void handleReset()}>
|
||||
계좌 초기화
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--left"
|
||||
topTitle="운영 · 자동매매"
|
||||
bottomTitle="계좌 · 비용"
|
||||
top={top}
|
||||
bottom={bottom}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperLeftSettingsTab;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
loadStrategies,
|
||||
loadLiveStrategySettings,
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
type PaperSummaryDto,
|
||||
type StrategyDto,
|
||||
} from '../../utils/backendApi';
|
||||
import { fmtKrw } from '../TradeOrderPanel';
|
||||
import PaperSplitPanel from './PaperSplitPanel';
|
||||
|
||||
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
summary: PaperSummaryDto | null;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onMarketSelect?: (market: string) => void;
|
||||
}
|
||||
|
||||
const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
market,
|
||||
summary,
|
||||
paperAutoTradeEnabled = false,
|
||||
onMarketSelect,
|
||||
}) => {
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [settings, setSettings] = useState<LiveStrategySettingsDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLiveStrategySettings(market)
|
||||
.then(s => { if (!cancelled) setSettings(s); })
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSettings({
|
||||
market,
|
||||
strategyId: null,
|
||||
isLiveCheck: false,
|
||||
executionType: 'CANDLE_CLOSE',
|
||||
positionMode: 'LONG_ONLY',
|
||||
candleType: '1m',
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market]);
|
||||
|
||||
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||
if (!settings) return;
|
||||
const next = { ...settings, ...patch, market };
|
||||
const prev = settings;
|
||||
setSettings(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveLiveStrategySettings(next);
|
||||
if (saved) setSettings(saved);
|
||||
else setSettings(prev);
|
||||
} catch {
|
||||
setSettings(prev);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [settings, market]);
|
||||
|
||||
const selected = strategies.find(s => s.id === settings?.strategyId);
|
||||
|
||||
const top = (
|
||||
<>
|
||||
<div className="ptd-asset-inline">
|
||||
<div className="ptd-asset-total ptd-asset-total--sm">
|
||||
{summary ? fmtKrw(summary.totalAsset) : '—'} <span>KRW</span>
|
||||
</div>
|
||||
<div className={`ptd-asset-ret ptd-asset-ret--sm ${(summary?.totalReturnPct ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{summary ? `${(summary.totalReturnPct ?? 0) >= 0 ? '+' : ''}${summary.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ptd-left-section">
|
||||
<label className="ptd-left-row">
|
||||
<span>전략 체크</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.isLiveCheck ?? false}
|
||||
disabled={saving}
|
||||
onChange={e => void persist({ isLiveCheck: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>전략 선택</span>
|
||||
<select
|
||||
className="ptd-left-select"
|
||||
value={settings?.strategyId ?? ''}
|
||||
disabled={saving}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
void persist({ strategyId: v ? Number(v) : null });
|
||||
}}
|
||||
>
|
||||
<option value="">— 선택 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selected && <p className="ptd-left-hint">적용: {selected.name}</p>}
|
||||
<label className="ptd-left-field">
|
||||
<span>평가 분봉</span>
|
||||
<select
|
||||
className="ptd-left-select"
|
||||
value={settings?.candleType ?? '1m'}
|
||||
disabled={saving}
|
||||
onChange={e => void persist({ candleType: e.target.value })}
|
||||
>
|
||||
{CANDLE_TYPES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="ptd-left-field">
|
||||
<span>실행 방식</span>
|
||||
<select
|
||||
className="ptd-left-select"
|
||||
value={settings?.executionType ?? 'CANDLE_CLOSE'}
|
||||
disabled={saving}
|
||||
onChange={e => void persist({ executionType: e.target.value as LiveStrategySettingsDto['executionType'] })}
|
||||
>
|
||||
<option value="CANDLE_CLOSE">봉 마감 직후</option>
|
||||
<option value="REALTIME_TICK">실시간 틱 (3초)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
|
||||
{paperAutoTradeEnabled ? '자동매매 ON' : '자동매매 OFF — 설정 탭에서 변경'}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const bottom = (
|
||||
<table className="ptd-table ptd-table--compact">
|
||||
<thead>
|
||||
<tr><th>자산</th><th>수량</th><th>손익</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(summary?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={3} className="ptd-muted">보유 없음</td></tr>
|
||||
) : summary!.positions.map(p => {
|
||||
const code = p.symbol.replace(/^KRW-/, '');
|
||||
const up = (p.profitLoss ?? 0) >= 0;
|
||||
return (
|
||||
<tr
|
||||
key={p.symbol}
|
||||
className={market === p.symbol ? 'ptd-row--sel' : ''}
|
||||
onClick={() => onMarketSelect?.(p.symbol)}
|
||||
>
|
||||
<td>{code}</td>
|
||||
<td>{p.quantity.toFixed(4)}</td>
|
||||
<td className={up ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--left"
|
||||
topTitle="전략 · 자산"
|
||||
bottomTitle="보유 종목"
|
||||
top={top}
|
||||
bottom={bottom}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperLeftStrategyTab;
|
||||
@@ -9,6 +9,8 @@ interface Props {
|
||||
market: string;
|
||||
theme?: Theme;
|
||||
wsLabel?: string;
|
||||
timeframe?: Timeframe;
|
||||
onTimeframeChange?: (tf: Timeframe) => void;
|
||||
}
|
||||
|
||||
function readChartTheme(container: HTMLElement | null) {
|
||||
@@ -25,11 +27,19 @@ function readChartTheme(container: HTMLElement | null) {
|
||||
};
|
||||
}
|
||||
|
||||
const PaperMiniChart: React.FC<Props> = ({ market, theme = 'dark', wsLabel = 'WebSocket <100ms' }) => {
|
||||
const PaperMiniChart: React.FC<Props> = ({
|
||||
market,
|
||||
theme = 'dark',
|
||||
wsLabel = 'WebSocket <100ms',
|
||||
timeframe: timeframeProp,
|
||||
onTimeframeChange,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
|
||||
const [timeframeInternal, setTimeframeInternal] = useState<Timeframe>('1h');
|
||||
const timeframe = timeframeProp ?? timeframeInternal;
|
||||
const setTimeframe = onTimeframeChange ?? setTimeframeInternal;
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const applyTheme = useCallback((chart: IChartApi, series: ISeriesApi<'Candlestick'>) => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
topTitle?: string;
|
||||
bottomTitle?: string;
|
||||
top: React.ReactNode;
|
||||
bottom: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 좌·우 패널 상·하단 카드 분리 */
|
||||
const PaperSplitPanel: React.FC<Props> = ({ topTitle, bottomTitle, top, bottom, className = '' }) => (
|
||||
<div className={`ptd-split-panel${className ? ` ${className}` : ''}`}>
|
||||
<section className="ptd-split-card ptd-split-card--top">
|
||||
{topTitle && <div className="ptd-split-card-head">{topTitle}</div>}
|
||||
<div className="ptd-split-card-body">{top}</div>
|
||||
</section>
|
||||
<section className="ptd-split-card ptd-split-card--bottom">
|
||||
{bottomTitle && <div className="ptd-split-card-head">{bottomTitle}</div>}
|
||||
<div className="ptd-split-card-body">{bottom}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PaperSplitPanel;
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface Candle {
|
||||
x: number;
|
||||
o: number;
|
||||
h: number;
|
||||
l: number;
|
||||
c: number;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const W = 1400;
|
||||
const H = 820;
|
||||
const CANDLE_COUNT = 110;
|
||||
const DISPLACE = 26;
|
||||
|
||||
/** Y축: 위로 갈수록(y↓) 가격 상승 — 좌하→우상 우상향 */
|
||||
function buildUpwardCandles(): Candle[] {
|
||||
const startX = 48;
|
||||
const step = (W - 96) / (CANDLE_COUNT - 1);
|
||||
const yStart = H - 120;
|
||||
const yEnd = 100;
|
||||
const out: Candle[] = [];
|
||||
|
||||
for (let i = 0; i < CANDLE_COUNT; i++) {
|
||||
const t = i / (CANDLE_COUNT - 1);
|
||||
const trend = yStart + (yEnd - yStart) * Math.pow(t, 0.92);
|
||||
const ripple = Math.sin(i * 0.38) * 14 + Math.cos(i * 0.16) * 9 + Math.sin(i * 0.07) * 5;
|
||||
const o = i === 0 ? trend + 8 : out[i - 1].c;
|
||||
const c = trend + ripple * 0.55;
|
||||
const bodyTop = Math.min(o, c);
|
||||
const bodyBot = Math.max(o, c);
|
||||
const wick = 4 + (i % 3);
|
||||
out.push({
|
||||
x: startX + i * step,
|
||||
o,
|
||||
c,
|
||||
h: bodyTop - wick,
|
||||
l: bodyBot + wick,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ichimokuMid(candles: Candle[], i: number, period: number): number {
|
||||
const from = Math.max(0, i - period + 1);
|
||||
const slice = candles.slice(from, i + 1);
|
||||
const highest = Math.min(...slice.map(c => c.h));
|
||||
const lowest = Math.max(...slice.map(c => c.l));
|
||||
return (highest + lowest) / 2;
|
||||
}
|
||||
|
||||
function linePath(points: Point[]): string {
|
||||
if (points.length < 2) return '';
|
||||
return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||
}
|
||||
|
||||
function buildKumoRegions(senkouA: Point[], senkouB: Point[]): { bullPaths: string[]; bearPaths: string[] } {
|
||||
const n = Math.min(senkouA.length, senkouB.length);
|
||||
const bullPaths: string[] = [];
|
||||
const bearPaths: string[] = [];
|
||||
let runStart = 0;
|
||||
let runBull = senkouA[0].y <= senkouB[0].y;
|
||||
|
||||
const closeRun = (end: number) => {
|
||||
if (end - runStart < 2) return;
|
||||
const sliceA = senkouA.slice(runStart, end);
|
||||
const sliceB = senkouB.slice(runStart, end);
|
||||
const fwdA = sliceA.map(p => `${p.x},${p.y}`).join(' L');
|
||||
const backB = [...sliceB].reverse().map(p => `${p.x},${p.y}`).join(' L');
|
||||
const d = `M${fwdA} L${backB} Z`;
|
||||
if (runBull) bullPaths.push(d);
|
||||
else bearPaths.push(d);
|
||||
};
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
const bull = senkouA[i].y <= senkouB[i].y;
|
||||
if (bull !== runBull) {
|
||||
closeRun(i);
|
||||
runStart = i;
|
||||
runBull = bull;
|
||||
}
|
||||
}
|
||||
closeRun(n);
|
||||
return { bullPaths, bearPaths };
|
||||
}
|
||||
|
||||
export default function SplashChartBackground() {
|
||||
const model = useMemo(() => {
|
||||
const candles = buildUpwardCandles();
|
||||
|
||||
const tenkan: Point[] = candles.map((c, i) => ({
|
||||
x: c.x,
|
||||
y: ichimokuMid(candles, i, 9),
|
||||
}));
|
||||
const kijun: Point[] = candles.map((c, i) => ({
|
||||
x: c.x,
|
||||
y: ichimokuMid(candles, i, 26),
|
||||
}));
|
||||
|
||||
const senkouA: Point[] = [];
|
||||
const senkouB: Point[] = [];
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
const future = candles[Math.min(i + DISPLACE, candles.length - 1)];
|
||||
senkouA.push({
|
||||
x: future.x,
|
||||
y: (tenkan[i].y + kijun[i].y) / 2,
|
||||
});
|
||||
senkouB.push({
|
||||
x: future.x,
|
||||
y: ichimokuMid(candles, i, 52),
|
||||
});
|
||||
}
|
||||
|
||||
const { bullPaths, bearPaths } = buildKumoRegions(senkouA, senkouB);
|
||||
|
||||
const volumes = candles.map((c, i) => {
|
||||
const body = Math.abs(c.c - c.o);
|
||||
const vh = 10 + body * 1.4 + (Math.sin(i * 0.4) + 1) * 8;
|
||||
return { x: c.x, h: vh };
|
||||
});
|
||||
|
||||
return { candles, tenkan, kijun, senkouA, senkouB, bullPaths, bearPaths, volumes };
|
||||
}, []);
|
||||
|
||||
const gridY = useMemo(() => {
|
||||
const lines: number[] = [];
|
||||
for (let y = 80; y <= H - 60; y += 70) lines.push(y);
|
||||
return lines;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="splash-chart-bg"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="splashKumoBull" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="#22c55e" stopOpacity="0.06" />
|
||||
</linearGradient>
|
||||
<linearGradient id="splashKumoBear" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width={W} height={H} fill="#0a0c10" />
|
||||
|
||||
{gridY.map(y => (
|
||||
<line key={y} x1="0" y1={y} x2={W} y2={y} className="splash-chart-grid" />
|
||||
))}
|
||||
|
||||
{/* 거래량 */}
|
||||
<g className="splash-chart-volume">
|
||||
{model.volumes.map((v, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={v.x - 4}
|
||||
y={H - 52 - v.h}
|
||||
width="8"
|
||||
height={v.h}
|
||||
className="splash-vol-bar"
|
||||
rx="0.5"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* 일목 구름 */}
|
||||
<g className="splash-chart-kumo">
|
||||
{model.bearPaths.map((d, i) => (
|
||||
<path key={`bear-${i}`} d={d} fill="url(#splashKumoBear)" />
|
||||
))}
|
||||
{model.bullPaths.map((d, i) => (
|
||||
<path key={`bull-${i}`} d={d} fill="url(#splashKumoBull)" />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* 일목 선행·전환·기준 */}
|
||||
<path className="splash-ichi splash-ichi--senkou-b" d={linePath(model.senkouB)} />
|
||||
<path className="splash-ichi splash-ichi--senkou-a" d={linePath(model.senkouA)} />
|
||||
<path className="splash-ichi splash-ichi--kijun" d={linePath(model.kijun)} />
|
||||
<path className="splash-ichi splash-ichi--tenkan" d={linePath(model.tenkan)} />
|
||||
|
||||
{/* 캔들 */}
|
||||
<g className="splash-chart-candles">
|
||||
{model.candles.map((c, i) => {
|
||||
const up = c.c < c.o;
|
||||
const top = Math.min(c.o, c.c);
|
||||
const bodyH = Math.max(Math.abs(c.c - c.o), 3.5);
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={c.x} y1={c.h} x2={c.x} y2={c.l} className="splash-candle-wick" />
|
||||
<rect
|
||||
x={c.x - 6}
|
||||
y={top}
|
||||
width="12"
|
||||
height={bodyH}
|
||||
className={up ? 'splash-candle-up' : 'splash-candle-down'}
|
||||
rx="1"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -13,25 +13,51 @@ interface Props {
|
||||
desc?: string;
|
||||
color?: string;
|
||||
period?: string;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function PaletteChip({ type, value, label, desc, color, period, onAdd }: Props) {
|
||||
export default function PaletteChip({
|
||||
type, value, label, desc, color, period, selected = false, onSelect, onAdd,
|
||||
}: Props) {
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
const payload: PaletteDragPayload = { type, value, label };
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (onSelect) onSelect();
|
||||
else onAdd();
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (onSelect) {
|
||||
if (selected) onAdd();
|
||||
else onSelect();
|
||||
} else {
|
||||
onAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}`}
|
||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${selected ? ' se-palette-card--selected' : ''}`}
|
||||
onDragStart={onDragStart}
|
||||
onClick={onAdd}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onAdd(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="se-palette-card-head">
|
||||
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -54,6 +54,11 @@ import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||
import { getMinimapColors } from './minimapTheme';
|
||||
import {
|
||||
loadCanvasInteractionMode,
|
||||
saveCanvasInteractionMode,
|
||||
type StrategyCanvasInteractionMode,
|
||||
} from '../../utils/strategyCanvasInteractionStorage';
|
||||
import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage';
|
||||
|
||||
export type FlowLayoutSeed = {
|
||||
@@ -190,6 +195,14 @@ function StrategyEditorCanvasInner({
|
||||
const structureKeyRef = useRef<string | null>(null);
|
||||
const orphanKeyRef = useRef<string | null>(null);
|
||||
const layoutEmitTimerRef = useRef<number | null>(null);
|
||||
const [interactionMode, setInteractionMode] = useState<StrategyCanvasInteractionMode>(
|
||||
() => loadCanvasInteractionMode(),
|
||||
);
|
||||
|
||||
const handleInteractionModeChange = useCallback((mode: StrategyCanvasInteractionMode) => {
|
||||
setInteractionMode(mode);
|
||||
saveCanvasInteractionMode(mode);
|
||||
}, []);
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
@@ -839,7 +852,7 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab}`}
|
||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -865,9 +878,9 @@ function StrategyEditorCanvasInner({
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
selectionOnDrag
|
||||
selectionOnDrag={interactionMode === 'select'}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={[1, 2]}
|
||||
panOnDrag={interactionMode === 'pan' ? true : [1, 2]}
|
||||
selectNodesOnDrag={false}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
deleteKeyCode={null}
|
||||
@@ -893,8 +906,28 @@ function StrategyEditorCanvasInner({
|
||||
/>
|
||||
<Panel position="top-left" className="se-canvas-toolbar">
|
||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
||||
<div className="se-canvas-interaction" role="group" aria-label="캔버스 조작 모드">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-canvas-interaction-btn${interactionMode === 'pan' ? ' se-canvas-interaction-btn--on' : ''}`}
|
||||
title="빈 영역 드래그로 그래프 이동"
|
||||
onClick={() => handleInteractionModeChange('pan')}
|
||||
>
|
||||
이동
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-canvas-interaction-btn${interactionMode === 'select' ? ' se-canvas-interaction-btn--on' : ''}`}
|
||||
title="드래그로 전략 요소 다중 선택"
|
||||
onClick={() => handleInteractionModeChange('select')}
|
||||
>
|
||||
드래그
|
||||
</button>
|
||||
</div>
|
||||
<span className="se-canvas-toolbar-hint">
|
||||
다중 선택 × 일괄삭제 · 연결선 × 끊기 · Del 삭제
|
||||
{interactionMode === 'pan'
|
||||
? '빈 영역 드래그 → 그래프 이동 · 노드 드래그 → 위치 변경'
|
||||
: '빈 영역 드래그 → 다중 선택 · Del 삭제 · 연결선 × 끊기'}
|
||||
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
|
||||
</span>
|
||||
</Panel>
|
||||
|
||||
Reference in New Issue
Block a user