모의투자, 백테스팅 레이아웃 수정
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-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; }
|
.admin-perm-item { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════
|
/* splash styles moved to styles/splashScreen.css */
|
||||||
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;
|
|
||||||
}
|
|
||||||
.tmb-guest-badge {
|
.tmb-guest-badge {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -11493,7 +11328,3 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 193, 7, 0.1);
|
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' && (
|
{menuPage === 'backtest' && (
|
||||||
<BacktestHistoryPage />
|
<BacktestHistoryPage theme={theme} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'paper' && (
|
{menuPage === 'paper' && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* BacktestHistoryPage — 첨부 이미지 스타일 (타임라인 이력 + 대시보드)
|
* BacktestHistoryPage — 전략편집기와 동일한 3열 + 하단 패널 레이아웃
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
type BacktestAnalysis,
|
type BacktestAnalysis,
|
||||||
type BacktestSignal,
|
type BacktestSignal,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
|
import type { Theme } from '../types';
|
||||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||||
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
|
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
|
||||||
import BacktestSparkline from './backtest/BacktestSparkline';
|
import BacktestSparkline from './backtest/BacktestSparkline';
|
||||||
|
import BuilderPageShell from './layout/BuilderPageShell';
|
||||||
|
|
||||||
const pct = (v: number) =>
|
const pct = (v: number) =>
|
||||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
|
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 [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -89,59 +95,124 @@ export function BacktestHistoryPage() {
|
|||||||
|
|
||||||
const selectedDetail = selected ? parseDetail(selected) : null;
|
const selectedDetail = selected ? parseDetail(selected) : null;
|
||||||
|
|
||||||
|
const signalStats = useMemo(() => {
|
||||||
|
const sigs = selectedDetail?.signals ?? [];
|
||||||
|
if (!sigs.length) return null;
|
||||||
|
const buy = sigs.filter(s => s.type === 'BUY' || s.type === 'SHORT_EXIT').length;
|
||||||
|
const sell = sigs.filter(s => s.type === 'SELL' || s.type === 'SHORT_ENTRY' || s.type === 'PARTIAL_SELL').length;
|
||||||
|
return { buy, sell, total: sigs.length };
|
||||||
|
}, [selectedDetail]);
|
||||||
|
|
||||||
|
const leftContent = records.length === 0 ? (
|
||||||
|
<div className="btd-sidebar-empty">
|
||||||
|
<p>백테스팅 이력이 없습니다.</p>
|
||||||
|
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="btd-timeline">
|
||||||
|
<span className="btd-timeline-line" />
|
||||||
|
{records.map(r => (
|
||||||
|
<TimelineItem
|
||||||
|
key={r.id}
|
||||||
|
r={r}
|
||||||
|
active={selected?.id === r.id}
|
||||||
|
onClick={() => setSelected(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="btd-page">
|
<BuilderPageShell
|
||||||
<aside className="btd-sidebar">
|
theme={theme}
|
||||||
<div className="btd-sidebar-head">
|
title="백테스팅"
|
||||||
<span className="btd-sidebar-bar" />
|
subtitle="Backtest History"
|
||||||
<h2 className="btd-sidebar-title">백테스팅 실행 이력</h2>
|
loading={loading}
|
||||||
<div className="btd-sidebar-actions">
|
loadingText="백테스팅 이력 로딩…"
|
||||||
<button type="button" className="btd-icon-btn" title="새로고침" onClick={() => void fetchList()}>↻</button>
|
leftStorageKey="btd-left-width"
|
||||||
<button type="button" className="btd-icon-btn btd-icon-btn--danger" title="전체 삭제" disabled={records.length === 0} onClick={() => void deleteAll()}>🗑</button>
|
footerStorageKey="btd-footer-height"
|
||||||
|
headerActions={(
|
||||||
|
<>
|
||||||
|
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void fetchList()}>새로고침</button>
|
||||||
|
<button type="button" className="bps-btn bps-btn--danger" disabled={records.length === 0} onClick={() => void deleteAll()}>전체 삭제</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
leftTitle="실행 이력"
|
||||||
|
left={leftContent}
|
||||||
|
centerHead={selected ? (
|
||||||
|
<span className="bps-center-head-title">{selected.strategyName || '전략 없음'} · {selected.symbol.replace(/^KRW-/, '')}</span>
|
||||||
|
) : undefined}
|
||||||
|
center={selected && selectedDetail ? (
|
||||||
|
<BacktestResultDashboard
|
||||||
|
analysis={selectedDetail.analysis}
|
||||||
|
signals={selectedDetail.signals}
|
||||||
|
strategyName={selected.strategyName}
|
||||||
|
symbol={selected.symbol}
|
||||||
|
timeframe={selected.timeframe}
|
||||||
|
barCount={selected.barCount}
|
||||||
|
createdAt={selected.createdAt}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="btd-empty">
|
||||||
|
<span>📈</span>
|
||||||
|
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
rightTitle="결과 요약"
|
||||||
|
right={selected ? (
|
||||||
|
<div className="btd-meta">
|
||||||
|
<div className="bps-card">
|
||||||
|
<div className="bps-card-head">전략</div>
|
||||||
|
<div className="bps-card-body btd-meta-value">{selected.strategyName || '전략 없음'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bps-card">
|
||||||
|
<div className="bps-card-head">종목 / 타임프레임</div>
|
||||||
|
<div className="bps-card-body btd-meta-value">{selected.symbol.replace(/^KRW-/, '')} · {selected.timeframe}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bps-card">
|
||||||
|
<div className="bps-card-head">실행일 / 봉 수</div>
|
||||||
|
<div className="bps-card-body btd-meta-value">{fmtDate(selected.createdAt)} · {selected.barCount.toLocaleString()}봉</div>
|
||||||
|
</div>
|
||||||
|
<div className="bps-card">
|
||||||
|
<div className="bps-card-head">총 수익률</div>
|
||||||
|
<div className={`bps-card-body btd-meta-value ${(selected.totalReturn ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||||
|
{pct(selected.totalReturn ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedDetail?.analysis && (
|
||||||
|
<div className="bps-card">
|
||||||
|
<div className="bps-card-head">MDD / Sharpe</div>
|
||||||
|
<div className="bps-card-body btd-meta-value">
|
||||||
|
{pct(selectedDetail.analysis.maxDrawdownPct)} · {selectedDetail.analysis.sharpeRatio.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bps-empty">선택된 결과가 없습니다.</div>
|
||||||
|
)}
|
||||||
|
footerLabel={signalStats ? '시그널 요약' : undefined}
|
||||||
|
footer={signalStats ? (
|
||||||
|
<div className="btd-footer-stats">
|
||||||
|
<div className="btd-footer-stat">
|
||||||
|
<span className="btd-footer-stat-label">매수 시그널</span>
|
||||||
|
<span className="btd-footer-stat-value up">{signalStats.buy}</span>
|
||||||
|
</div>
|
||||||
|
<div className="btd-footer-stat">
|
||||||
|
<span className="btd-footer-stat-label">매도 시그널</span>
|
||||||
|
<span className="btd-footer-stat-value down">{signalStats.sell}</span>
|
||||||
|
</div>
|
||||||
|
<div className="btd-footer-stat">
|
||||||
|
<span className="btd-footer-stat-label">총 시그널</span>
|
||||||
|
<span className="btd-footer-stat-value">{signalStats.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="btd-footer-stat">
|
||||||
|
<span className="btd-footer-stat-label">분석 봉 수</span>
|
||||||
|
<span className="btd-footer-stat-value">{selected?.barCount.toLocaleString() ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : undefined}
|
||||||
{loading ? (
|
/>
|
||||||
<div className="btd-sidebar-empty">로딩 중…</div>
|
|
||||||
) : records.length === 0 ? (
|
|
||||||
<div className="btd-sidebar-empty">
|
|
||||||
<p>백테스팅 이력이 없습니다.</p>
|
|
||||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="btd-timeline">
|
|
||||||
<span className="btd-timeline-line" />
|
|
||||||
{records.map(r => (
|
|
||||||
<TimelineItem
|
|
||||||
key={r.id}
|
|
||||||
r={r}
|
|
||||||
active={selected?.id === r.id}
|
|
||||||
onClick={() => setSelected(r)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="btd-main">
|
|
||||||
{selected && selectedDetail ? (
|
|
||||||
<BacktestResultDashboard
|
|
||||||
analysis={selectedDetail.analysis}
|
|
||||||
signals={selectedDetail.signals}
|
|
||||||
strategyName={selected.strategyName}
|
|
||||||
symbol={selected.symbol}
|
|
||||||
timeframe={selected.timeframe}
|
|
||||||
barCount={selected.barCount}
|
|
||||||
createdAt={selected.createdAt}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="btd-empty">
|
|
||||||
<span>📈</span>
|
|
||||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,17 @@ import {
|
|||||||
import { computePaperMetrics } from '../utils/paperMetrics';
|
import { computePaperMetrics } from '../utils/paperMetrics';
|
||||||
import { fmtKrw } from './TradeOrderPanel';
|
import { fmtKrw } from './TradeOrderPanel';
|
||||||
import TradeOrderPanel from './TradeOrderPanel';
|
import TradeOrderPanel from './TradeOrderPanel';
|
||||||
import type { TradeOrderFillRequest } from '../types';
|
import type { Theme, TradeOrderFillRequest } from '../types';
|
||||||
import PaperMiniChart from './paper/PaperMiniChart';
|
import PaperAnalysisChart from './paper/PaperAnalysisChart';
|
||||||
import PaperIndicatorPanel from './paper/PaperIndicatorPanel';
|
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
|
||||||
|
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
|
||||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||||
import type { Theme } from '../types';
|
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||||
|
import BuilderPageShell from './layout/BuilderPageShell';
|
||||||
const COIN_COLORS: Record<string, string> = {
|
|
||||||
BTC: '#f7931a', ETH: '#627eea', XRP: '#23292f', SOL: '#9945ff',
|
|
||||||
DOGE: '#c2a633', ADA: '#0033ad', DOT: '#e6007a', AVAX: '#e84142',
|
|
||||||
};
|
|
||||||
const DONUT_COLORS = ['#3f7ef5', '#22c55e', '#a78bfa', '#fbbf24', '#38bdf8', '#64748b'];
|
|
||||||
|
|
||||||
type HistoryTab = 'open' | 'recent';
|
type HistoryTab = 'open' | 'recent';
|
||||||
type RightTab = 'trade' | 'orderbook';
|
type RightTab = 'trade' | 'orderbook' | 'history';
|
||||||
|
type LeftTab = 'strategy' | 'settings';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
@@ -63,10 +60,6 @@ function coinCode(symbol: string): string {
|
|||||||
return symbol.replace(/^KRW-/, '');
|
return symbol.replace(/^KRW-/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function coinIcon(code: string): string {
|
|
||||||
return code.slice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PaperTradingPage: React.FC<Props> = ({
|
const PaperTradingPage: React.FC<Props> = ({
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
tickers,
|
tickers,
|
||||||
@@ -82,6 +75,7 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||||
|
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -126,7 +120,6 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
|
|
||||||
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
||||||
const s = summary;
|
const s = summary;
|
||||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
|
||||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||||
|
|
||||||
const posQty = useMemo(() => {
|
const posQty = useMemo(() => {
|
||||||
@@ -134,29 +127,6 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
return p?.quantity ?? 0;
|
return p?.quantity ?? 0;
|
||||||
}, [s?.positions, selectedMarket]);
|
}, [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 handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||||
const side = rowType === 'ask' ? 'buy' : 'sell';
|
const side = rowType === 'ask' ? 'buy' : 'sell';
|
||||||
fillSeqRef.current += 1;
|
fillSeqRef.current += 1;
|
||||||
@@ -176,178 +146,170 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
if (res) { setSummary(res); setTrades([]); }
|
if (res) { setSummary(res); setTrades([]); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (initialLoading) {
|
const rightTabs = (
|
||||||
return <div className="ptd-page ptd-page--loading">모의투자 대시보드 로딩…</div>;
|
<div className="bps-right-tabs">
|
||||||
}
|
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||||
|
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
||||||
|
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>체결</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const leftTabs = (
|
||||||
|
<div className="bps-left-tabs">
|
||||||
|
<button type="button" className={`bps-left-tab${leftTab === 'strategy' ? ' bps-left-tab--on' : ''}`} onClick={() => setLeftTab('strategy')}>전략</button>
|
||||||
|
<button type="button" className={`bps-left-tab${leftTab === 'settings' ? ' bps-left-tab--on' : ''}`} onClick={() => setLeftTab('settings')}>설정</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ptd-page">
|
<BuilderPageShell
|
||||||
<header className="ptd-topbar">
|
theme={theme}
|
||||||
<h1 className="ptd-title">모의투자</h1>
|
title="모의투자"
|
||||||
<div className="ptd-topbar-actions">
|
subtitle="Paper Trading"
|
||||||
<button type="button" className="ptd-btn" onClick={() => void loadData()}>새로고침</button>
|
loading={initialLoading}
|
||||||
<button type="button" className="ptd-btn ptd-btn--danger" onClick={handleReset}>계좌 초기화</button>
|
loadingText="모의투자 대시보드 로딩…"
|
||||||
</div>
|
leftStorageKey="ptd-left-width"
|
||||||
</header>
|
footerStorageKey="ptd-footer-height"
|
||||||
|
headerActions={(
|
||||||
{!s?.enabled && (
|
<>
|
||||||
<div className="ptd-banner ptd-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
<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 ? (
|
||||||
<section className="ptd-metrics">
|
<div className="bps-banner bps-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||||
|
) : undefined}
|
||||||
|
leftTabs={leftTabs}
|
||||||
|
left={leftTab === 'strategy' ? (
|
||||||
|
<PaperLeftStrategyTab
|
||||||
|
market={selectedMarket}
|
||||||
|
summary={s}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onMarketSelect={setSelectedMarket}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PaperLeftSettingsTab
|
||||||
|
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
centerHead={(
|
||||||
|
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
|
||||||
|
)}
|
||||||
|
center={(
|
||||||
|
<div className="ptd-card ptd-chart-card ptd-center-fill">
|
||||||
|
<PaperAnalysisChart market={selectedMarket} theme={theme} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
rightTabs={rightTabs}
|
||||||
|
right={(
|
||||||
|
<div className="ptd-right-body" ref={orderAnchorRef}>
|
||||||
|
{rightTab === 'trade' ? (
|
||||||
|
<PaperSplitPanel
|
||||||
|
className="ptd-split-panel--right"
|
||||||
|
topTitle="매수"
|
||||||
|
bottomTitle="매도"
|
||||||
|
top={(
|
||||||
|
<TradeOrderPanel
|
||||||
|
side="buy"
|
||||||
|
market={selectedMarket}
|
||||||
|
tradePrice={tradePrice}
|
||||||
|
availableKrw={s?.cashBalance ?? 0}
|
||||||
|
fillRequest={fillBuy}
|
||||||
|
searchAnchorRef={orderAnchorRef}
|
||||||
|
onMarketSelect={setSelectedMarket}
|
||||||
|
showSymbolField
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
bottom={(
|
||||||
|
<TradeOrderPanel
|
||||||
|
side="sell"
|
||||||
|
market={selectedMarket}
|
||||||
|
tradePrice={tradePrice}
|
||||||
|
availableCoinQty={posQty}
|
||||||
|
fillRequest={fillSell}
|
||||||
|
onMarketSelect={setSelectedMarket}
|
||||||
|
showSymbolField={false}
|
||||||
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : rightTab === 'orderbook' ? (
|
||||||
|
<PaperSplitPanel
|
||||||
|
className="ptd-split-panel--right"
|
||||||
|
topTitle="매도 호가"
|
||||||
|
bottomTitle="매수 호가"
|
||||||
|
top={(
|
||||||
|
<PaperCompactOrderbook
|
||||||
|
market={selectedMarket}
|
||||||
|
onPick={handleObPick}
|
||||||
|
section="asks"
|
||||||
|
fillHeight
|
||||||
|
hideHeader
|
||||||
|
depth={10}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
bottom={(
|
||||||
|
<PaperCompactOrderbook
|
||||||
|
market={selectedMarket}
|
||||||
|
onPick={handleObPick}
|
||||||
|
section="bids"
|
||||||
|
fillHeight
|
||||||
|
hideHeader
|
||||||
|
depth={10}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PaperSplitPanel
|
||||||
|
className="ptd-split-panel--right"
|
||||||
|
topTitle="체결 구분"
|
||||||
|
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'}
|
||||||
|
top={(
|
||||||
|
<div className="ptd-tabs ptd-tabs--in-card">
|
||||||
|
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
|
||||||
|
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>최근체결</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
bottom={historyTab === 'open' ? (
|
||||||
|
<p className="ptd-muted">미체결 주문 없음 (모의투자 즉시 체결)</p>
|
||||||
|
) : (
|
||||||
|
<table className="ptd-table ptd-table--compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시간</th><th>자산</th><th>유형</th><th>가격</th><th>상태</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{trades.slice(0, 20).map(t => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
|
||||||
|
<td>{coinCode(t.symbol)}</td>
|
||||||
|
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
|
||||||
|
<td>{fmtKrw(t.price)}</td>
|
||||||
|
<td><span className="ptd-status ptd-status--done">체결</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!trades.length && <tr><td colSpan={5} className="ptd-muted">체결 내역 없음</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
footerLabel="성과 지표"
|
||||||
|
footer={(
|
||||||
<div className="ptd-metrics-grid">
|
<div className="ptd-metrics-grid">
|
||||||
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
|
<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="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="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
|
||||||
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
|
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
|
/>
|
||||||
<div className="ptd-main">
|
|
||||||
<aside className="ptd-col ptd-col--left">
|
|
||||||
<div className="ptd-card ptd-asset-card">
|
|
||||||
<div className="ptd-card-label">실시간 자산 현황</div>
|
|
||||||
<div className="ptd-asset-total">{s ? fmtKrw(s.totalAsset) : '—'} <span>KRW</span></div>
|
|
||||||
<div className={`ptd-asset-ret ${retUp ? 'up' : 'down'}`}>
|
|
||||||
{s ? `${retUp ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
|
||||||
</div>
|
|
||||||
<div className="ptd-asset-sub">초기자본 {s ? fmtKrw(s.initialCapital) : '—'} KRW</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ptd-card">
|
|
||||||
<div className="ptd-card-head">보유 포트폴리오</div>
|
|
||||||
<table className="ptd-table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>자산</th><th>현재가</th><th>수량</th><th>손익</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(s?.positions ?? []).length === 0 ? (
|
|
||||||
<tr><td colSpan={4} className="ptd-muted">보유 종목 없음</td></tr>
|
|
||||||
) : s!.positions.map(p => {
|
|
||||||
const code = coinCode(p.symbol);
|
|
||||||
const up = (p.profitLoss ?? 0) >= 0;
|
|
||||||
return (
|
|
||||||
<tr key={p.symbol} className={selectedMarket === p.symbol ? 'ptd-row--sel' : ''} onClick={() => setSelectedMarket(p.symbol)}>
|
|
||||||
<td>
|
|
||||||
<span className="ptd-coin" style={{ background: COIN_COLORS[code] ?? '#64748b' }}>{coinIcon(code)}</span>
|
|
||||||
{code}
|
|
||||||
</td>
|
|
||||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
|
||||||
<td>{p.quantity.toFixed(4)}</td>
|
|
||||||
<td className={up ? 'up' : 'down'}>
|
|
||||||
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
|
|
||||||
{p.profitLossPct != null && <small> ({p.profitLossPct.toFixed(1)}%)</small>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ptd-card ptd-donut-card">
|
|
||||||
<div className="ptd-card-head">투자 비중</div>
|
|
||||||
<div className="ptd-donut-wrap">
|
|
||||||
<div className="ptd-donut" style={donutStyle}>
|
|
||||||
<div className="ptd-donut-hole">
|
|
||||||
<span>{s ? `${(s.totalAsset / 1_000_000).toFixed(1)}M` : '—'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="ptd-donut-legend">
|
|
||||||
{allocation.map((a, i) => (
|
|
||||||
<li key={a.label}>
|
|
||||||
<span className="ptd-dot" style={{ background: DONUT_COLORS[i % DONUT_COLORS.length] }} />
|
|
||||||
{a.label} {a.pct.toFixed(0)}%
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="ptd-col ptd-col--center">
|
|
||||||
<div className="ptd-card ptd-chart-card">
|
|
||||||
<PaperMiniChart market={selectedMarket} theme={theme} />
|
|
||||||
<PaperIndicatorPanel market={selectedMarket} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside className="ptd-col ptd-col--right" ref={orderAnchorRef}>
|
|
||||||
<div className="ptd-right-panel">
|
|
||||||
<div className="ptd-tabs ptd-tabs--main">
|
|
||||||
<button type="button" className={`ptd-tab${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
|
||||||
<button type="button" className={`ptd-tab${rightTab === 'orderbook' ? ' active' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
|
||||||
</div>
|
|
||||||
<div className="ptd-right-body">
|
|
||||||
{rightTab === 'trade' ? (
|
|
||||||
<>
|
|
||||||
<div className="ptd-card ptd-order-card">
|
|
||||||
<div className="ptd-card-head">주문</div>
|
|
||||||
<div className="ptd-order-stack">
|
|
||||||
<TradeOrderPanel
|
|
||||||
side="buy"
|
|
||||||
market={selectedMarket}
|
|
||||||
tradePrice={tradePrice}
|
|
||||||
availableKrw={s?.cashBalance ?? 0}
|
|
||||||
fillRequest={fillBuy}
|
|
||||||
searchAnchorRef={orderAnchorRef}
|
|
||||||
onMarketSelect={setSelectedMarket}
|
|
||||||
showSymbolField
|
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
||||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
|
||||||
/>
|
|
||||||
<TradeOrderPanel
|
|
||||||
side="sell"
|
|
||||||
market={selectedMarket}
|
|
||||||
tradePrice={tradePrice}
|
|
||||||
availableCoinQty={posQty}
|
|
||||||
fillRequest={fillSell}
|
|
||||||
onMarketSelect={setSelectedMarket}
|
|
||||||
showSymbolField={false}
|
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
||||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ptd-card ptd-history-card">
|
|
||||||
<div className="ptd-tabs">
|
|
||||||
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
|
|
||||||
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>최근체결</button>
|
|
||||||
</div>
|
|
||||||
{historyTab === 'open' ? (
|
|
||||||
<p className="ptd-muted">미체결 주문 없음 (모의투자 즉시 체결)</p>
|
|
||||||
) : (
|
|
||||||
<table className="ptd-table ptd-table--compact">
|
|
||||||
<thead>
|
|
||||||
<tr><th>시간</th><th>자산</th><th>유형</th><th>가격</th><th>상태</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trades.slice(0, 12).map(t => (
|
|
||||||
<tr key={t.id}>
|
|
||||||
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
|
|
||||||
<td>{coinCode(t.symbol)}</td>
|
|
||||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
|
|
||||||
<td>{fmtKrw(t.price)}</td>
|
|
||||||
<td><span className="ptd-status ptd-status--done">체결</span></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!trades.length && <tr><td colSpan={5} className="ptd-muted">체결 내역 없음</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 서비스 진입 스플래시 — 로그인 또는 게스트 모드로 메인 화면 진입
|
* 서비스 진입 스플래시 — 글래스모피즘 로그인
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { loginUser, type LoginResponse } from '../utils/backendApi';
|
import { loginUser, type LoginResponse } from '../utils/backendApi';
|
||||||
|
import SplashChartBackground from './splash/SplashChartBackground';
|
||||||
|
import '../styles/splashScreen.css';
|
||||||
|
|
||||||
const DEFAULT_USERNAME = 'admin';
|
const DEFAULT_USERNAME = 'admin';
|
||||||
const DEFAULT_PASSWORD = 'admin';
|
const DEFAULT_PASSWORD = 'admin';
|
||||||
|
const APP_VERSION = '1.0.3';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLoginSuccess: (res: LoginResponse) => void;
|
onLoginSuccess: (res: LoginResponse) => void;
|
||||||
@@ -35,118 +38,50 @@ const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="splash">
|
<div className="splash">
|
||||||
<div className="splash-bg" aria-hidden />
|
<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">
|
<div className="splash-chart-layer" aria-hidden>
|
||||||
<defs>
|
<SplashChartBackground />
|
||||||
<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>
|
</div>
|
||||||
|
<div className="splash-chart-fade" aria-hidden />
|
||||||
|
|
||||||
<div className="splash-inner">
|
<div className="splash-center">
|
||||||
<header className="splash-brand">
|
<div className="splash-glass">
|
||||||
<div className="splash-logo">
|
<h1 className="splash-brand">GoldenChart</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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}>
|
<form className="splash-form" onSubmit={submitLogin}>
|
||||||
<div className="splash-form-row">
|
<input
|
||||||
<label className="splash-field">
|
className="splash-input"
|
||||||
<span>아이디</span>
|
type="text"
|
||||||
<input
|
autoComplete="username"
|
||||||
type="text"
|
value={username}
|
||||||
autoComplete="username"
|
onChange={e => setUsername(e.target.value)}
|
||||||
value={username}
|
disabled={loading}
|
||||||
onChange={e => setUsername(e.target.value)}
|
placeholder="ID"
|
||||||
disabled={loading}
|
/>
|
||||||
placeholder="admin"
|
<input
|
||||||
/>
|
className="splash-input"
|
||||||
</label>
|
type="password"
|
||||||
<label className="splash-field">
|
autoComplete="current-password"
|
||||||
<span>비밀번호</span>
|
value={password}
|
||||||
<input
|
onChange={e => setPassword(e.target.value)}
|
||||||
type="text"
|
disabled={loading}
|
||||||
autoComplete="off"
|
placeholder="••••••••"
|
||||||
className="splash-field--visible"
|
/>
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
placeholder="••••••"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{error && <p className="splash-error">{error}</p>}
|
{error && <p className="splash-error">{error}</p>}
|
||||||
<div className="splash-actions">
|
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||||
<button
|
{loading ? '로그인 중…' : '로그인'}
|
||||||
type="submit"
|
</button>
|
||||||
className="splash-btn splash-btn--primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? '로그인 중…' : '로그인'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="splash-btn splash-btn--guest"
|
|
||||||
onClick={onGuest}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
게스트 모드
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<p className="splash-footnote">
|
|
||||||
기본 관리자: <code>admin</code> / <code>admin</code> · 권한은 설정 → 관리자 설정에서 변경
|
<p className="splash-version">
|
||||||
|
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="splash-footer">
|
<button type="button" className="splash-guest-link" onClick={onGuest} disabled={loading}>
|
||||||
<span>GoldenChart Trading Platform</span>
|
게스트로 체험하기
|
||||||
</footer>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
||||||
const [paletteSearch, setPaletteSearch] = useState('');
|
const [paletteSearch, setPaletteSearch] = useState('');
|
||||||
|
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
|
||||||
const [stratName, setStratName] = useState('');
|
const [stratName, setStratName] = useState('');
|
||||||
const [stratDesc, setStratDesc] = useState('');
|
const [stratDesc, setStratDesc] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -575,6 +576,13 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
const match = (label: string, desc?: string) =>
|
const match = (label: string, desc?: string) =>
|
||||||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
!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 (
|
return (
|
||||||
<div className={`se-page se-page--${theme}`}>
|
<div className={`se-page se-page--${theme}`}>
|
||||||
{saveToast && (
|
{saveToast && (
|
||||||
@@ -683,7 +691,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
setDeleteOpen(true);
|
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
|
<path
|
||||||
fill="currentColor"
|
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"
|
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
|
||||||
@@ -710,8 +718,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
onPointerDown={onLeftSplitter}
|
onPointerDown={onLeftSplitter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="se-center">
|
<div className="se-main">
|
||||||
<div className="se-center-work">
|
<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-center-head">
|
||||||
<div className="se-signal-tabs">
|
<div className="se-signal-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -801,13 +812,16 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
def={DEF}
|
def={DEF}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="se-right">
|
<aside className="se-right">
|
||||||
|
<div className="se-palette-panel">
|
||||||
<div className="se-right-tabs">
|
<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 === '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>
|
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="se-right-body">
|
||||||
{rightTab === 'indicators' && (
|
{rightTab === 'indicators' && (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -820,7 +834,13 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
<h3>논리 연산</h3>
|
<h3>논리 연산</h3>
|
||||||
<div className="se-palette-grid se-palette-grid--3">
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
{operators.map(op => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,6 +852,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
key={item.value}
|
key={item.value}
|
||||||
{...item}
|
{...item}
|
||||||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
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)}
|
onAdd={() => applyPalette(item.type, item.value, item.label)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -849,6 +871,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
desc={item.desc}
|
desc={item.desc}
|
||||||
color={item.color}
|
color={item.color}
|
||||||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
period={getIndicatorPeriodLabel(item.value, DEF)}
|
||||||
|
selected={isPaletteSelected('indicator', item.value)}
|
||||||
|
onSelect={() => selectPalette('indicator', item.value)}
|
||||||
onAdd={() => applyPalette('indicator', item.value, item.label)}
|
onAdd={() => applyPalette('indicator', item.value, item.label)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -869,7 +893,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveOpen && (
|
{saveOpen && (
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Theme } from '../../types';
|
||||||
|
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
|
||||||
|
import '../../styles/strategyEditorTheme.css';
|
||||||
|
import '../../styles/builderPageShell.css';
|
||||||
|
|
||||||
|
const LEFT_MIN = 220;
|
||||||
|
const LEFT_MAX = 520;
|
||||||
|
const LEFT_DEFAULT = 280;
|
||||||
|
const FOOTER_MIN = 88;
|
||||||
|
const FOOTER_MAX = 420;
|
||||||
|
const FOOTER_DEFAULT = 140;
|
||||||
|
|
||||||
|
export interface BuilderPageShellProps {
|
||||||
|
theme: Theme;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
headerActions?: React.ReactNode;
|
||||||
|
banner?: React.ReactNode;
|
||||||
|
leftTitle?: string;
|
||||||
|
leftTabs?: React.ReactNode;
|
||||||
|
leftActions?: React.ReactNode;
|
||||||
|
left: React.ReactNode;
|
||||||
|
centerHead?: React.ReactNode;
|
||||||
|
center: React.ReactNode;
|
||||||
|
rightTitle?: string;
|
||||||
|
rightTabs?: React.ReactNode;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
footerLabel?: string;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
leftStorageKey?: string;
|
||||||
|
footerStorageKey?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuilderPageShell({
|
||||||
|
theme,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
headerActions,
|
||||||
|
banner,
|
||||||
|
leftTitle,
|
||||||
|
leftTabs,
|
||||||
|
leftActions,
|
||||||
|
left,
|
||||||
|
centerHead,
|
||||||
|
center,
|
||||||
|
rightTitle,
|
||||||
|
rightTabs,
|
||||||
|
right,
|
||||||
|
footerLabel,
|
||||||
|
footer,
|
||||||
|
leftStorageKey = 'bps-left-width',
|
||||||
|
footerStorageKey = 'bps-footer-height',
|
||||||
|
loading = false,
|
||||||
|
loadingText = '로딩 중…',
|
||||||
|
}: BuilderPageShellProps) {
|
||||||
|
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, LEFT_DEFAULT));
|
||||||
|
const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
|
||||||
|
const leftWidthRef = useRef(leftWidth);
|
||||||
|
const footerHeightRef = useRef(footerHeight);
|
||||||
|
leftWidthRef.current = leftWidth;
|
||||||
|
footerHeightRef.current = footerHeight;
|
||||||
|
|
||||||
|
const onLeftSplitter = usePanelResize(
|
||||||
|
'vertical',
|
||||||
|
setLeftWidth,
|
||||||
|
() => leftWidthRef.current,
|
||||||
|
LEFT_MIN,
|
||||||
|
LEFT_MAX,
|
||||||
|
v => storeSize(leftStorageKey, v),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFooterSplitter = usePanelResize(
|
||||||
|
'horizontal',
|
||||||
|
setFooterHeight,
|
||||||
|
() => footerHeightRef.current,
|
||||||
|
FOOTER_MIN,
|
||||||
|
FOOTER_MAX,
|
||||||
|
v => storeSize(footerStorageKey, v),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bodyStyle = useMemo(() => ({
|
||||||
|
'--bps-footer-height': `${footerHeight}px`,
|
||||||
|
}) as React.CSSProperties, [footerHeight]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={`bps-page se-page se-page--${theme}`}>
|
||||||
|
<header className="bps-header">
|
||||||
|
<div className="bps-header-left">
|
||||||
|
<h1 className="bps-title">{title}</h1>
|
||||||
|
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="bps-loading">{loadingText}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bps-page se-page se-page--${theme}`}>
|
||||||
|
<header className="bps-header">
|
||||||
|
<div className="bps-header-left">
|
||||||
|
<h1 className="bps-title">{title}</h1>
|
||||||
|
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
{headerActions && <div className="bps-header-actions">{headerActions}</div>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{banner}
|
||||||
|
|
||||||
|
<div className="bps-body" style={bodyStyle}>
|
||||||
|
<aside className="bps-left" style={{ width: leftWidth }}>
|
||||||
|
<div className="bps-panel">
|
||||||
|
{leftTabs ? (
|
||||||
|
<>
|
||||||
|
{leftTabs}
|
||||||
|
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bps-panel-head">
|
||||||
|
<h2 className="bps-panel-title">{leftTitle}</h2>
|
||||||
|
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="bps-panel-body">{left}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="좌측 패널 너비 조절"
|
||||||
|
onPointerDown={onLeftSplitter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bps-main">
|
||||||
|
<div className="bps-main-row">
|
||||||
|
<main className="bps-center">
|
||||||
|
<div className="bps-center-work">
|
||||||
|
{centerHead && <div className="bps-center-head">{centerHead}</div>}
|
||||||
|
<div className="bps-center-content">{center}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="bps-splitter bps-splitter--h se-splitter se-splitter--h"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="하단 패널 높이 조절"
|
||||||
|
onPointerDown={onFooterSplitter}
|
||||||
|
/>
|
||||||
|
<footer className="bps-footer" style={{ height: footerHeight }}>
|
||||||
|
{footerLabel && <div className="bps-footer-label">{footerLabel}</div>}
|
||||||
|
<div className="bps-footer-body">{footer}</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{right && (
|
||||||
|
<aside className="bps-right">
|
||||||
|
{rightTabs ?? (rightTitle ? (
|
||||||
|
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
|
||||||
|
<h2 className="bps-panel-title">{rightTitle}</h2>
|
||||||
|
</div>
|
||||||
|
) : null)}
|
||||||
|
<div className="bps-right-body">{right}</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { Theme, Timeframe } from '../../types';
|
||||||
|
import PaperMiniChart from './PaperMiniChart';
|
||||||
|
import PaperIndicatorPanel from './PaperIndicatorPanel';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
market: string;
|
||||||
|
theme?: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모의투자 중앙 — 상단 캔들 + 하단 보조지표 (실시간 차트 레이아웃) */
|
||||||
|
const PaperAnalysisChart: React.FC<Props> = ({ market, theme = 'dark' }) => {
|
||||||
|
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ptd-analysis-chart">
|
||||||
|
<div className="ptd-main-pane">
|
||||||
|
<PaperMiniChart
|
||||||
|
market={market}
|
||||||
|
theme={theme}
|
||||||
|
timeframe={timeframe}
|
||||||
|
onTimeframeChange={setTimeframe}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ptd-sub-panes">
|
||||||
|
<PaperIndicatorPanel market={market} timeframe={timeframe} stacked />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaperAnalysisChart;
|
||||||
@@ -7,6 +7,8 @@ interface Props {
|
|||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
/** split 패널용 — 매도/매수 호가만 */
|
||||||
|
section?: 'all' | 'asks' | 'bids';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPrice(p: number): string {
|
function fmtPrice(p: number): string {
|
||||||
@@ -26,6 +28,7 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
|
|||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
depth = 8,
|
depth = 8,
|
||||||
|
section = 'all',
|
||||||
}) => {
|
}) => {
|
||||||
const { orderbook } = useUpbitOrderbook(market);
|
const { orderbook } = useUpbitOrderbook(market);
|
||||||
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
|
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 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 (
|
return (
|
||||||
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
|
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}${section !== 'all' ? ' ptd-ob--section' : ''}`}>
|
||||||
{!hideHeader && <div className="ptd-ob-head">호가 (Depth)</div>}
|
{!hideHeader && section === 'all' && <div className="ptd-ob-head">호가 (Depth)</div>}
|
||||||
<div className="ptd-ob-body">
|
<div className="ptd-ob-body">
|
||||||
{asks.map(a => (
|
{showAsks && asks.map(a => (
|
||||||
<button
|
<button
|
||||||
key={`a-${a.price}`}
|
key={`a-${a.price}`}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -54,8 +61,11 @@ const PaperCompactOrderbook: React.FC<Props> = memo(({
|
|||||||
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
|
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
|
{showMid && section !== 'all' && (
|
||||||
{bids.map(b => (
|
<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
|
<button
|
||||||
key={`b-${b.price}`}
|
key={`b-${b.price}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ function sparkPath(values: number[], w: number, h: number, min?: number, max?: n
|
|||||||
interface Props {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
timeframe?: Timeframe;
|
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 [rsi, setRsi] = React.useState<number[]>([]);
|
||||||
const [macd, setMacd] = React.useState<number[]>([]);
|
const [macd, setMacd] = React.useState<number[]>([]);
|
||||||
const [cci, setCci] = 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);
|
const bars = await fetchUpbitCandles(market, timeframe, 120);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const closes = bars.map(b => b.close);
|
const closes = bars.map(b => b.close);
|
||||||
setRsi(computeRsi(closes).slice(-40));
|
setRsi(computeRsi(closes).slice(-60));
|
||||||
const ema = (arr: number[], p: number) => {
|
const ema = (arr: number[], p: number) => {
|
||||||
const k = 2 / (p + 1);
|
const k = 2 / (p + 1);
|
||||||
let v = arr[0];
|
let v = arr[0];
|
||||||
@@ -62,7 +64,7 @@ const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
|||||||
};
|
};
|
||||||
const e12 = ema(closes, 12);
|
const e12 = ema(closes, 12);
|
||||||
const e26 = ema(closes, 26);
|
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 tp = bars.map(b => (b.high + b.low + b.close) / 3);
|
||||||
const cciVals: number[] = [];
|
const cciVals: number[] = [];
|
||||||
for (let i = 20; i < tp.length; i++) {
|
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;
|
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));
|
cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
|
||||||
}
|
}
|
||||||
setCci(cciVals.slice(-40));
|
setCci(cciVals.slice(-60));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [market, timeframe]);
|
}, [market, timeframe]);
|
||||||
|
|
||||||
const panels = useMemo(() => [
|
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: '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]);
|
], [rsi, macd, cci]);
|
||||||
|
|
||||||
|
const rootClass = stacked ? 'ptd-ind-stack' : 'ptd-indicators';
|
||||||
|
const itemClass = stacked ? 'ptd-ind-row' : 'ptd-ind-panel';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ptd-indicators">
|
<div className={rootClass}>
|
||||||
{panels.map(p => (
|
{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>
|
<div className="ptd-ind-label">{p.label}</div>
|
||||||
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
|
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
|
||||||
{p.ref?.map((r, i) => {
|
{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;
|
market: string;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
wsLabel?: string;
|
wsLabel?: string;
|
||||||
|
timeframe?: Timeframe;
|
||||||
|
onTimeframeChange?: (tf: Timeframe) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readChartTheme(container: HTMLElement | null) {
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | 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 [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const applyTheme = useCallback((chart: IChartApi, series: ISeriesApi<'Candlestick'>) => {
|
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;
|
desc?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
period?: string;
|
period?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
onAdd: () => 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 onDragStart = (e: React.DragEvent) => {
|
||||||
const payload: PaletteDragPayload = { type, value, label };
|
const payload: PaletteDragPayload = { type, value, label };
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
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}
|
onDragStart={onDragStart}
|
||||||
onClick={onAdd}
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onAdd(); }}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="se-palette-card-head">
|
<div className="se-palette-card-head">
|
||||||
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
<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 {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -54,6 +54,11 @@ import { StrategyFlowEdge } from './StrategyFlowEdge';
|
|||||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||||
import { getMinimapColors } from './minimapTheme';
|
import { getMinimapColors } from './minimapTheme';
|
||||||
|
import {
|
||||||
|
loadCanvasInteractionMode,
|
||||||
|
saveCanvasInteractionMode,
|
||||||
|
type StrategyCanvasInteractionMode,
|
||||||
|
} from '../../utils/strategyCanvasInteractionStorage';
|
||||||
import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage';
|
import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage';
|
||||||
|
|
||||||
export type FlowLayoutSeed = {
|
export type FlowLayoutSeed = {
|
||||||
@@ -190,6 +195,14 @@ function StrategyEditorCanvasInner({
|
|||||||
const structureKeyRef = useRef<string | null>(null);
|
const structureKeyRef = useRef<string | null>(null);
|
||||||
const orphanKeyRef = useRef<string | null>(null);
|
const orphanKeyRef = useRef<string | null>(null);
|
||||||
const layoutEmitTimerRef = useRef<number | 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 [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
@@ -839,7 +852,7 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab}`}
|
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@@ -865,9 +878,9 @@ function StrategyEditorCanvasInner({
|
|||||||
nodesDraggable
|
nodesDraggable
|
||||||
nodesConnectable
|
nodesConnectable
|
||||||
elementsSelectable
|
elementsSelectable
|
||||||
selectionOnDrag
|
selectionOnDrag={interactionMode === 'select'}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
panOnDrag={[1, 2]}
|
panOnDrag={interactionMode === 'pan' ? true : [1, 2]}
|
||||||
selectNodesOnDrag={false}
|
selectNodesOnDrag={false}
|
||||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||||
deleteKeyCode={null}
|
deleteKeyCode={null}
|
||||||
@@ -893,8 +906,28 @@ function StrategyEditorCanvasInner({
|
|||||||
/>
|
/>
|
||||||
<Panel position="top-left" className="se-canvas-toolbar">
|
<Panel position="top-left" className="se-canvas-toolbar">
|
||||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
<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">
|
<span className="se-canvas-toolbar-hint">
|
||||||
다중 선택 × 일괄삭제 · 연결선 × 끊기 · Del 삭제
|
{interactionMode === 'pan'
|
||||||
|
? '빈 영역 드래그 → 그래프 이동 · 노드 드래그 → 위치 변경'
|
||||||
|
: '빈 영역 드래그 → 다중 선택 · Del 삭제 · 연결선 × 끊기'}
|
||||||
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
|
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* 백테스팅 대시보드 (btd-*) — 앱 테마 변수 + 금색 포인트 */
|
/* 백테스팅 대시보드 (btd-*) — BuilderPageShell 내부 콘텐츠 스타일 */
|
||||||
|
|
||||||
.btd-page {
|
.bps-page,
|
||||||
|
.se-page {
|
||||||
--btd-gold: #c9a227;
|
--btd-gold: #c9a227;
|
||||||
--btd-gold-light: #e8c547;
|
--btd-gold-light: #e8c547;
|
||||||
--btd-gold-dark: #b8860b;
|
--btd-gold-dark: #b8860b;
|
||||||
@@ -8,105 +9,109 @@
|
|||||||
--btd-gold-border-strong: rgba(201, 162, 39, 0.35);
|
--btd-gold-border-strong: rgba(201, 162, 39, 0.35);
|
||||||
--btd-gold-bg: rgba(201, 162, 39, 0.08);
|
--btd-gold-bg: rgba(201, 162, 39, 0.08);
|
||||||
--btd-gold-bg-hover: rgba(201, 162, 39, 0.14);
|
--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,
|
.se-page--light {
|
||||||
html.theme-light .btd-page {
|
|
||||||
--btd-gold-border: rgba(184, 134, 11, 0.28);
|
--btd-gold-border: rgba(184, 134, 11, 0.28);
|
||||||
--btd-gold-border-strong: rgba(184, 134, 11, 0.45);
|
--btd-gold-border-strong: rgba(184, 134, 11, 0.45);
|
||||||
--btd-gold-bg: rgba(201, 162, 39, 0.1);
|
--btd-gold-bg: rgba(201, 162, 39, 0.1);
|
||||||
--btd-gold-bg-hover: rgba(201, 162, 39, 0.16);
|
--btd-gold-bg-hover: rgba(201, 162, 39, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 좌측 타임라인 사이드바 (~23%) ── */
|
.bps-center-content .btd-empty span { font-size: 48px; opacity: 0.35; }
|
||||||
.btd-sidebar {
|
.btd-sidebar-hint { font-size: 0.72rem; opacity: 0.75; line-height: 1.6; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btd-sidebar-head {
|
.bps-panel-body .btd-timeline {
|
||||||
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 {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
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 {
|
.btd-timeline-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 28px;
|
left: 28px;
|
||||||
@@ -207,39 +212,6 @@ html.theme-light .btd-page {
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
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 {
|
.btd-section-head {
|
||||||
display: flex;
|
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;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg);
|
gap: 0;
|
||||||
color: var(--text);
|
padding: 0;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ptd-topbar {
|
.bps-right-body .ptd-right-body > * {
|
||||||
display: flex;
|
flex: 1;
|
||||||
align-items: center;
|
min-height: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ptd-metrics {
|
.bps-right-body .ptd-right-body > .ptd-split-panel {
|
||||||
margin-bottom: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.ptd-metrics-grid {
|
|
||||||
|
.bps-footer .ptd-metrics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
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 {
|
.ptd-metric {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -78,28 +307,6 @@
|
|||||||
.ptd-metric--up .ptd-metric-value { color: #22c55e; }
|
.ptd-metric--up .ptd-metric-value { color: #22c55e; }
|
||||||
.ptd-metric--down .ptd-metric-value { color: #ef4444; }
|
.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 {
|
.ptd-card {
|
||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -300,23 +507,30 @@
|
|||||||
.ptd-right-body {
|
.ptd-right-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
overflow-x: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
padding: 8px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.ptd-right-body > .ptd-ob--fill {
|
.ptd-right-body > .ptd-ob--fill {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: -8px;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ptd-order-card { flex-shrink: 0; border: none; background: transparent; }
|
.ptd-order-card {
|
||||||
.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; }
|
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-panel { padding: 4px 2px; background: transparent; }
|
||||||
.ptd-order-card .top-field { margin-bottom: 4px; }
|
.ptd-order-card .top-field { margin-bottom: 4px; }
|
||||||
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
|
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
|
||||||
@@ -397,6 +611,23 @@
|
|||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
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-wrap,
|
||||||
.ptd-history-card .ptd-table {
|
.ptd-history-card .ptd-table {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -425,16 +656,3 @@
|
|||||||
}
|
}
|
||||||
.ptd-status--done { background: rgba(34,197,94,0.15); color: #22c55e; }
|
.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;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
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 {
|
.se-splitter {
|
||||||
@@ -157,7 +179,7 @@
|
|||||||
}
|
}
|
||||||
.se-splitter--h {
|
.se-splitter--h {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
margin: 0 10px;
|
margin: 0 8px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -170,8 +192,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
align-self: stretch;
|
||||||
background: var(--se-panel-shell-bg);
|
background: var(--se-panel-shell-bg);
|
||||||
padding: 10px;
|
padding: 10px 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-strat-panel {
|
.se-strat-panel {
|
||||||
@@ -273,7 +296,7 @@
|
|||||||
.se-strat-name {
|
.se-strat-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--se-text);
|
color: var(--se-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -295,11 +318,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 26px;
|
width: 32px;
|
||||||
height: 26px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--se-text-dim);
|
color: var(--se-text-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -350,7 +373,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
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 {
|
.se-center-work {
|
||||||
@@ -432,10 +468,48 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
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-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-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 {
|
.se-canvas-empty {
|
||||||
background: var(--se-toolbar-bg);
|
background: var(--se-toolbar-bg);
|
||||||
border: 1px dashed var(--se-border);
|
border: 1px dashed var(--se-border);
|
||||||
@@ -764,9 +838,10 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0 10px 10px;
|
margin: 0;
|
||||||
border: 1px solid var(--se-terminal-border);
|
border: none;
|
||||||
border-radius: 10px;
|
border-top: 1px solid var(--se-terminal-border);
|
||||||
|
border-radius: 0;
|
||||||
background: var(--se-terminal-bg);
|
background: var(--se-terminal-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
@@ -834,11 +909,23 @@
|
|||||||
.se-right {
|
.se-right {
|
||||||
flex: 0 0 260px;
|
flex: 0 0 260px;
|
||||||
width: 260px;
|
width: 260px;
|
||||||
border-left: 1px solid var(--se-border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,6 +947,14 @@
|
|||||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
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 {
|
.se-palette-search {
|
||||||
margin: 10px 10px 6px;
|
margin: 10px 10px 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -907,12 +1002,20 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.se-palette-card--selected {
|
||||||
|
outline: 2px solid var(--se-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 논리 연산: AND / OR / NOT ── */
|
/* ── 논리 연산: AND / OR / NOT ── */
|
||||||
.se-palette-card--logic-and {
|
.se-palette-card--logic-and {
|
||||||
border-color: color-mix(in srgb, var(--se-palette-logic-and-accent) 42%, transparent);
|
border-color: color-mix(in srgb, var(--se-palette-logic-and-accent) 42%, transparent);
|
||||||
background: var(--se-palette-logic-and-bg);
|
background: var(--se-palette-logic-and-bg);
|
||||||
box-shadow: inset 3px 0 0 var(--se-palette-logic-and-accent);
|
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 {
|
.se-palette-card--logic-and .se-palette-card-icon {
|
||||||
background: color-mix(in srgb, var(--se-palette-logic-and-accent) 22%, transparent);
|
background: color-mix(in srgb, var(--se-palette-logic-and-accent) 22%, transparent);
|
||||||
color: var(--se-palette-logic-and-accent);
|
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