모의투자, 백테스팅 레이아웃 수정
This commit is contained in:
+2
-171
@@ -11317,173 +11317,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
.admin-perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px 12px; max-height: 360px; overflow-y: auto; padding: 8px; background: var(--bg3); border-radius: 6px; border: 1px solid var(--border); }
|
||||
.admin-perm-item { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
SPLASH (진입 화면)
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
.splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0e14;
|
||||
color: #e8eaed;
|
||||
overflow: auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
.splash-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(33, 150, 243, 0.25), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 100% 80%, rgba(255, 213, 79, 0.08), transparent),
|
||||
linear-gradient(180deg, #0d1117 0%, #0a0e14 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.splash-chart-deco {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 18%;
|
||||
height: 140px;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
.splash-chart-svg { width: 100%; height: 100%; }
|
||||
.splash-line {
|
||||
stroke-dasharray: 600;
|
||||
animation: splash-draw 2.5s ease-out forwards;
|
||||
}
|
||||
@keyframes splash-draw {
|
||||
from { stroke-dashoffset: 600; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
.splash-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.splash-brand { text-align: center; }
|
||||
.splash-logo { margin-bottom: 12px; filter: drop-shadow(0 4px 20px rgba(33, 150, 243, 0.4)); }
|
||||
.splash-title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, #fff 0%, #90caf9 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.splash-tagline {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: #9aa0a6;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.splash-features {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 16px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #b0b8c4;
|
||||
}
|
||||
.splash-features li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.splash-feat-icon { font-size: 14px; }
|
||||
.splash-card {
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
background: rgba(22, 28, 36, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.splash-card-title { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
||||
.splash-card-desc { margin: 0 0 16px; font-size: 13px; color: #9aa0a6; line-height: 1.5; }
|
||||
.splash-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.splash-form-row { display: flex; flex-direction: column; gap: 10px; }
|
||||
.splash-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #9aa0a6; }
|
||||
.splash-field input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #0d1117;
|
||||
color: #e8eaed;
|
||||
font-size: 14px;
|
||||
}
|
||||
.splash-field input:focus {
|
||||
outline: none;
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.25);
|
||||
}
|
||||
.splash-error { margin: 0; font-size: 12px; color: #ef5350; }
|
||||
.splash-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.splash-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 11px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.splash-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.splash-btn--primary {
|
||||
background: #2196f3;
|
||||
color: #fff;
|
||||
}
|
||||
.splash-btn--primary:hover:not(:disabled) { background: #42a5f5; }
|
||||
.splash-btn--guest {
|
||||
background: transparent;
|
||||
color: #90caf9;
|
||||
border: 1px solid rgba(33, 150, 243, 0.5);
|
||||
}
|
||||
.splash-btn--guest:hover:not(:disabled) {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
border-color: #42a5f5;
|
||||
}
|
||||
.splash-footnote {
|
||||
margin: 14px 0 0;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
.splash-footnote code {
|
||||
padding: 1px 5px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.splash-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
font-size: 11px;
|
||||
color: #4b5563;
|
||||
}
|
||||
/* splash styles moved to styles/splashScreen.css */
|
||||
|
||||
.tmb-guest-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -11493,7 +11328,3 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
@media (min-width: 520px) {
|
||||
.splash-form-row { flex-direction: row; }
|
||||
.splash-form-row .splash-field { flex: 1; }
|
||||
}
|
||||
|
||||
@@ -1498,7 +1498,7 @@ function App() {
|
||||
|
||||
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
||||
{menuPage === 'backtest' && (
|
||||
<BacktestHistoryPage />
|
||||
<BacktestHistoryPage theme={theme} />
|
||||
)}
|
||||
|
||||
{menuPage === 'paper' && (
|
||||
|
||||
@@ -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,21 +95,15 @@ export function BacktestHistoryPage() {
|
||||
|
||||
const selectedDetail = selected ? parseDetail(selected) : null;
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
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]);
|
||||
|
||||
{loading ? (
|
||||
<div className="btd-sidebar-empty">로딩 중…</div>
|
||||
) : records.length === 0 ? (
|
||||
const leftContent = records.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
@@ -120,11 +120,29 @@ export function BacktestHistoryPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
|
||||
<main className="btd-main">
|
||||
{selected && selectedDetail ? (
|
||||
return (
|
||||
<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}
|
||||
@@ -140,8 +158,61 @@ export function BacktestHistoryPage() {
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
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>
|
||||
) : 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,114 +146,69 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
if (res) { setSummary(res); setTrades([]); }
|
||||
}, []);
|
||||
|
||||
if (initialLoading) {
|
||||
return <div className="ptd-page ptd-page--loading">모의투자 대시보드 로딩…</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>
|
||||
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>
|
||||
</header>
|
||||
|
||||
{!s?.enabled && (
|
||||
<div className="ptd-banner ptd-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
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>
|
||||
</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' ? (
|
||||
return (
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="모의투자"
|
||||
subtitle="Paper Trading"
|
||||
loading={initialLoading}
|
||||
loadingText="모의투자 대시보드 로딩…"
|
||||
leftStorageKey="ptd-left-width"
|
||||
footerStorageKey="ptd-footer-height"
|
||||
headerActions={(
|
||||
<>
|
||||
<div className="ptd-card ptd-order-card">
|
||||
<div className="ptd-card-head">주문</div>
|
||||
<div className="ptd-order-stack">
|
||||
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}>새로고침</button>
|
||||
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleReset()}>계좌 초기화</button>
|
||||
</>
|
||||
)}
|
||||
banner={!s?.enabled ? (
|
||||
<div className="bps-banner bps-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
) : undefined}
|
||||
leftTabs={leftTabs}
|
||||
left={leftTab === 'strategy' ? (
|
||||
<PaperLeftStrategyTab
|
||||
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}
|
||||
@@ -297,6 +222,8 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
)}
|
||||
bottom={(
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
@@ -309,15 +236,46 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ptd-card ptd-history-card">
|
||||
<div className="ptd-tabs">
|
||||
)}
|
||||
/>
|
||||
) : 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>
|
||||
{historyTab === 'open' ? (
|
||||
)}
|
||||
bottom={historyTab === 'open' ? (
|
||||
<p className="ptd-muted">미체결 주문 없음 (모의투자 즉시 체결)</p>
|
||||
) : (
|
||||
<table className="ptd-table ptd-table--compact">
|
||||
@@ -325,7 +283,7 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
<tr><th>시간</th><th>자산</th><th>유형</th><th>가격</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.slice(0, 12).map(t => (
|
||||
{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>
|
||||
@@ -338,16 +296,20 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</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
|
||||
className="splash-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="admin"
|
||||
placeholder="ID"
|
||||
/>
|
||||
</label>
|
||||
<label className="splash-field">
|
||||
<span>비밀번호</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="splash-field--visible"
|
||||
className="splash-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="••••••"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="splash-error">{error}</p>}
|
||||
<div className="splash-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="splash-btn splash-btn--primary"
|
||||
disabled={loading}
|
||||
>
|
||||
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="splash-btn splash-btn--guest"
|
||||
onClick={onGuest}
|
||||
disabled={loading}
|
||||
>
|
||||
게스트 모드
|
||||
</button>
|
||||
</div>
|
||||
</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,7 +718,10 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
onPointerDown={onLeftSplitter}
|
||||
/>
|
||||
|
||||
<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">
|
||||
@@ -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,8 +893,12 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveOpen && (
|
||||
<DraggableModalFrame onClose={() => setSaveOpen(false)} title="전략 저장">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* 백테스팅 대시보드 (btd-*) — 앱 테마 변수 + 금색 포인트 */
|
||||
/* 백테스팅 대시보드 (btd-*) — BuilderPageShell 내부 콘텐츠 스타일 */
|
||||
|
||||
.btd-page {
|
||||
.bps-page,
|
||||
.se-page {
|
||||
--btd-gold: #c9a227;
|
||||
--btd-gold-light: #e8c547;
|
||||
--btd-gold-dark: #b8860b;
|
||||
@@ -8,105 +9,109 @@
|
||||
--btd-gold-border-strong: rgba(201, 162, 39, 0.35);
|
||||
--btd-gold-bg: rgba(201, 162, 39, 0.08);
|
||||
--btd-gold-bg-hover: rgba(201, 162, 39, 0.14);
|
||||
|
||||
display: flex;
|
||||
height: calc(100vh - var(--tmb-h, 46px));
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font, 'Noto Sans KR', sans-serif);
|
||||
}
|
||||
|
||||
.app.light .btd-page,
|
||||
html.theme-light .btd-page {
|
||||
.se-page--light {
|
||||
--btd-gold-border: rgba(184, 134, 11, 0.28);
|
||||
--btd-gold-border-strong: rgba(184, 134, 11, 0.45);
|
||||
--btd-gold-bg: rgba(201, 162, 39, 0.1);
|
||||
--btd-gold-bg-hover: rgba(201, 162, 39, 0.16);
|
||||
}
|
||||
|
||||
/* ── 좌측 타임라인 사이드바 (~23%) ── */
|
||||
.btd-sidebar {
|
||||
width: 23%;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--btd-gold-border);
|
||||
background: var(--bg2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bps-center-content .btd-empty span { font-size: 48px; opacity: 0.35; }
|
||||
.btd-sidebar-hint { font-size: 0.72rem; opacity: 0.75; line-height: 1.6; }
|
||||
|
||||
.btd-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 10px;
|
||||
border-bottom: 1px solid var(--btd-gold-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btd-sidebar-bar {
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--btd-gold-light), var(--btd-gold-dark));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btd-sidebar-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.btd-sidebar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btd-icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--btd-gold-border);
|
||||
background: var(--btd-gold-bg);
|
||||
color: var(--btd-gold);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btd-icon-btn:hover:not(:disabled) { background: var(--btd-gold-bg-hover); }
|
||||
.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: var(--text3);
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.btd-sidebar-hint { font-size: 11px; opacity: 0.75; line-height: 1.6; }
|
||||
|
||||
.btd-timeline {
|
||||
.bps-panel-body .btd-timeline {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px 16px 22px;
|
||||
padding: 4px 2px 8px 14px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.bps-panel-body .btd-sidebar-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--se-text-dim);
|
||||
font-size: 0.78rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bps-center-content .btd-dashboard {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bps-center-content .btd-empty {
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--se-text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btd-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btd-meta-value {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.btd-footer-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btd-footer-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent);
|
||||
}
|
||||
|
||||
.btd-footer-stat-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.btd-footer-stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.btd-footer-stat-value.up { color: var(--se-up); }
|
||||
.btd-footer-stat-value.down { color: var(--se-down); }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.btd-footer-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* legacy page layout removed — shell handles structure */
|
||||
|
||||
.btd-timeline-line {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
@@ -207,39 +212,6 @@ html.theme-light .btd-page {
|
||||
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: var(--bg);
|
||||
}
|
||||
|
||||
.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: var(--text3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.btd-empty span { font-size: 48px; opacity: 0.35; }
|
||||
|
||||
/* ── 섹션 헤더 (금색 바) ── */
|
||||
.btd-section-head {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/* Builder shell — 전략편집기(se-page)와 동일한 3열 + 하단 패널 레이아웃 */
|
||||
|
||||
.bps-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: calc(100vh - var(--tmb-h, 44px));
|
||||
min-height: 0;
|
||||
background: var(--se-bg);
|
||||
color: var(--se-text);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bps-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--se-header-border);
|
||||
background: var(--se-header-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bps-header-left { display: flex; align-items: baseline; gap: 10px; }
|
||||
|
||||
.bps-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
background: var(--se-title-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.bps-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
||||
|
||||
.bps-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.bps-btn {
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-btn-bg);
|
||||
color: var(--se-text);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.bps-btn:hover { border-color: color-mix(in srgb, var(--se-accent) 50%, transparent); }
|
||||
.bps-btn--ghost { background: transparent; }
|
||||
.bps-btn--gold {
|
||||
background: var(--se-btn-gold-bg);
|
||||
border-color: var(--se-btn-gold-border);
|
||||
color: var(--se-btn-gold-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.bps-btn--danger { border-color: color-mix(in srgb, var(--se-danger) 50%, transparent); color: var(--se-danger); }
|
||||
.bps-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
.bps-banner {
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.bps-banner--warn {
|
||||
background: color-mix(in srgb, #fbbf24 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #fbbf24 35%, transparent);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.bps-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bps-left {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-panel-shell-bg);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.bps-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 12px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--se-panel-card-bg);
|
||||
border: 1px solid var(--se-panel-card-border);
|
||||
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||
}
|
||||
|
||||
.bps-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bps-panel-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.bps-panel-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
.bps-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bps-splitter {
|
||||
flex-shrink: 0;
|
||||
background: var(--se-splitter-bg);
|
||||
transition: background 0.12s, box-shadow 0.12s;
|
||||
touch-action: none;
|
||||
z-index: 6;
|
||||
}
|
||||
.bps-splitter:hover,
|
||||
.bps-splitter.bps-splitter--active {
|
||||
background: var(--se-splitter-active);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--se-accent) 20%, transparent);
|
||||
}
|
||||
.bps-splitter--v { width: 5px; cursor: col-resize; }
|
||||
.bps-splitter--h {
|
||||
height: 5px;
|
||||
margin: 0 10px;
|
||||
border-radius: 3px;
|
||||
cursor: row-resize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bps-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-center-bg);
|
||||
}
|
||||
|
||||
.bps-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.bps-main-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bps-center-work {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bps-center-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 0;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bps-center-head-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.bps-center-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bps-right {
|
||||
flex: 0 0 280px;
|
||||
width: 280px;
|
||||
border-left: 1px solid var(--se-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
background: var(--se-palette-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bps-right-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bps-left-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
flex-shrink: 0;
|
||||
margin: -14px -12px 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.bps-left-tab,
|
||||
.bps-right-tab {
|
||||
flex: 1;
|
||||
padding: 10px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bps-left-tab--on,
|
||||
.bps-right-tab--on {
|
||||
color: var(--se-tab-active);
|
||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
||||
}
|
||||
|
||||
.bps-panel-body--tabs {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.bps-right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bps-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 12px 0;
|
||||
border: 1px solid var(--se-terminal-border);
|
||||
border-radius: 10px;
|
||||
background: var(--se-terminal-bg);
|
||||
overflow: hidden;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.bps-footer-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--se-terminal-label);
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
|
||||
.bps-footer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bps-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--se-strat-item-border);
|
||||
background: var(--se-strat-item-bg);
|
||||
box-shadow: var(--se-strat-item-shadow);
|
||||
color: var(--se-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
|
||||
}
|
||||
.bps-list-item:hover {
|
||||
box-shadow: var(--se-strat-item-hover-shadow);
|
||||
background: var(--se-item-hover-bg);
|
||||
}
|
||||
.bps-list-item--sel {
|
||||
background: var(--se-item-selected-bg);
|
||||
border-color: var(--se-item-selected-border);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--se-gold) 20%, transparent);
|
||||
}
|
||||
.bps-list-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bps-list-item--sel .bps-list-item-name { color: var(--se-item-selected-text); }
|
||||
.bps-list-item-meta { font-size: 0.68rem; color: var(--se-text-muted); }
|
||||
.bps-list-item-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bps-list-item-badge.up { color: var(--se-up); background: color-mix(in srgb, var(--se-up) 12%, transparent); }
|
||||
.bps-list-item-badge.down { color: var(--se-down); background: color-mix(in srgb, var(--se-down) 12%, transparent); }
|
||||
|
||||
.bps-empty {
|
||||
font-size: 0.78rem;
|
||||
color: var(--se-text-dim);
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bps-card {
|
||||
border: 1px solid var(--se-border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bps-card-head {
|
||||
padding: 8px 10px 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
.bps-card-body { padding: 8px 10px 10px; }
|
||||
|
||||
.bps-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1,67 +1,296 @@
|
||||
/* 모의투자 대시보드 (ptd-) — 전체 화면 고정 레이아웃, 앱 테마 변수 연동 */
|
||||
.ptd-page {
|
||||
/* 모의투자 — 좌측 패널·우측 탭·성과 지표 하단선 정렬 */
|
||||
|
||||
.bps-page .bps-main {
|
||||
padding: 0 10px 10px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bps-page .bps-right {
|
||||
border: 1px solid var(--se-panel-card-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||
}
|
||||
|
||||
.bps-page .bps-footer {
|
||||
margin: 0 12px 0;
|
||||
}
|
||||
|
||||
.bps-center-content .ptd-center-fill {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bps-center-content .ptd-chart-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-panel-card-bg);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 중앙 — 캔들 + 보조지표 (실시간 차트 레이아웃) */
|
||||
.ptd-analysis-chart {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-analysis-chart .ptd-main-pane {
|
||||
flex: 1.55;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-analysis-chart .ptd-sub-panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--sep);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ptd-analysis-chart .ptd-chart-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
.ptd-analysis-chart .ptd-chart-canvas {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.ptd-ind-stack {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ptd-ind-row {
|
||||
flex: 1;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 10px 6px;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
background: color-mix(in srgb, var(--bg3) 80%, transparent);
|
||||
}
|
||||
.ptd-ind-row:last-child { border-bottom: none; }
|
||||
.ptd-ind-row .ptd-ind-svg {
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 좌·우 상하단 카드 분리 */
|
||||
.ptd-split-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.bps-panel-body--tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bps-panel-body--tabs > .ptd-split-panel--left,
|
||||
.bps-right-body > .ptd-right-body > .ptd-split-panel--right {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.ptd-split-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--se-border, var(--border));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--se-bg-elevated, var(--bg2)) 88%, transparent);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--se-text, var(--text)) 4%, transparent);
|
||||
}
|
||||
.ptd-split-card-head {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2, var(--se-text-muted));
|
||||
border-bottom: 1px solid var(--sep, var(--se-border));
|
||||
}
|
||||
.ptd-split-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card-body .ptd-ob--section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card-body .ptd-ob--section .ptd-ob-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ptd-split-panel--right .ptd-order-card,
|
||||
.ptd-split-panel--right .top-panel {
|
||||
background: transparent;
|
||||
}
|
||||
.ptd-tabs--in-card {
|
||||
border-bottom: none;
|
||||
margin: -4px -2px 0;
|
||||
}
|
||||
.ptd-asset-inline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
}
|
||||
.ptd-asset-total--sm {
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ptd-asset-ret--sm {
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 좌측 전략·설정 탭 */
|
||||
.ptd-left-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.ptd-left-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ptd-left-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--sep);
|
||||
}
|
||||
.ptd-left-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text2);
|
||||
}
|
||||
.ptd-left-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
}
|
||||
.ptd-left-select,
|
||||
.ptd-left-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ptd-left-hint {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.ptd-left-status {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg3);
|
||||
}
|
||||
.ptd-left-status--on {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
.ptd-left-reset {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bps-right-body .ptd-right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
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: var(--text2);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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: var(--text); }
|
||||
.ptd-topbar-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.ptd-btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg2);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ptd-btn:hover { background: var(--bg3); }
|
||||
.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;
|
||||
.bps-right-body .ptd-right-body > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ptd-metrics {
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
.bps-right-body .ptd-right-body > .ptd-split-panel {
|
||||
gap: 8px;
|
||||
}
|
||||
.ptd-metrics-grid {
|
||||
|
||||
.bps-footer .ptd-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bps-page .ptd-metric,
|
||||
.se-page .ptd-metric {
|
||||
background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent);
|
||||
border-color: var(--se-border);
|
||||
}
|
||||
|
||||
.bps-page .ptd-card,
|
||||
.se-page .ptd-card {
|
||||
background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent);
|
||||
border-color: var(--se-border);
|
||||
}
|
||||
|
||||
.bps-page .up,
|
||||
.se-page .up { color: var(--se-up, #22c55e); }
|
||||
.bps-page .down,
|
||||
.se-page .down { color: var(--se-down, #ef4444); }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.bps-footer .ptd-metrics-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* legacy layout removed — shell handles page structure */
|
||||
.ptd-metric {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -78,28 +307,6 @@
|
||||
.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: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
@@ -300,23 +507,30 @@
|
||||
.ptd-right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ptd-right-body > .ptd-ob--fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: -8px;
|
||||
margin: 0;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; }
|
||||
.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; }
|
||||
@@ -397,6 +611,23 @@
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.ptd-history-card--tab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ptd-history-card--tab .ptd-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.bps-right-body > .ptd-right-body > .ptd-history-card--tab {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ptd-history-card .ptd-table-wrap,
|
||||
.ptd-history-card .ptd-table {
|
||||
flex: 1;
|
||||
@@ -425,16 +656,3 @@
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/* GoldenChart splash — glassmorphism login + 은은한 차트 배경 */
|
||||
|
||||
.splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0c10;
|
||||
color: #e8eaed;
|
||||
overflow: hidden;
|
||||
font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif);
|
||||
}
|
||||
|
||||
.splash-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at 78% 35%, rgba(45, 212, 191, 0.07), transparent 58%),
|
||||
linear-gradient(165deg, #0c0e13 0%, #0a0c10 55%, #08090d 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 전체 화면 차트 — 우상향 캔들 + 일목 구름 */
|
||||
.splash-chart-layer {
|
||||
position: absolute;
|
||||
inset: -4% -6% -4% -2%;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(3%, -4%) scale(1.08);
|
||||
transform-origin: 75% 35%;
|
||||
}
|
||||
|
||||
.splash-chart-bg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.splash-chart-fade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse 42% 38% at 50% 52%, rgba(10, 12, 16, 0.55) 0%, rgba(10, 12, 16, 0.82) 72%),
|
||||
linear-gradient(125deg, rgba(10, 12, 16, 0.72) 0%, rgba(10, 12, 16, 0.12) 38%, rgba(10, 12, 16, 0.45) 100%),
|
||||
linear-gradient(to top, rgba(10, 12, 16, 0.35) 0%, transparent 28%);
|
||||
}
|
||||
|
||||
.splash-chart-grid {
|
||||
stroke: rgba(255, 255, 255, 0.05);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.splash-chart-kumo {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.splash-ichi {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.splash-ichi--tenkan {
|
||||
stroke: rgba(56, 189, 248, 0.35);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.splash-ichi--kijun {
|
||||
stroke: rgba(244, 114, 182, 0.32);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.splash-ichi--senkou-a {
|
||||
stroke: rgba(34, 197, 94, 0.28);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
.splash-ichi--senkou-b {
|
||||
stroke: rgba(239, 68, 68, 0.25);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.splash-candle-wick {
|
||||
stroke: rgba(203, 213, 225, 0.45);
|
||||
stroke-width: 1.3;
|
||||
}
|
||||
.splash-candle-up {
|
||||
fill: rgba(45, 212, 191, 0.55);
|
||||
stroke: rgba(94, 234, 212, 0.75);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.splash-candle-down {
|
||||
fill: rgba(100, 116, 139, 0.5);
|
||||
stroke: rgba(148, 163, 184, 0.45);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.splash-chart-volume {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.splash-vol-bar {
|
||||
fill: rgba(100, 116, 139, 0.35);
|
||||
}
|
||||
|
||||
.splash-center {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
width: min(92vw, 380px);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.splash-glass {
|
||||
width: 100%;
|
||||
padding: 36px 32px 28px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(12, 14, 18, 0.55);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow:
|
||||
0 24px 64px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.splash-brand {
|
||||
margin: 0 0 28px;
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
background: linear-gradient(180deg, #f9e29c 0%, #d4af37 42%, #b8860b 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 2px 12px rgba(212, 175, 55, 0.25));
|
||||
}
|
||||
|
||||
.splash-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.splash-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 13px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #f3f4f6;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.splash-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.splash-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(212, 175, 55, 0.45);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.12);
|
||||
}
|
||||
|
||||
.splash-input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.splash-error {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #f87171;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.splash-login-btn {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 13px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #1a1408;
|
||||
background: linear-gradient(180deg, #f9e29c 0%, #d4af37 48%, #c9a227 100%);
|
||||
box-shadow: 0 4px 16px rgba(201, 162, 39, 0.35);
|
||||
transition: transform 0.12s, filter 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.splash-login-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 6px 20px rgba(201, 162, 39, 0.45);
|
||||
}
|
||||
|
||||
.splash-login-btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.splash-login-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.splash-version {
|
||||
margin: 22px 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
color: rgba(255, 255, 255, 0.32);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.splash-guest-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.splash-guest-link:hover:not(:disabled) {
|
||||
color: rgba(249, 226, 156, 0.85);
|
||||
}
|
||||
|
||||
.splash-guest-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.splash-glass {
|
||||
padding: 28px 22px 22px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.splash-brand {
|
||||
font-size: 1.65rem;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,28 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.se-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 10px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.se-main-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se-splitter {
|
||||
@@ -157,7 +179,7 @@
|
||||
}
|
||||
.se-splitter--h {
|
||||
height: 5px;
|
||||
margin: 0 10px;
|
||||
margin: 0 8px;
|
||||
border-radius: 3px;
|
||||
cursor: row-resize;
|
||||
flex-shrink: 0;
|
||||
@@ -170,8 +192,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
background: var(--se-panel-shell-bg);
|
||||
padding: 10px;
|
||||
padding: 10px 10px 0;
|
||||
}
|
||||
|
||||
.se-strat-panel {
|
||||
@@ -273,7 +296,7 @@
|
||||
.se-strat-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text);
|
||||
white-space: nowrap;
|
||||
@@ -295,11 +318,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--se-text-dim);
|
||||
cursor: pointer;
|
||||
@@ -350,7 +373,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-center-bg);
|
||||
align-self: stretch;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.se-center-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
background: var(--se-panel-card-bg);
|
||||
border: 1px solid var(--se-panel-card-border);
|
||||
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se-center-work {
|
||||
@@ -432,10 +468,48 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.se-canvas-toolbar-title { font-size: 0.72rem; font-weight: 700; color: var(--se-toolbar-title); }
|
||||
.se-canvas-toolbar-hint { font-size: 0.65rem; color: var(--se-text-muted); }
|
||||
|
||||
.se-canvas-interaction {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--se-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.se-canvas-interaction-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.se-canvas-interaction-btn:hover {
|
||||
background: color-mix(in srgb, var(--se-accent) 10%, transparent);
|
||||
color: var(--se-text);
|
||||
}
|
||||
.se-canvas-interaction-btn--on {
|
||||
background: color-mix(in srgb, var(--se-accent) 18%, transparent);
|
||||
color: var(--se-tab-active);
|
||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
||||
}
|
||||
|
||||
.se-canvas-wrap--pan .react-flow__pane {
|
||||
cursor: grab;
|
||||
}
|
||||
.se-canvas-wrap--pan .react-flow__pane:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.se-canvas-wrap--select .react-flow__pane {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.se-canvas-empty {
|
||||
background: var(--se-toolbar-bg);
|
||||
border: 1px dashed var(--se-border);
|
||||
@@ -764,9 +838,10 @@
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 10px 10px;
|
||||
border: 1px solid var(--se-terminal-border);
|
||||
border-radius: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--se-terminal-border);
|
||||
border-radius: 0;
|
||||
background: var(--se-terminal-bg);
|
||||
overflow: hidden;
|
||||
min-height: 88px;
|
||||
@@ -834,11 +909,23 @@
|
||||
.se-right {
|
||||
flex: 0 0 260px;
|
||||
width: 260px;
|
||||
border-left: 1px solid var(--se-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--se-palette-bg);
|
||||
align-self: stretch;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se-palette-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
background: var(--se-panel-card-bg);
|
||||
border: 1px solid var(--se-panel-card-border);
|
||||
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -860,6 +947,14 @@
|
||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
||||
}
|
||||
|
||||
.se-right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se-palette-search {
|
||||
margin: 10px 10px 6px;
|
||||
padding: 8px 10px;
|
||||
@@ -907,12 +1002,20 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.se-palette-card--selected {
|
||||
outline: 2px solid var(--se-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ── 논리 연산: AND / OR / NOT ── */
|
||||
.se-palette-card--logic-and {
|
||||
border-color: color-mix(in srgb, var(--se-palette-logic-and-accent) 42%, transparent);
|
||||
background: var(--se-palette-logic-and-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-and-accent);
|
||||
}
|
||||
.se-palette-card--selected.se-palette-card--logic-and {
|
||||
outline-color: var(--se-palette-logic-and-accent);
|
||||
}
|
||||
.se-palette-card--logic-and .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-logic-and-accent) 22%, transparent);
|
||||
color: var(--se-palette-logic-and-accent);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type StrategyCanvasInteractionMode = 'pan' | 'select';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_canvas_interaction';
|
||||
|
||||
export function loadCanvasInteractionMode(): StrategyCanvasInteractionMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'pan' ? 'pan' : 'select';
|
||||
} catch {
|
||||
return 'select';
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCanvasInteractionMode(mode: StrategyCanvasInteractionMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user