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

This commit is contained in:
Macbook
2026-05-24 20:13:21 +09:00
parent 958b813f3b
commit af230a4233
23 changed files with 2394 additions and 782 deletions
+2 -171
View File
@@ -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; }
}
+1 -1
View File
@@ -1498,7 +1498,7 @@ function App() {
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
{menuPage === 'backtest' && (
<BacktestHistoryPage />
<BacktestHistoryPage theme={theme} />
)}
{menuPage === 'paper' && (
+92 -21
View File
@@ -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}
/>
);
}
+117 -155
View File
@@ -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>
)}
/>
);
};
+25 -90
View File
@@ -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> &amp; </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 &amp; 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>
);
};
+30 -2
View File
@@ -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>
+92 -120
View File
@@ -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;
+368
View File
@@ -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;
}
+310 -92
View File
@@ -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; }
}
+252
View File
@@ -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;
}
}
+115 -12
View File
@@ -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 */
}
}