2335 lines
96 KiB
TypeScript
2335 lines
96 KiB
TypeScript
/**
|
||
* 설정 페이지
|
||
* macOS 시스템 환경설정 스타일 — 좌측 카테고리 목록 + 우측 설정 패널
|
||
*/
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import type { Theme } from '../types';
|
||
import {
|
||
loadBacktestSettings,
|
||
saveLiveStrategySettings,
|
||
type LiveStrategySettingsDto,
|
||
} from '../utils/backendApi';
|
||
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
|
||
import { BacktestSettingsPanel } from './BacktestSettingsPanel';
|
||
import IndicatorMainDefaultsPanel, {
|
||
type IndicatorSaveUiState,
|
||
} from './IndicatorMainDefaultsPanel';
|
||
import type { IndicatorConfig } from '../types';
|
||
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||
import ChartTimeFormatPicker from './ChartTimeFormatPicker';
|
||
import DesktopUpdatePanel from './DesktopUpdatePanel';
|
||
import {
|
||
TRADE_ALERT_SOUND_OPTIONS,
|
||
normalizeTradeAlertSoundId,
|
||
previewTradeAlertSound,
|
||
} from '../utils/tradeAlertSound';
|
||
import {
|
||
TRADE_ALERT_GRID_COL_OPTIONS,
|
||
TRADE_ALERT_POPUP_LAYOUTS,
|
||
TRADE_ALERT_POPUP_POSITIONS,
|
||
normalizeTradeAlertGridCols,
|
||
normalizeTradeAlertPopupLayout,
|
||
normalizeTradeAlertPopupPosition,
|
||
} from '../utils/tradeAlertPopupLayout';
|
||
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
|
||
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
|
||
import LlmSettingsPanel from './settings/LlmSettingsPanel';
|
||
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
|
||
import type { LlmAppSettings } from '../utils/llmSettings';
|
||
import { isTradingSettingsCategory } from '../utils/tradingAccess';
|
||
import {
|
||
CHART_LEGEND_SETTING_ITEMS,
|
||
type ChartLegendVisibility,
|
||
} from '../types/chartLegend';
|
||
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
|
||
import { cloneChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||
import ColorInput from './ColorInput';
|
||
import {
|
||
LINE_STYLE_LABELS,
|
||
LINE_STYLE_OPTIONS,
|
||
LINE_WIDTH_OPTIONS,
|
||
} from '../utils/plotColorUtils';
|
||
import TimezonePicker from './TimezonePicker';
|
||
import AdminPasswordGate from './AdminPasswordGate';
|
||
import AdminSettingsPanel from './AdminSettingsPanel';
|
||
import {
|
||
type SettingsCategoryId,
|
||
settingsCategoryToMenuId,
|
||
canAccessMenu,
|
||
} from '../utils/permissions';
|
||
|
||
interface SettingsPageProps {
|
||
theme: Theme;
|
||
magnetMode?: 'off' | 'weak' | 'strong';
|
||
onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void;
|
||
btAutoPopup?: boolean;
|
||
onBtAutoPopup?: (v: boolean) => void;
|
||
btShowPrice?: boolean;
|
||
onBtShowPrice?: (v: boolean) => void;
|
||
/** 실시간 전략 체크 — 현재 차트 종목 */
|
||
liveMarket?: string;
|
||
/** 실시간 전략 체크 — 선택 가능한 전략 목록 */
|
||
liveStrategies?: { id: number; name: string }[];
|
||
/** 마커 초기화 콜백 */
|
||
onClearLiveMarkers?: () => void;
|
||
/** 매매 알림 팝업 ON/OFF */
|
||
tradeAlertPopup?: boolean;
|
||
onTradeAlertPopup?: (v: boolean) => void;
|
||
/** 알림 설정 탭 */
|
||
tradeAlertSoundEnabled?: boolean;
|
||
onTradeAlertSoundEnabled?: (v: boolean) => void;
|
||
tradeAlertSound?: string;
|
||
onTradeAlertSound?: (v: string) => void;
|
||
tradeAlertPopupPosition?: string;
|
||
onTradeAlertPopupPosition?: (v: string) => void;
|
||
tradeAlertPopupLayout?: string;
|
||
onTradeAlertPopupLayout?: (v: string) => void;
|
||
tradeAlertPopupGridCols?: number;
|
||
onTradeAlertPopupGridCols?: (v: number) => void;
|
||
liveSettings?: LiveStrategySettingsDto;
|
||
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||
watchlistCount?: number;
|
||
monitoredMarkets?: string[];
|
||
/** 가상투자 세션 실행 중 — 전략 설정은 가상투자 화면에서 */
|
||
virtualDriven?: boolean;
|
||
/** Main 탭 보조지표 기본 파라미터 (DB) */
|
||
getIndicatorParams?: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||
saveIndicatorParams?: (type: string, params: Record<string, number | string | boolean>) => void;
|
||
getIndicatorVisual?: (type: string, defaultPlots?: PlotDef[], defaultHlines?: HLineDef[]) => {
|
||
plots: PlotDef[];
|
||
hlines: HLineDef[];
|
||
cloudColors?: IchimokuCloudColors;
|
||
};
|
||
saveIndicatorVisual?: (
|
||
type: string,
|
||
plots?: PlotDef[],
|
||
hlines?: HLineDef[],
|
||
cloudColors?: IchimokuCloudColors,
|
||
) => void;
|
||
/** 설정 화면 — 보조지표 기본값 일괄 DB 저장 */
|
||
saveAllIndicatorDefaults?: (configs: Record<string, IndicatorConfig>) => Promise<void>;
|
||
/** 보조지표 DB 캐시 갱신 revision */
|
||
indicatorSettingsRevision?: number;
|
||
/** 설정 화면에서 차트 on/off 연동 */
|
||
chartIndicators?: IndicatorConfig[];
|
||
onAddIndicatorToChart?: (type: string, config?: IndicatorConfig) => void;
|
||
onRemoveIndicatorFromChart?: (type: string) => void;
|
||
/** 보조지표 설정 목록 순서 변경 → 차트 pane 순서 */
|
||
onIndicatorListOrderChange?: (orderedTypes: string[]) => void;
|
||
chartCandleAreaPriceLabels?: boolean;
|
||
onChartCandleAreaPriceLabels?: (v: boolean) => void;
|
||
chartSeriesPriceLabels?: boolean;
|
||
onChartSeriesPriceLabels?: (v: boolean) => void;
|
||
chartVolumeVisible?: boolean;
|
||
onChartVolumeVisible?: (v: boolean) => void;
|
||
chartLiveReceiveHighlight?: boolean;
|
||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||
chartLegendOptions?: ChartLegendVisibility;
|
||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||
chartCrosshairInfoVisible?: boolean;
|
||
onChartCrosshairInfoVisible?: (v: boolean) => void;
|
||
chartCrosshairMarkersVisible?: boolean;
|
||
onChartCrosshairMarkersVisible?: (v: boolean) => void;
|
||
chartHoverToolbarVisible?: boolean;
|
||
onChartHoverToolbarVisible?: (v: boolean) => void;
|
||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||
paperTradingEnabled?: boolean;
|
||
onPaperTradingEnabled?: (v: boolean) => void;
|
||
paperInitialCapital?: number;
|
||
onPaperInitialCapital?: (v: number) => void;
|
||
paperFeeRatePct?: number;
|
||
onPaperFeeRatePct?: (v: number) => void;
|
||
paperSlippagePct?: number;
|
||
onPaperSlippagePct?: (v: number) => void;
|
||
paperMinOrderKrw?: number;
|
||
onPaperMinOrderKrw?: (v: number) => void;
|
||
paperAutoTradeEnabled?: boolean;
|
||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||
paperAutoTradeBudgetPct?: number;
|
||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
|
||
onPaperOrderFillMode?: (v: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL') => void;
|
||
paperOrderAutoCancelPct?: number;
|
||
onPaperOrderAutoCancelPct?: (v: number) => void;
|
||
virtualTargetMaxCount?: number;
|
||
onVirtualTargetMaxCount?: (v: number) => void;
|
||
onPaperAccountReset?: () => void;
|
||
tradingMode?: string;
|
||
onTradingMode?: (v: string) => void;
|
||
liveAutoTradeEnabled?: boolean;
|
||
onLiveAutoTradeEnabled?: (v: boolean) => void;
|
||
hasUpbitKeys?: boolean;
|
||
upbitAccessKeyMasked?: string | null;
|
||
onUpbitKeys?: (access: string, secret: string) => void;
|
||
chartRealtimeSource?: string;
|
||
onChartRealtimeSource?: (v: string) => void;
|
||
liveAutoTradeBudgetPct?: number;
|
||
onLiveAutoTradeBudgetPct?: (v: number) => void;
|
||
fcmPushEnabled?: boolean;
|
||
onFcmPushEnabled?: (v: boolean) => void;
|
||
onFcmTest?: () => void;
|
||
displayTimezone?: string;
|
||
onDisplayTimezoneChange?: (tz: string) => void;
|
||
chartTimeFormat?: string;
|
||
onChartTimeFormatChange?: (format: string) => void;
|
||
tradeAlertTimeFormat?: string;
|
||
onTradeAlertTimeFormatChange?: (format: string) => void;
|
||
menuPermissions?: Record<string, boolean>;
|
||
verificationIssueNotify?: boolean;
|
||
onVerificationIssueNotify?: (v: boolean) => void;
|
||
trendSearchSettings?: TrendSearchAppSettings;
|
||
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
|
||
llmSettings?: LlmAppSettings;
|
||
onLlmSettingsChange?: (s: LlmAppSettings) => void;
|
||
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
|
||
initialCategory?: SettingsCategoryId;
|
||
/** 정식 로그인 여부 (게스트는 매매·전략 설정 탭 차단) */
|
||
isFormalLogin?: boolean;
|
||
onRequireFormalLogin?: () => void;
|
||
}
|
||
|
||
// ── 카테고리 정의 ────────────────────────────────────────────────────────────
|
||
type CategoryId = SettingsCategoryId;
|
||
|
||
interface Category {
|
||
id: CategoryId;
|
||
label: string;
|
||
icon: React.ReactNode;
|
||
desc: string;
|
||
}
|
||
|
||
const IcGeneral = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="3"/>
|
||
<path d="M11 2v2M11 18v2M2 11h2M18 11h2M4.93 4.93l1.41 1.41M15.66 15.66l1.41 1.41M4.93 17.07l1.41-1.41M15.66 6.34l1.41-1.41"/>
|
||
</svg>
|
||
);
|
||
const IcChart = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="2,16 7,10 11,13 16,6 20,6"/>
|
||
<line x1="4" y1="19" x2="4" y2="13"/>
|
||
<line x1="8.5" y1="19" x2="8.5" y2="15.5"/>
|
||
<line x1="13" y1="19" x2="13" y2="10"/>
|
||
<line x1="17.5" y1="19" x2="17.5" y2="6"/>
|
||
</svg>
|
||
);
|
||
const IcStrategy = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="9"/>
|
||
<circle cx="11" cy="11" r="3.5"/>
|
||
<line x1="11" y1="2" x2="11" y2="7.5"/>
|
||
<line x1="11" y1="14.5" x2="11" y2="20"/>
|
||
<line x1="2" y1="11" x2="7.5" y2="11"/>
|
||
<line x1="14.5" y1="11" x2="20" y2="11"/>
|
||
</svg>
|
||
);
|
||
const IcAlert = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M18 8A7 7 0 0 0 4 8c0 7-3 9-3 9h20s-3-2-3-9"/>
|
||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||
</svg>
|
||
);
|
||
const IcNetwork = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="9"/>
|
||
<path d="M11 2C11 2 7 6 7 11s4 9 4 9M11 2c0 0 4 4 4 9s-4 9-4 9"/>
|
||
<line x1="2" y1="11" x2="20" y2="11"/>
|
||
</svg>
|
||
);
|
||
const IcIndicators = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="2,15 6,9 10,11 14,6 20,4"/>
|
||
<line x1="4" y1="19" x2="4" y2="13"/>
|
||
<line x1="8" y1="19" x2="8" y2="15"/>
|
||
<line x1="12" y1="19" x2="12" y2="10"/>
|
||
<line x1="16" y1="19" x2="16" y2="7"/>
|
||
</svg>
|
||
);
|
||
const IcPaper = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="3" y="5" width="16" height="12" rx="2"/>
|
||
<line x1="7" y1="9" x2="15" y2="9"/>
|
||
<line x1="7" y1="12" x2="12" y2="12"/>
|
||
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
|
||
</svg>
|
||
);
|
||
const IcVirtual = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="2" y="6" width="8" height="10" rx="1.5"/>
|
||
<rect x="12" y="4" width="8" height="12" rx="1.5"/>
|
||
<path d="M10 11h2"/>
|
||
</svg>
|
||
);
|
||
const IcTrading = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="2" y="5" width="16" height="12" rx="2"/>
|
||
<line x1="7" y1="9" x2="15" y2="9"/>
|
||
<line x1="7" y1="12" x2="12" y2="12"/>
|
||
<path d="M16 2l2 2-2 2"/>
|
||
<path d="M6 20l-2-2 2-2"/>
|
||
</svg>
|
||
);
|
||
const IcLive = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="9"/>
|
||
<path d="M8 11h6M11 8v6"/>
|
||
</svg>
|
||
);
|
||
const IcTrendSearch = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="4,17 9,11 13,14 18,6"/>
|
||
<line x1="4" y1="19" x2="18" y2="19"/>
|
||
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/>
|
||
</svg>
|
||
);
|
||
const IcLlm = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 3l1.2 3.6L16 8l-3.8 1.4L11 13l-1.2-3.6L6 8l3.8-1.4L11 3z"/>
|
||
<path d="M17 14l.8 2.2L20 17l-2.2.8L17 20l-.8-2.2L14 17l2.2-.8L17 14z"/>
|
||
<rect x="3" y="15" width="6" height="4" rx="1"/>
|
||
</svg>
|
||
);
|
||
const IcBacktest = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="2,17 6,11 9,14 13,7 16,10 20,4"/>
|
||
<circle cx="6" cy="11" r="1.5" fill="currentColor" stroke="none"/>
|
||
<circle cx="13" cy="7" r="1.5" fill="currentColor" stroke="none"/>
|
||
<line x1="6" y1="11" x2="6" y2="19"/>
|
||
<line x1="13" y1="7" x2="13" y2="19"/>
|
||
</svg>
|
||
);
|
||
|
||
const IcAdmin = () => (
|
||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
|
||
<path d="M4 19c0-3.5 3-6 7-6s7 2.5 7 6"/>
|
||
<path d="M16 4l2 2M18 4l-2 2"/>
|
||
</svg>
|
||
);
|
||
|
||
const ALL_CATEGORIES: Category[] = [
|
||
{ id: 'general', label: '일반 설정', icon: <IcGeneral />, desc: '언어, 테마, 날짜 형식 등 기본 설정' },
|
||
{ id: 'chart', label: '차트 설정', icon: <IcChart />, desc: '캔들 색상, 격자선, 크로스헤어, 가격축 등' },
|
||
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
|
||
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
||
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
|
||
{ id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
|
||
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
|
||
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
|
||
{ id: 'llm', label: 'LLM 설정', icon: <IcLlm />, desc: 'AI 전략 검증용 LLM 서버 주소·포트·모델·타임아웃' },
|
||
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
||
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
||
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
||
];
|
||
|
||
function filterCategories(permissions?: Record<string, boolean>): Category[] {
|
||
return ALL_CATEGORIES.filter(c =>
|
||
canAccessMenu(permissions, settingsCategoryToMenuId(c.id)),
|
||
);
|
||
}
|
||
|
||
// ── 설정 행 공통 컴포넌트 ────────────────────────────────────────────────────
|
||
const SettingRow: React.FC<{
|
||
label: string;
|
||
desc?: string;
|
||
className?: string;
|
||
children: React.ReactNode;
|
||
}> = ({ label, desc, className, children }) => (
|
||
<div className={`stg-row${className ? ` ${className}` : ''}`}>
|
||
<div className="stg-row-label">
|
||
<span className="stg-row-title">{label}</span>
|
||
{desc && <span className="stg-row-desc">{desc}</span>}
|
||
</div>
|
||
<div className="stg-row-ctrl">{children}</div>
|
||
</div>
|
||
);
|
||
|
||
const SettingSection: React.FC<{ title: string; children: React.ReactNode; grid?: boolean }> = ({
|
||
title, children, grid,
|
||
}) => (
|
||
<div className="stg-section">
|
||
<div className="stg-section-title">{title}</div>
|
||
<div className={`stg-section-body${grid ? ' stg-section-body--grid' : ''}`}>{children}</div>
|
||
</div>
|
||
);
|
||
|
||
const StgCard: React.FC<{
|
||
label: string;
|
||
desc?: string;
|
||
full?: boolean;
|
||
children: React.ReactNode;
|
||
}> = ({ label, desc, full, children }) => (
|
||
<div className={`stg-card${full ? ' stg-card--full' : ''}`}>
|
||
<div className="stg-card-head">
|
||
<span className="stg-card-label">{label}</span>
|
||
{desc && <span className="stg-card-desc">{desc}</span>}
|
||
</div>
|
||
<div className="stg-card-ctrl">{children}</div>
|
||
</div>
|
||
);
|
||
|
||
// ── 각 카테고리 패널 ─────────────────────────────────────────────────────────
|
||
|
||
interface PaperTradingSettingsPanelProps {
|
||
paperTradingEnabled?: boolean;
|
||
onPaperTradingEnabled?: (v: boolean) => void;
|
||
paperInitialCapital?: number;
|
||
onPaperInitialCapital?: (v: number) => void;
|
||
paperFeeRatePct?: number;
|
||
onPaperFeeRatePct?: (v: number) => void;
|
||
paperSlippagePct?: number;
|
||
onPaperSlippagePct?: (v: number) => void;
|
||
paperMinOrderKrw?: number;
|
||
onPaperMinOrderKrw?: (v: number) => void;
|
||
paperAutoTradeEnabled?: boolean;
|
||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||
paperAutoTradeBudgetPct?: number;
|
||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||
onPaperAccountReset?: () => void;
|
||
}
|
||
|
||
const PaperTradingSettingsPanel: React.FC<PaperTradingSettingsPanelProps> = ({
|
||
paperTradingEnabled = true,
|
||
onPaperTradingEnabled,
|
||
paperInitialCapital = 10_000_000,
|
||
onPaperInitialCapital,
|
||
paperFeeRatePct = 0.05,
|
||
onPaperFeeRatePct,
|
||
paperSlippagePct = 0,
|
||
onPaperSlippagePct,
|
||
paperMinOrderKrw = 5000,
|
||
onPaperMinOrderKrw,
|
||
paperAutoTradeEnabled = false,
|
||
onPaperAutoTradeEnabled,
|
||
paperAutoTradeBudgetPct = 95,
|
||
onPaperAutoTradeBudgetPct,
|
||
onPaperAccountReset,
|
||
}) => (
|
||
<>
|
||
<SettingSection title="모의투자">
|
||
<SettingRow
|
||
label="모의투자 활성화"
|
||
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
<SettingSection title="자동매매">
|
||
<SettingRow
|
||
label="자동매매"
|
||
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="자동 매수 예산 (%)"
|
||
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
|
||
>
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperAutoTradeBudgetPct}
|
||
min={1}
|
||
max={100}
|
||
step={1}
|
||
disabled={!paperAutoTradeEnabled}
|
||
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
<SettingSection title="계좌·비용">
|
||
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperInitialCapital}
|
||
min={100000}
|
||
step={100000}
|
||
onChange={e => onPaperInitialCapital?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="수수료율 (%)" desc="체결 금액 기준 수수료 (업비트 KRW 마켓 기본 0.05% 참고).">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperFeeRatePct}
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
onChange={e => onPaperFeeRatePct?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="슬리피지 (%)" desc="매수는 가격 상승, 매도는 가격 하락으로 체결가를 조정합니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperSlippagePct}
|
||
min={0}
|
||
max={5}
|
||
step={0.01}
|
||
onChange={e => onPaperSlippagePct?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="최소 주문 금액 (KRW)" desc="이 금액 미만 주문은 거부됩니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperMinOrderKrw}
|
||
min={1000}
|
||
step={1000}
|
||
onChange={e => onPaperMinOrderKrw?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
<SettingSection title="계좌 관리">
|
||
<SettingRow
|
||
label="계좌 초기화"
|
||
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
|
||
>
|
||
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
|
||
모의 계좌 초기화
|
||
</button>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
|
||
// ── 통합 매매설정 패널 ────────────────────────────────────────────────────────
|
||
|
||
interface TradingSettingsPanelProps {
|
||
paperTradingEnabled?: boolean;
|
||
onPaperTradingEnabled?: (v: boolean) => void;
|
||
paperInitialCapital?: number;
|
||
onPaperInitialCapital?: (v: number) => void;
|
||
paperFeeRatePct?: number;
|
||
onPaperFeeRatePct?: (v: number) => void;
|
||
paperSlippagePct?: number;
|
||
onPaperSlippagePct?: (v: number) => void;
|
||
paperMinOrderKrw?: number;
|
||
onPaperMinOrderKrw?: (v: number) => void;
|
||
paperAutoTradeEnabled?: boolean;
|
||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||
paperAutoTradeBudgetPct?: number;
|
||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
|
||
onPaperOrderFillMode?: (v: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL') => void;
|
||
paperOrderAutoCancelPct?: number;
|
||
onPaperOrderAutoCancelPct?: (v: number) => void;
|
||
virtualTargetMaxCount?: number;
|
||
onVirtualTargetMaxCount?: (v: number) => void;
|
||
onPaperAccountReset?: () => void;
|
||
}
|
||
|
||
const TradingSettingsPanel: React.FC<TradingSettingsPanelProps> = ({
|
||
paperTradingEnabled = true,
|
||
onPaperTradingEnabled,
|
||
paperInitialCapital = 10_000_000,
|
||
onPaperInitialCapital,
|
||
paperFeeRatePct = 0.05,
|
||
onPaperFeeRatePct,
|
||
paperSlippagePct = 0,
|
||
onPaperSlippagePct,
|
||
paperMinOrderKrw = 5000,
|
||
onPaperMinOrderKrw,
|
||
paperAutoTradeEnabled = false,
|
||
onPaperAutoTradeEnabled,
|
||
paperAutoTradeBudgetPct = 95,
|
||
onPaperAutoTradeBudgetPct,
|
||
paperOrderFillMode = 'LIMIT_ONLY',
|
||
onPaperOrderFillMode,
|
||
paperOrderAutoCancelPct = 1.0,
|
||
onPaperOrderAutoCancelPct,
|
||
virtualTargetMaxCount = 20,
|
||
onVirtualTargetMaxCount,
|
||
onPaperAccountReset,
|
||
}) => (
|
||
<>
|
||
{/* ── 모의투자 ────────────────────────────────────────── */}
|
||
<SettingSection title="모의투자">
|
||
<SettingRow
|
||
label="모의투자 활성화"
|
||
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{/* ── 자동매매 ─────────────────────────────────────────── */}
|
||
<SettingSection title="자동매매">
|
||
<SettingRow
|
||
label="자동매매"
|
||
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="자동 매수 예산 (%)"
|
||
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
|
||
>
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperAutoTradeBudgetPct}
|
||
min={1} max={100} step={1}
|
||
disabled={!paperAutoTradeEnabled}
|
||
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{/* ── 미체결 주문 처리 ─────────────────────────────────── */}
|
||
<SettingSection title="미체결 주문 처리">
|
||
<SettingRow
|
||
label="체결 모드"
|
||
desc="수동·자동매매 시 생성된 미체결 주문이 시세 변동 시 어떻게 처리될지 결정합니다."
|
||
className="stg-row--block"
|
||
>
|
||
<div className="stg-radio-row">
|
||
<label className="stg-radio-option stg-radio-option--cyan">
|
||
<input
|
||
type="radio"
|
||
name="stg-fill-mode"
|
||
value="LIMIT_ONLY"
|
||
checked={paperOrderFillMode === 'LIMIT_ONLY'}
|
||
onChange={() => onPaperOrderFillMode?.('LIMIT_ONLY')}
|
||
/>
|
||
<span className="stg-radio-option-text">
|
||
<strong>A. 지정가 유지 (기본값)</strong>
|
||
<span>매수: 시세 ≤ 주문가일 때만 체결 / 매도: 시세 ≥ 주문가일 때만 체결. 반대 방향으로 가격이 벗어나면 미체결 유지</span>
|
||
</span>
|
||
</label>
|
||
<label className="stg-radio-option stg-radio-option--blue">
|
||
<input
|
||
type="radio"
|
||
name="stg-fill-mode"
|
||
value="NEXT_TICK"
|
||
checked={paperOrderFillMode === 'NEXT_TICK'}
|
||
onChange={() => onPaperOrderFillMode?.('NEXT_TICK')}
|
||
/>
|
||
<span className="stg-radio-option-text">
|
||
<strong>B. 다음 틱 강제 체결</strong>
|
||
<span>주문 생성 직후 첫 번째 시세 틱에서 가격 방향 무관하게 즉시 체결 (시장가와 동일한 효과)</span>
|
||
</span>
|
||
</label>
|
||
<label className="stg-radio-option stg-radio-option--orange">
|
||
<input
|
||
type="radio"
|
||
name="stg-fill-mode"
|
||
value="AUTO_CANCEL"
|
||
checked={paperOrderFillMode === 'AUTO_CANCEL'}
|
||
onChange={() => onPaperOrderFillMode?.('AUTO_CANCEL')}
|
||
/>
|
||
<span className="stg-radio-option-text">
|
||
<strong>C. 허용폭 초과 시 자동 취소</strong>
|
||
<span>주문가 대비 설정한 % 이상 가격이 벗어나면 미체결 주문 자동 취소. 허용폭 내이면 지정가 조건으로 체결</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</SettingRow>
|
||
{paperOrderFillMode === 'AUTO_CANCEL' && (
|
||
<SettingRow label="자동 취소 허용 이탈폭 (%)" desc="주문가 대비 이 % 이상 가격이 벗어나면 미체결 주문이 자동 취소됩니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperOrderAutoCancelPct}
|
||
min={0.1} max={50} step={0.1}
|
||
onChange={e => onPaperOrderAutoCancelPct?.(Number(e.target.value))}
|
||
/>
|
||
<span className="stg-unit">%</span>
|
||
</SettingRow>
|
||
)}
|
||
</SettingSection>
|
||
|
||
{/* ── 계좌·비용 ─────────────────────────────────────────── */}
|
||
<SettingSection title="계좌·비용">
|
||
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperInitialCapital}
|
||
min={100000} step={100000}
|
||
onChange={e => onPaperInitialCapital?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="수수료율 (%)" desc="체결 금액 기준 수수료 (업비트 KRW 마켓 기본 0.05% 참고).">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperFeeRatePct}
|
||
min={0} max={1} step={0.01}
|
||
onChange={e => onPaperFeeRatePct?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="슬리피지 (%)" desc="매수는 가격 상승, 매도는 가격 하락으로 체결가를 조정합니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperSlippagePct}
|
||
min={0} max={5} step={0.01}
|
||
onChange={e => onPaperSlippagePct?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="최소 주문 금액 (KRW)" desc="이 금액 미만 주문은 거부됩니다.">
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={paperMinOrderKrw}
|
||
min={1000} step={1000}
|
||
onChange={e => onPaperMinOrderKrw?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{/* ── 가상매매 ─────────────────────────────────────────── */}
|
||
<SettingSection title="가상매매">
|
||
<SettingRow
|
||
label="투자대상 목록 최대 개수"
|
||
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
|
||
>
|
||
<input
|
||
type="number"
|
||
className="stg-input stg-input--num"
|
||
value={virtualTargetMaxCount}
|
||
min={1} max={100} step={1}
|
||
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{/* ── 계좌 관리 ─────────────────────────────────────────── */}
|
||
<SettingSection title="계좌 관리">
|
||
<SettingRow
|
||
label="계좌 초기화"
|
||
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
|
||
>
|
||
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
|
||
모의 계좌 초기화
|
||
</button>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
|
||
const LiveTradingSettingsPanel: React.FC<{
|
||
tradingMode?: string;
|
||
onTradingMode?: (v: string) => void;
|
||
liveAutoTradeEnabled?: boolean;
|
||
onLiveAutoTradeEnabled?: (v: boolean) => void;
|
||
hasUpbitKeys?: boolean;
|
||
upbitAccessKeyMasked?: string | null;
|
||
onUpbitKeys?: (access: string, secret: string) => void;
|
||
liveAutoTradeBudgetPct?: number;
|
||
onLiveAutoTradeBudgetPct?: (v: number) => void;
|
||
}> = ({
|
||
tradingMode = 'PAPER',
|
||
onTradingMode,
|
||
liveAutoTradeEnabled = false,
|
||
onLiveAutoTradeEnabled,
|
||
hasUpbitKeys = false,
|
||
upbitAccessKeyMasked = null,
|
||
onUpbitKeys,
|
||
liveAutoTradeBudgetPct = 95,
|
||
onLiveAutoTradeBudgetPct,
|
||
}) => {
|
||
const [accessKey, setAccessKey] = useState('');
|
||
const [secretKey, setSecretKey] = useState('');
|
||
|
||
return (
|
||
<>
|
||
<SettingSection title="매매 운영 모드">
|
||
<SettingRow label="실행 대상" desc="모의투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
|
||
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
|
||
<option value="PAPER">모의투자만</option>
|
||
<option value="LIVE">실거래만</option>
|
||
<option value="BOTH">모의 + 실거래 병행</option>
|
||
</select>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
<SettingSection title="실거래 (업비트 Open API)">
|
||
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
|
||
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
|
||
{hasUpbitKeys ? '연결됨' : '미등록'}
|
||
</span>
|
||
</SettingRow>
|
||
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
|
||
<input
|
||
type="text"
|
||
className="stg-input stg-input--sensitive"
|
||
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
|
||
value={accessKey}
|
||
onChange={e => setAccessKey(e.target.value)}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
|
||
<input
|
||
type="text"
|
||
className="stg-input stg-input--sensitive"
|
||
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
|
||
value={secretKey}
|
||
onChange={e => setSecretKey(e.target.value)}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
/>
|
||
</SettingRow>
|
||
{(accessKey.trim() || secretKey.trim()) && (
|
||
<SettingRow label="">
|
||
<button
|
||
type="button"
|
||
className="stg-btn stg-btn--primary"
|
||
onClick={() => {
|
||
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
|
||
setAccessKey('');
|
||
setSecretKey('');
|
||
}}
|
||
>
|
||
API 키 저장
|
||
</button>
|
||
</SettingRow>
|
||
)}
|
||
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
|
||
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
|
||
value={liveAutoTradeBudgetPct}
|
||
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
|
||
<span className="stg-unit">%</span>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ── 일반 설정 패널 ────────────────────────────────────────────────────────────
|
||
|
||
const GeneralPanel: React.FC<{
|
||
displayTimezone: string;
|
||
onDisplayTimezoneChange: (tz: string) => void;
|
||
chartTimeFormat?: string;
|
||
onChartTimeFormatChange?: (format: string) => void;
|
||
tradeAlertTimeFormat?: string;
|
||
onTradeAlertTimeFormatChange?: (format: string) => void;
|
||
verificationIssueNotify?: boolean;
|
||
onVerificationIssueNotify?: (v: boolean) => void;
|
||
showVerificationNotify?: boolean;
|
||
}> = ({
|
||
displayTimezone,
|
||
onDisplayTimezoneChange,
|
||
chartTimeFormat = 'MM-dd HH:mm',
|
||
onChartTimeFormatChange,
|
||
tradeAlertTimeFormat = 'MM-dd HH:mm',
|
||
onTradeAlertTimeFormatChange,
|
||
verificationIssueNotify = true,
|
||
onVerificationIssueNotify,
|
||
showVerificationNotify = true,
|
||
}) => {
|
||
const [lang, setLang] = useState('ko');
|
||
const [startPage, setStart] = useState('chart');
|
||
const [animations, setAnim] = useState(true);
|
||
const [tooltips, setTool] = useState(true);
|
||
|
||
return (
|
||
<>
|
||
<SettingSection title="언어 및 지역">
|
||
<SettingRow label="언어" desc="UI 표시 언어를 선택합니다.">
|
||
<select className="stg-select" value={lang} onChange={e => setLang(e.target.value)}>
|
||
<option value="ko">한국어</option>
|
||
<option value="en">English</option>
|
||
<option value="ja">日本語</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="차트 날짜, 시간형식"
|
||
desc="캔들·보조지표 차트 하단 시간축·크로스헤어에 적용됩니다. 프리셋 선택 또는 직접 입력(토큰: yyyy, yy, MM, dd, HH, mm, ss)이 가능합니다."
|
||
>
|
||
<ChartTimeFormatPicker
|
||
value={chartTimeFormat}
|
||
onChange={fmt => onChartTimeFormatChange?.(fmt)}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="매매 시그널 알림 시간 형식"
|
||
desc="매매 시그널 알림 팝업·상세에 표시되는 캔들·수신 시각 형식입니다. 프리셋 선택 또는 직접 입력이 가능합니다."
|
||
>
|
||
<ChartTimeFormatPicker
|
||
value={tradeAlertTimeFormat}
|
||
onChange={fmt => onTradeAlertTimeFormatChange?.(fmt)}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="시간대" desc="차트·하단 시계·알림에 사용되는 표시 시간대입니다.">
|
||
<TimezonePicker
|
||
variant="select"
|
||
value={displayTimezone}
|
||
onChange={onDisplayTimezoneChange}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{showVerificationNotify && (
|
||
<SettingSection title="검증게시판">
|
||
<SettingRow
|
||
label="검증 이슈 등록 알림"
|
||
desc="검증 이슈가 등록되거나 단계가 변경될 때 팝업 알림을 표시합니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={verificationIssueNotify}
|
||
onChange={e => onVerificationIssueNotify?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
)}
|
||
|
||
<SettingSection title="시작 화면">
|
||
<SettingRow label="시작 페이지" desc="앱 시작 시 표시될 기본 화면입니다.">
|
||
<select className="stg-select" value={startPage} onChange={e => setStart(e.target.value)}>
|
||
<option value="dashboard">대시보드</option>
|
||
<option value="chart">실시간 차트</option>
|
||
<option value="strategy">투자 전략</option>
|
||
<option value="backtest">백테스팅</option>
|
||
</select>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="UI 동작">
|
||
<SettingRow label="애니메이션" desc="화면 전환 및 차트 업데이트 시 애니메이션을 사용합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={animations} onChange={e => setAnim(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="툴팁 표시" desc="버튼 및 지표에 마우스를 올렸을 때 설명을 표시합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={tooltips} onChange={e => setTool(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<DesktopUpdatePanel />
|
||
</>
|
||
);
|
||
};
|
||
|
||
function PaneSeparatorStyleFields({
|
||
style,
|
||
onChange,
|
||
}: {
|
||
style: PaneSeparatorStyle;
|
||
onChange: (next: PaneSeparatorStyle) => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
<SettingRow label="구분선 표시">
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={style.visible}
|
||
onChange={e => onChange({ ...style, visible: e.target.checked })}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
{style.visible && (
|
||
<>
|
||
<SettingRow label="선 색상">
|
||
<ColorInput
|
||
value={style.color}
|
||
onChange={color => onChange({ ...style, color })}
|
||
showCompactNative={false}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label="선 굵기">
|
||
<select
|
||
className="stg-select"
|
||
value={style.width}
|
||
onChange={e => onChange({
|
||
...style,
|
||
width: Number(e.target.value) as PaneSeparatorStyle['width'],
|
||
})}
|
||
>
|
||
{LINE_WIDTH_OPTIONS.map(w => (
|
||
<option key={w} value={w}>{w}px</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label="선 유형">
|
||
<select
|
||
className="stg-select"
|
||
value={style.lineStyle}
|
||
onChange={e => onChange({
|
||
...style,
|
||
lineStyle: e.target.value as PaneSeparatorStyle['lineStyle'],
|
||
})}
|
||
>
|
||
{LINE_STYLE_OPTIONS.map(s => (
|
||
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface ChartPanelProps {
|
||
chartRealtimeSource?: string;
|
||
onChartRealtimeSource?: (v: string) => void;
|
||
magnetMode?: 'off' | 'weak' | 'strong';
|
||
onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void;
|
||
chartCandleAreaPriceLabels?: boolean;
|
||
onChartCandleAreaPriceLabels?: (v: boolean) => void;
|
||
chartSeriesPriceLabels?: boolean;
|
||
onChartSeriesPriceLabels?: (v: boolean) => void;
|
||
chartVolumeVisible?: boolean;
|
||
onChartVolumeVisible?: (v: boolean) => void;
|
||
chartLiveReceiveHighlight?: boolean;
|
||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||
chartLegendOptions?: ChartLegendVisibility;
|
||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||
chartCrosshairInfoVisible?: boolean;
|
||
onChartCrosshairInfoVisible?: (v: boolean) => void;
|
||
chartCrosshairMarkersVisible?: boolean;
|
||
onChartCrosshairMarkersVisible?: (v: boolean) => void;
|
||
chartHoverToolbarVisible?: boolean;
|
||
onChartHoverToolbarVisible?: (v: boolean) => void;
|
||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||
chartTimeFormat?: string;
|
||
onChartTimeFormatChange?: (format: string) => void;
|
||
btShowPrice?: boolean;
|
||
onBtShowPrice?: (v: boolean) => void;
|
||
}
|
||
|
||
const ChartPanel: React.FC<ChartPanelProps> = ({
|
||
chartRealtimeSource = 'BACKEND_STOMP',
|
||
onChartRealtimeSource,
|
||
magnetMode = 'off', onMagnetMode,
|
||
chartCandleAreaPriceLabels = true,
|
||
onChartCandleAreaPriceLabels,
|
||
chartSeriesPriceLabels = true,
|
||
onChartSeriesPriceLabels,
|
||
chartVolumeVisible = true,
|
||
onChartVolumeVisible,
|
||
chartLiveReceiveHighlight = true,
|
||
onChartLiveReceiveHighlight,
|
||
chartLegendOptions,
|
||
onChartLegendOptionsChange,
|
||
chartCrosshairInfoVisible = true,
|
||
onChartCrosshairInfoVisible,
|
||
chartCrosshairMarkersVisible = true,
|
||
onChartCrosshairMarkersVisible,
|
||
chartHoverToolbarVisible = true,
|
||
onChartHoverToolbarVisible,
|
||
chartPaneSeparator,
|
||
onChartPaneSeparatorChange,
|
||
chartTimeFormat = 'MM-dd HH:mm',
|
||
onChartTimeFormatChange,
|
||
btShowPrice = true,
|
||
onBtShowPrice,
|
||
}) => {
|
||
const [upColor, setUp] = useState('#26a69a');
|
||
const [downColor, setDown] = useState('#ef5350');
|
||
const [bgColor, setBg] = useState('#131722');
|
||
const [gridLines, setGrid] = useState(true);
|
||
const [crosshair, setCh] = useState('normal');
|
||
const [priceScale, setPs] = useState('right');
|
||
const [precision, setPrec] = useState('2');
|
||
|
||
return (
|
||
<>
|
||
<SettingSection title="시간축 표시">
|
||
<SettingRow
|
||
label="시간 포맷"
|
||
desc="차트 하단 시간축·크로스헤어에 표시되는 날짜·시간 형식입니다. 프리셋을 선택하거나 직접 입력할 수 있습니다."
|
||
>
|
||
<ChartTimeFormatPicker
|
||
value={chartTimeFormat}
|
||
onChange={fmt => onChartTimeFormatChange?.(fmt)}
|
||
/>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="실시간 차트 데이터">
|
||
<SettingRow label="데이터 소스" desc="Blueprint 권장: 백엔드 STOMP. 직연결은 업비트 WebSocket을 프론트가 직접 수신합니다.">
|
||
<select className="stg-select" value={chartRealtimeSource}
|
||
onChange={e => onChartRealtimeSource?.(e.target.value)}>
|
||
<option value="BACKEND_STOMP">백엔드 STOMP (권장)</option>
|
||
<option value="UPBIT_DIRECT">업비트 직연결 (기존)</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="실시간 수신 음영처리"
|
||
desc="켜면 실시간 데이터 수신 순간 멀티차트·가상투자 카드 아웃라인에 형광 연두색 glow가 표시됩니다. 끄면 아웃라인 음영 효과가 비활성화됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartLiveReceiveHighlight}
|
||
onChange={e => onChartLiveReceiveHighlight?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
<SettingSection title="캔들 색상">
|
||
<SettingRow label="상승 캔들 색상" desc="양봉(상승) 캔들의 기본 색상입니다.">
|
||
<div className="stg-color-row">
|
||
<input type="color" className="stg-color" value={upColor} onChange={e => setUp(e.target.value)} />
|
||
<span className="stg-color-label">{upColor}</span>
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow label="하락 캔들 색상" desc="음봉(하락) 캔들의 기본 색상입니다.">
|
||
<div className="stg-color-row">
|
||
<input type="color" className="stg-color" value={downColor} onChange={e => setDown(e.target.value)} />
|
||
<span className="stg-color-label">{downColor}</span>
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow label="배경 색상" desc="차트 영역의 배경 색상입니다.">
|
||
<div className="stg-color-row">
|
||
<input type="color" className="stg-color" value={bgColor} onChange={e => setBg(e.target.value)} />
|
||
<span className="stg-color-label">{bgColor}</span>
|
||
</div>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="차트 표시 옵션">
|
||
<SettingRow label="격자선 표시" desc="차트 배경에 격자선을 표시합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={gridLines} onChange={e => setGrid(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="거래량 표시" desc="차트 하단에 거래량 바를 표시합니다.">
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartVolumeVisible}
|
||
onChange={e => onChartVolumeVisible?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="크로스헤어 모드" desc="마우스 이동 시 표시되는 십자선 형식입니다.">
|
||
<select className="stg-select" value={crosshair} onChange={e => setCh(e.target.value)}>
|
||
<option value="normal">기본</option>
|
||
<option value="magnet">마그넷</option>
|
||
<option value="hidden">숨기기</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="자석모드 (Magnet Mode)"
|
||
desc="켜면 십자선 커서가 가장 가까운 캔들 고점·저점·시가·종가에 자동으로 달라붙습니다. 정확한 지지/저항 선 작도에 유용합니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={magnetMode !== 'off'}
|
||
onChange={e => onMagnetMode?.(e.target.checked ? 'strong' : 'off')}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="지표 가격축 라벨·설명">
|
||
<div className="stg-subsection-label">
|
||
캔들 차트 영역
|
||
<span className="stg-subsection-desc">캔들·거래량 pane 위에 겹쳐 표시되는 지표(이동평균·일목 등)</span>
|
||
</div>
|
||
<SettingRow
|
||
label="가격축 라벨·설명"
|
||
desc="켜면 오버레이 지표 선마다 우측 가격축에 금액과 설명(전환선, 기준선 등)이 표시됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartCandleAreaPriceLabels}
|
||
onChange={e => onChartCandleAreaPriceLabels?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||
보조지표 영역
|
||
<span className="stg-subsection-desc">캔들 아래 별도 pane에 표시되는 RSI·MACD 등</span>
|
||
</div>
|
||
<SettingRow
|
||
label="가격축 라벨·설명"
|
||
desc="켜면 하단 보조지표 pane의 선마다 우측 가격축에 금액과 설명이 표시됩니다. 끄면 선만 표시됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartSeriesPriceLabels}
|
||
onChange={e => onChartSeriesPriceLabels?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="매매 시그널">
|
||
<SettingRow
|
||
label="매수·매도 금액 표시"
|
||
desc="백테스트·실시간 전략 시그널 마커에 체결 금액을 함께 표시합니다. OFF이면 '매수'·'매도'만 표시합니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={btShowPrice}
|
||
onChange={e => onBtShowPrice?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{chartPaneSeparator && onChartPaneSeparatorChange && (
|
||
<SettingSection title="차트 영역 구분선">
|
||
<div className="stg-subsection-label">
|
||
캔들·보조지표
|
||
<span className="stg-subsection-desc">캔들(·거래량) 영역과 첫 보조지표 pane 사이</span>
|
||
</div>
|
||
<PaneSeparatorStyleFields
|
||
style={chartPaneSeparator.mainToIndicator}
|
||
onChange={mainToIndicator => onChartPaneSeparatorChange(
|
||
cloneChartPaneSeparatorOptions({
|
||
...chartPaneSeparator,
|
||
mainToIndicator,
|
||
}),
|
||
)}
|
||
/>
|
||
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||
보조지표 간
|
||
<span className="stg-subsection-desc">보조지표 pane 사이</span>
|
||
</div>
|
||
<PaneSeparatorStyleFields
|
||
style={chartPaneSeparator.indicatorToIndicator}
|
||
onChange={indicatorToIndicator => onChartPaneSeparatorChange(
|
||
cloneChartPaneSeparatorOptions({
|
||
...chartPaneSeparator,
|
||
indicatorToIndicator,
|
||
}),
|
||
)}
|
||
/>
|
||
</SettingSection>
|
||
)}
|
||
|
||
<SettingSection title="크로스헤어 정보">
|
||
<SettingRow
|
||
label="크로스헤어 정보 표시"
|
||
desc="마우스로 차트를 가리킬 때 표시되는 시가·고가·저가·종가와 보조지표 수치를 켜거나 끕니다. 종목·현재가·보조지표 이름은 유지됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartCrosshairInfoVisible}
|
||
onChange={e => onChartCrosshairInfoVisible?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="교차점 점 표시"
|
||
desc="켜면 세로 크로스헤어가 캔들·보조지표 선과 만나는 위치에 색 점이 표시됩니다. 끄면 크로스헤어 선만 표시됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartCrosshairMarkersVisible}
|
||
onChange={e => onChartCrosshairMarkersVisible?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{chartLegendOptions && onChartLegendOptionsChange && (
|
||
<SettingSection title="상단 정보 표시">
|
||
{CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => (
|
||
<SettingRow key={key} label={label} desc={desc}>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartLegendOptions[key]}
|
||
onChange={e => onChartLegendOptionsChange({ [key]: e.target.checked })}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
))}
|
||
</SettingSection>
|
||
)}
|
||
|
||
<SettingSection title="가격축">
|
||
<SettingRow label="가격축 위치" desc="가격 눈금자의 표시 위치입니다.">
|
||
<select className="stg-select" value={priceScale} onChange={e => setPs(e.target.value)}>
|
||
<option value="right">우측</option>
|
||
<option value="left">좌측</option>
|
||
<option value="both">양쪽</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label="소수점 자리수" desc="가격 표시 시 소수점 이하 자리수입니다.">
|
||
<select className="stg-select" value={precision} onChange={e => setPrec(e.target.value)}>
|
||
<option value="0">0자리</option>
|
||
<option value="2">2자리</option>
|
||
<option value="4">4자리</option>
|
||
<option value="8">8자리</option>
|
||
</select>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="차트 조작 툴바">
|
||
<SettingRow
|
||
label="줌·이동 플로팅 툴바"
|
||
desc="켜면 차트에 마우스를 올렸을 때 하단에 축소(−)·확대(+)·전체 보기·좌우 이동(‹ ›) 버튼이 표시됩니다. 끄면 툴바가 나타나지 않습니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={chartHoverToolbarVisible}
|
||
onChange={e => onChartHoverToolbarVisible?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
};
|
||
|
||
interface StrategyPanelProps {
|
||
liveMarket?: string;
|
||
liveStrategies?: { id: number; name: string }[];
|
||
liveSettings?: LiveStrategySettingsDto;
|
||
watchlistCount?: number;
|
||
monitoredMarkets?: string[];
|
||
virtualDriven?: boolean;
|
||
onClearLiveMarkers?: () => void;
|
||
tradeAlertPopup?: boolean;
|
||
onTradeAlertPopup?: (v: boolean) => void;
|
||
onLiveSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||
}
|
||
|
||
const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||
liveMarket = 'KRW-BTC',
|
||
liveStrategies = [],
|
||
liveSettings: liveSettingsProp,
|
||
watchlistCount = 0,
|
||
monitoredMarkets = [],
|
||
virtualDriven = false,
|
||
onClearLiveMarkers,
|
||
tradeAlertPopup = true,
|
||
onTradeAlertPopup,
|
||
onLiveSettingsChange,
|
||
}) => {
|
||
const [defaultRisk, setRisk] = useState('1');
|
||
const [maxPos, setMax] = useState('5');
|
||
const [trailingStop, setTrail] = useState(false);
|
||
const [reentry, setRe] = useState(false);
|
||
const [backtestFee, setFee] = useState('0.05');
|
||
const [slippage, setSlip] = useState('0.01');
|
||
const [evalMetric, setEval] = useState('sharpe');
|
||
|
||
// ── 실시간 전략 체크 설정 상태 ──────────────────────────────────────────────
|
||
const [liveSettings, setLiveSettings] = useState<LiveStrategySettingsDto>(
|
||
liveSettingsProp ?? {
|
||
market: liveMarket,
|
||
strategyId: null,
|
||
isLiveCheck: false,
|
||
executionType: 'CANDLE_CLOSE',
|
||
},
|
||
);
|
||
const [liveSaving, setLiveSaving] = useState(false);
|
||
const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY');
|
||
|
||
useEffect(() => {
|
||
void loadBacktestSettings().then(s => {
|
||
if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') {
|
||
setBacktestPositionMode(s.positionMode);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (liveSettingsProp) setLiveSettings(liveSettingsProp);
|
||
}, [liveSettingsProp]);
|
||
|
||
const persistLive = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||
if (virtualDriven) return;
|
||
const next: LiveStrategySettingsDto = {
|
||
...liveSettings,
|
||
...patch,
|
||
market: liveMarket,
|
||
positionMode: backtestPositionMode,
|
||
};
|
||
if (next.isLiveCheck && next.strategyId != null) {
|
||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
|
||
if (!synced) return;
|
||
}
|
||
setLiveSaving(true);
|
||
try {
|
||
const saved = await saveLiveStrategySettings(next);
|
||
if (saved) {
|
||
setLiveSettings(saved);
|
||
onLiveSettingsChange?.(saved);
|
||
}
|
||
onClearLiveMarkers?.();
|
||
} finally {
|
||
setLiveSaving(false);
|
||
}
|
||
}, [liveSettings, liveMarket, onClearLiveMarkers, onLiveSettingsChange, virtualDriven, backtestPositionMode]);
|
||
|
||
const isOn = liveSettings.isLiveCheck;
|
||
const execType = liveSettings.executionType;
|
||
const stratId = liveSettings.strategyId;
|
||
const monCount = monitoredMarkets.length;
|
||
|
||
return (
|
||
<>
|
||
{/* ── 실시간 전략 체크 ─────────────────────────────────────────────── */}
|
||
<SettingSection title="실시간 전략 체크">
|
||
{virtualDriven && (
|
||
<p className="stg-hint" style={{ marginBottom: 12 }}>
|
||
가상투자 세션이 실행 중입니다. 전략·실행방식·시그널 모드는 <strong>가상투자</strong> 화면에서 변경하세요.
|
||
</p>
|
||
)}
|
||
<SettingRow
|
||
label="모니터링 현황"
|
||
desc={virtualDriven
|
||
? "가상투자 투자대상 종목이 백엔드에서 실시간 전략 체크 대상입니다."
|
||
: "★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. 실시간 체크를 켜고 전략을 선택하세요."}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
||
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
||
관심 <span style={{ color: 'var(--accent)' }}>{watchlistCount}</span>개
|
||
{' · '}체크 대상 <span style={{ color: monCount > 0 ? '#00BCD4' : 'var(--text3)' }}>{monCount}</span>개
|
||
</span>
|
||
{monCount > 0 && (
|
||
<span style={{ fontSize: 10, color: 'var(--text3)', maxWidth: 220, textAlign: 'right' }}>
|
||
{monitoredMarkets.map(m => m.replace('KRW-', '')).join(', ')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="실시간 체크"
|
||
desc="캔들 차트 위에 매수·매도 시그널을 실시간으로 표시합니다. 설정한 전략 조건을 백엔드에서 지속적으로 평가합니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={isOn}
|
||
disabled={virtualDriven}
|
||
onChange={e => persistLive({ isLiveCheck: e.target.checked })}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
{liveSaving && <span style={{ fontSize: 10, color: 'var(--text3)', marginLeft: 6 }}>저장 중…</span>}
|
||
</SettingRow>
|
||
|
||
<SettingRow
|
||
label="모니터링 종목"
|
||
desc="현재 실시간 차트에서 선택된 종목입니다."
|
||
>
|
||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>{liveMarket}</span>
|
||
</SettingRow>
|
||
|
||
<SettingRow
|
||
label="전략 선택"
|
||
desc="실시간으로 체크할 투자 전략을 선택합니다. 선택된 전략의 매수·매도 조건을 백엔드에서 실시간으로 평가합니다."
|
||
>
|
||
<select
|
||
className="stg-select"
|
||
disabled={virtualDriven || !isOn}
|
||
value={stratId ?? ''}
|
||
onChange={e => persistLive({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||
style={{ opacity: isOn ? 1 : 0.45 }}
|
||
>
|
||
<option value="">전략 선택…</option>
|
||
{liveStrategies.map(s => (
|
||
<option key={s.id} value={s.id}>{s.name}</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
|
||
<SettingRow
|
||
className="stg-row--block"
|
||
label="체크 방식"
|
||
desc="봉 마감 직후: 완성된 캔들 기준으로 1회 판정합니다. 실시간 틱: 3초 주기로 진행 중인 캔들을 기준으로 판정합니다."
|
||
>
|
||
<div className={`stg-radio-row${!isOn ? ' stg-radio-row--disabled' : ''}`}>
|
||
<label className="stg-radio-option stg-radio-option--cyan">
|
||
<input
|
||
type="radio"
|
||
name="stg-exec-type"
|
||
value="CANDLE_CLOSE"
|
||
checked={execType === 'CANDLE_CLOSE'}
|
||
disabled={virtualDriven || !isOn}
|
||
onChange={() => persistLive({ executionType: 'CANDLE_CLOSE' })}
|
||
/>
|
||
<span className="stg-radio-option-text">
|
||
<strong>봉 마감 직후</strong>
|
||
<span>완성된 캔들 기준으로 1회 판정</span>
|
||
</span>
|
||
</label>
|
||
<label className="stg-radio-option stg-radio-option--cyan">
|
||
<input
|
||
type="radio"
|
||
name="stg-exec-type"
|
||
value="REALTIME_TICK"
|
||
checked={execType === 'REALTIME_TICK'}
|
||
disabled={virtualDriven || !isOn}
|
||
onChange={() => persistLive({ executionType: 'REALTIME_TICK' })}
|
||
/>
|
||
<span className="stg-radio-option-text">
|
||
<strong>실시간 틱 (3초)</strong>
|
||
<span>진행 중인 캔들 기준, 3초 주기 판정</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{isOn && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '8px 10px', borderRadius: 6,
|
||
background: 'rgba(0,188,212,0.1)', border: '1px solid rgba(0,188,212,0.25)',
|
||
fontSize: 11.5, color: 'var(--text2)',
|
||
}}>
|
||
<span style={{
|
||
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
|
||
background: '#00BCD4', boxShadow: '0 0 5px rgba(0,188,212,0.6)',
|
||
animation: 'lsp-pulse 1.5s ease-in-out infinite',
|
||
}} />
|
||
{stratId
|
||
? `${liveStrategies.find(s => s.id === stratId)?.name ?? '전략'} 모니터링 중`
|
||
: '전략을 선택하면 모니터링이 시작됩니다'}
|
||
</div>
|
||
)}
|
||
|
||
<SettingRow
|
||
className="stg-row--block"
|
||
label="시그널 모드"
|
||
desc="백테스트 설정의 포지션 종속성 모드와 동일하게 적용됩니다. 변경은 설정 → 백테스트 설정에서 하세요."
|
||
>
|
||
<div style={{
|
||
padding: '8px 10px', borderRadius: 6,
|
||
background: 'rgba(63,126,245,0.08)', border: '1px solid rgba(63,126,245,0.2)',
|
||
fontSize: 12, color: 'var(--text2)',
|
||
opacity: isOn ? 1 : 0.55,
|
||
}}>
|
||
{backtestPositionMode === 'LONG_ONLY'
|
||
? '보유 자산 기준 (Long-Only) — 매수 이력 있을 때만 매도 허용'
|
||
: '순수 지표 기준 (Signal-Only) — 포지션 무관, 지표 충족 시 즉시 매도'}
|
||
</div>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
{/* ── 매매 알림 팝업 ────────────────────────────────────────────────── */}
|
||
<SettingSection title="매매 알림 팝업">
|
||
<SettingRow
|
||
label="알림 팝업"
|
||
desc="실시간 전략 체크에서 매수·매도 시그널 발생 시 우측 상단 알림 카드를 표시합니다. 차트·전략·설정 등 모든 화면에서 동일하게 표시됩니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={tradeAlertPopup}
|
||
onChange={e => onTradeAlertPopup?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
{tradeAlertPopup && (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '8px 10px', borderRadius: 6,
|
||
background: 'rgba(255,193,7,0.1)', border: '1px solid rgba(255,193,7,0.25)',
|
||
fontSize: 11.5, color: 'var(--text2)',
|
||
}}>
|
||
<span style={{
|
||
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
|
||
background: '#FFC107', boxShadow: '0 0 5px rgba(255,193,7,0.6)',
|
||
animation: 'lsp-pulse 1.5s ease-in-out infinite',
|
||
}} />
|
||
시그널 발생 시 알림 팝업이 자동으로 표시됩니다
|
||
</div>
|
||
)}
|
||
</SettingSection>
|
||
|
||
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
|
||
<SettingSection title="리스크 관리" grid>
|
||
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
|
||
<input className="stg-input stg-input--wide" type="number" min="0" max="100" step="0.1"
|
||
value={defaultRisk} onChange={e => setRisk(e.target.value)} />
|
||
</StgCard>
|
||
<StgCard label="최대 동시 포지션" desc="동시에 보유할 수 있는 최대 포지션 수입니다.">
|
||
<input className="stg-input stg-input--wide" type="number" min="1" max="100"
|
||
value={maxPos} onChange={e => setMax(e.target.value)} />
|
||
</StgCard>
|
||
<StgCard label="트레일링 스탑" desc="수익 발생 시 손절선을 자동으로 상향 조정합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={trailingStop} onChange={e => setTrail(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</StgCard>
|
||
<StgCard label="재진입 허용" desc="청산 후 동일 조건으로 재진입을 허용합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={reentry} onChange={e => setRe(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</StgCard>
|
||
</SettingSection>
|
||
|
||
{/* ── 백테스팅 기본값 ──────────────────────────────────────────────── */}
|
||
<SettingSection title="백테스팅 기본값" grid>
|
||
<StgCard label="수수료율 (%)" desc="백테스팅 시 적용할 매수/매도 수수료율입니다.">
|
||
<input className="stg-input stg-input--wide" type="number" min="0" step="0.001"
|
||
value={backtestFee} onChange={e => setFee(e.target.value)} />
|
||
</StgCard>
|
||
<StgCard label="슬리피지 (%)" desc="백테스팅 시 적용할 슬리피지 비율입니다.">
|
||
<input className="stg-input stg-input--wide" type="number" min="0" step="0.001"
|
||
value={slippage} onChange={e => setSlip(e.target.value)} />
|
||
</StgCard>
|
||
<StgCard label="성과 지표 기준" desc="전략 비교 시 사용할 기본 성과 지표입니다.">
|
||
<select className="stg-select stg-select--wide" value={evalMetric} onChange={e => setEval(e.target.value)}>
|
||
<option value="sharpe">샤프 비율</option>
|
||
<option value="pnl">총 손익률</option>
|
||
<option value="winrate">승률</option>
|
||
<option value="maxdd">최대 낙폭</option>
|
||
</select>
|
||
</StgCard>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
};
|
||
|
||
interface AlertPanelProps {
|
||
tradeAlertPopup?: boolean;
|
||
onTradeAlertPopup?: (v: boolean) => void;
|
||
tradeAlertSoundEnabled?: boolean;
|
||
onTradeAlertSoundEnabled?: (v: boolean) => void;
|
||
tradeAlertSound?: string;
|
||
onTradeAlertSound?: (v: string) => void;
|
||
tradeAlertPopupPosition?: string;
|
||
onTradeAlertPopupPosition?: (v: string) => void;
|
||
tradeAlertPopupLayout?: string;
|
||
onTradeAlertPopupLayout?: (v: string) => void;
|
||
tradeAlertPopupGridCols?: number;
|
||
onTradeAlertPopupGridCols?: (v: number) => void;
|
||
fcmPushEnabled?: boolean;
|
||
onFcmPushEnabled?: (v: boolean) => void;
|
||
onFcmTest?: () => void;
|
||
}
|
||
|
||
const AlertPanel: React.FC<AlertPanelProps> = ({
|
||
tradeAlertPopup = true,
|
||
onTradeAlertPopup,
|
||
tradeAlertSoundEnabled = true,
|
||
onTradeAlertSoundEnabled,
|
||
tradeAlertSound = 'bell',
|
||
onTradeAlertSound,
|
||
tradeAlertPopupPosition = 'right',
|
||
onTradeAlertPopupPosition,
|
||
tradeAlertPopupLayout = 'stack',
|
||
onTradeAlertPopupLayout,
|
||
tradeAlertPopupGridCols = 2,
|
||
onTradeAlertPopupGridCols,
|
||
fcmPushEnabled = false,
|
||
onFcmPushEnabled,
|
||
onFcmTest,
|
||
}) => {
|
||
const [browserNotif, setBn] = useState(false);
|
||
const [emailAlert, setEmail] = useState(false);
|
||
const [emailAddr, setAddr] = useState('');
|
||
const [priceAlert, setPa] = useState(true);
|
||
const [signalAlert, setSig] = useState(true);
|
||
const [cooldown, setCool] = useState('60');
|
||
|
||
const soundId = normalizeTradeAlertSoundId(tradeAlertSound);
|
||
const popupPosition = normalizeTradeAlertPopupPosition(tradeAlertPopupPosition);
|
||
const popupLayout = normalizeTradeAlertPopupLayout(tradeAlertPopupLayout);
|
||
const gridCols = normalizeTradeAlertGridCols(tradeAlertPopupGridCols);
|
||
|
||
const handlePreviewSound = () => {
|
||
if (!tradeAlertSoundEnabled) {
|
||
window.alert('알림 사운드가 꺼져 있습니다. 먼저 사운드를 켜 주세요.');
|
||
return;
|
||
}
|
||
if (soundId === 'silent') {
|
||
window.alert('무음이 선택되었습니다. 알림 발생 시 소리가 재생되지 않습니다.');
|
||
return;
|
||
}
|
||
previewTradeAlertSound(soundId);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<SettingSection title="매매 시그널 알림">
|
||
<SettingRow
|
||
label="알림 팝업"
|
||
desc="시그널 발생 시 알림 카드가 표시됩니다. 차트·전략·설정 등 어떤 화면에 있어도 동일하게 나타납니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={tradeAlertPopup}
|
||
onChange={e => onTradeAlertPopup?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
|
||
{tradeAlertPopup && (
|
||
<>
|
||
<SettingRow
|
||
label="표시 위치"
|
||
desc="알림 팝업 목록이 나타날 화면 위치입니다. 변경 즉시 현재 알림에 반영됩니다."
|
||
>
|
||
<div className="stg-alert-layout-options">
|
||
{TRADE_ALERT_POPUP_POSITIONS.map(opt => (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
className={`stg-alert-layout-option${popupPosition === opt.id ? ' stg-alert-layout-option--active' : ''}`}
|
||
onClick={() => onTradeAlertPopupPosition?.(opt.id)}
|
||
>
|
||
<strong>{opt.label}</strong>
|
||
<span>{opt.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
|
||
<SettingRow
|
||
label="표시 방식"
|
||
desc="알림 목록의 배치 방식입니다. 스택·그리드·가로 나열·최신 1건 중 선택할 수 있습니다."
|
||
>
|
||
<div className="stg-alert-layout-options">
|
||
{TRADE_ALERT_POPUP_LAYOUTS.map(opt => (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
className={`stg-alert-layout-option${popupLayout === opt.id ? ' stg-alert-layout-option--active' : ''}`}
|
||
onClick={() => onTradeAlertPopupLayout?.(opt.id)}
|
||
>
|
||
<strong>{opt.label}</strong>
|
||
<span>{opt.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{popupLayout === 'grid' && (
|
||
<SettingRow
|
||
label="그리드 열 개수"
|
||
desc="N×N 그리드의 열(가로) 개수입니다. 2~4열까지 설정할 수 있습니다."
|
||
>
|
||
<select
|
||
className="stg-select stg-select--wide"
|
||
value={gridCols}
|
||
onChange={e => onTradeAlertPopupGridCols?.(Number(e.target.value))}
|
||
>
|
||
{TRADE_ALERT_GRID_COL_OPTIONS.map(n => (
|
||
<option key={n} value={n}>{n}열 ({n}×{n} 격자)</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
)}
|
||
|
||
<SettingRow
|
||
label="배치 미리보기"
|
||
desc="선택한 위치·방식으로 알림이 표시되는 모습입니다. 현재 표시 중인 알림에도 즉시 적용됩니다."
|
||
>
|
||
<TradeAlertPopupPreview
|
||
position={popupPosition}
|
||
layout={popupLayout}
|
||
gridCols={gridCols}
|
||
enabled={tradeAlertPopup}
|
||
/>
|
||
</SettingRow>
|
||
</>
|
||
)}
|
||
|
||
<SettingRow
|
||
label="알림 사운드"
|
||
desc="실시간 전략에서 매수·매도 시그널이 발생하면 선택한 알림음을 재생합니다. 팝업 표시 여부와 별도로 동작합니다."
|
||
>
|
||
<label className="stg-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={tradeAlertSoundEnabled}
|
||
onChange={e => onTradeAlertSoundEnabled?.(e.target.checked)}
|
||
/>
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="알림음 선택"
|
||
desc="시그널 발생 시 재생할 알림음을 선택합니다."
|
||
>
|
||
<div className="stg-sound-row">
|
||
<select
|
||
className="stg-select stg-select--wide"
|
||
value={soundId}
|
||
disabled={!tradeAlertSoundEnabled}
|
||
onChange={e => onTradeAlertSound?.(e.target.value)}
|
||
>
|
||
{TRADE_ALERT_SOUND_OPTIONS.map(opt => (
|
||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
className="stg-btn-secondary"
|
||
disabled={!tradeAlertSoundEnabled}
|
||
onClick={handlePreviewSound}
|
||
>
|
||
미리듣기
|
||
</button>
|
||
</div>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="FCM 푸시 (모바일·브라우저)">
|
||
<SettingRow label="FCM 푸시" desc="Firebase 설정(VITE_FIREBASE_*) 및 서버 firebase-service-account.json 필요">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={fcmPushEnabled}
|
||
onChange={e => onFcmPushEnabled?.(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="테스트 발송" desc="등록된 FCM 토큰으로 테스트 알림을 보냅니다.">
|
||
<button type="button" className="stg-btn-secondary" onClick={onFcmTest}>FCM 테스트</button>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="알림 방식">
|
||
<SettingRow label="브라우저 알림 (예정)" desc="OS 푸시 알림을 통해 백그라운드에서도 알림을 받습니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={browserNotif} onChange={e => setBn(e.target.checked)} disabled />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="이메일 알림" desc="지정한 이메일 주소로 알림 메시지를 발송합니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={emailAlert} onChange={e => setEmail(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
{emailAlert && (
|
||
<SettingRow label="이메일 주소" desc="알림을 수신할 이메일 주소를 입력하세요.">
|
||
<input className="stg-input stg-input--wide" type="email" placeholder="user@example.com"
|
||
value={emailAddr} onChange={e => setAddr(e.target.value)} />
|
||
</SettingRow>
|
||
)}
|
||
</SettingSection>
|
||
|
||
<SettingSection title="알림 조건">
|
||
<SettingRow label="가격 알림" desc="설정된 가격 도달 시 알림을 발생시킵니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={priceAlert} onChange={e => setPa(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="매매 신호 알림" desc="전략 조건에 의한 매수/매도 신호 발생 시 알림을 줍니다.">
|
||
<label className="stg-toggle">
|
||
<input type="checkbox" checked={signalAlert} onChange={e => setSig(e.target.checked)} />
|
||
<span className="stg-toggle-slider" />
|
||
</label>
|
||
</SettingRow>
|
||
<SettingRow label="알림 재발송 대기 (초)" desc="동일 조건의 알림이 반복 발송되지 않도록 대기하는 시간입니다.">
|
||
<input className="stg-input" type="number" min="0" max="3600"
|
||
value={cooldown} onChange={e => setCool(e.target.value)} />
|
||
</SettingRow>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
};
|
||
|
||
const NetworkPanel: React.FC = () => {
|
||
const [apiHost, setApi] = useState('http://localhost:8080');
|
||
const [wsHost, setWs] = useState('ws://localhost:8080/ws');
|
||
const [timeout, setTo] = useState('10000');
|
||
const [retries, setRt] = useState('3');
|
||
const [proxyUrl, setProxy] = useState('');
|
||
const [connected, _] = useState(true);
|
||
|
||
const handleTest = () => {
|
||
fetch(apiHost + '/actuator/health')
|
||
.then(() => alert('✅ 서버 연결 성공'))
|
||
.catch(() => alert('❌ 서버 연결 실패'));
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<SettingSection title="API 서버">
|
||
<SettingRow label="서버 주소" desc="백엔드 REST API 서버의 호스트 주소입니다. 포트 포함.">
|
||
<input className="stg-input stg-input--wide" type="text"
|
||
value={apiHost} onChange={e => setApi(e.target.value)}
|
||
placeholder="http://localhost:8080" />
|
||
</SettingRow>
|
||
<SettingRow label="연결 상태" desc="현재 API 서버와의 연결 상태입니다.">
|
||
<span className={`stg-badge ${connected ? 'stg-badge--ok' : 'stg-badge--err'}`}>
|
||
{connected ? '● 연결됨' : '● 연결 끊김'}
|
||
</span>
|
||
</SettingRow>
|
||
<SettingRow label="" desc="">
|
||
<button className="stg-btn-test" onClick={handleTest}>연결 테스트</button>
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="WebSocket">
|
||
<SettingRow label="WebSocket 주소" desc="실시간 시세 수신에 사용되는 WebSocket 서버 주소입니다.">
|
||
<input className="stg-input stg-input--wide" type="text"
|
||
value={wsHost} onChange={e => setWs(e.target.value)}
|
||
placeholder="ws://localhost:8080/ws" />
|
||
</SettingRow>
|
||
</SettingSection>
|
||
|
||
<SettingSection title="연결 설정">
|
||
<SettingRow label="연결 타임아웃 (ms)" desc="요청 후 응답이 없을 때 연결을 종료하는 시간(밀리초)입니다.">
|
||
<input className="stg-input" type="number" min="1000" step="1000"
|
||
value={timeout} onChange={e => setTo(e.target.value)} />
|
||
</SettingRow>
|
||
<SettingRow label="재시도 횟수" desc="연결 실패 시 자동 재시도 횟수입니다.">
|
||
<input className="stg-input" type="number" min="0" max="10"
|
||
value={retries} onChange={e => setRt(e.target.value)} />
|
||
</SettingRow>
|
||
<SettingRow label="프록시 URL" desc="프록시 서버를 통해 API 요청을 라우팅합니다. 비어 있으면 직접 연결합니다.">
|
||
<input className="stg-input stg-input--wide" type="text"
|
||
value={proxyUrl} onChange={e => setProxy(e.target.value)}
|
||
placeholder="http://proxy:8888 (선택)" />
|
||
</SettingRow>
|
||
</SettingSection>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
|
||
const SettingsPage: React.FC<SettingsPageProps> = ({
|
||
theme,
|
||
magnetMode = 'off',
|
||
onMagnetMode,
|
||
btAutoPopup = true,
|
||
onBtAutoPopup,
|
||
btShowPrice = true,
|
||
onBtShowPrice,
|
||
liveMarket = 'KRW-BTC',
|
||
liveStrategies = [],
|
||
onClearLiveMarkers,
|
||
tradeAlertPopup = true,
|
||
onTradeAlertPopup,
|
||
liveSettings,
|
||
onLiveSettingsChange,
|
||
watchlistCount = 0,
|
||
monitoredMarkets = [],
|
||
virtualDriven = false,
|
||
getIndicatorParams,
|
||
saveIndicatorParams,
|
||
getIndicatorVisual,
|
||
saveIndicatorVisual,
|
||
saveAllIndicatorDefaults,
|
||
indicatorSettingsRevision = 0,
|
||
chartIndicators = [],
|
||
onAddIndicatorToChart,
|
||
onRemoveIndicatorFromChart,
|
||
onIndicatorListOrderChange,
|
||
chartCandleAreaPriceLabels = true,
|
||
onChartCandleAreaPriceLabels,
|
||
chartSeriesPriceLabels = true,
|
||
onChartSeriesPriceLabels,
|
||
chartVolumeVisible = true,
|
||
onChartVolumeVisible,
|
||
chartLiveReceiveHighlight = true,
|
||
onChartLiveReceiveHighlight,
|
||
chartLegendOptions,
|
||
onChartLegendOptionsChange,
|
||
chartCrosshairInfoVisible = true,
|
||
onChartCrosshairInfoVisible,
|
||
chartCrosshairMarkersVisible = true,
|
||
onChartCrosshairMarkersVisible,
|
||
chartHoverToolbarVisible = true,
|
||
onChartHoverToolbarVisible,
|
||
chartPaneSeparator,
|
||
onChartPaneSeparatorChange,
|
||
tradeAlertSoundEnabled = true,
|
||
onTradeAlertSoundEnabled,
|
||
tradeAlertSound = 'bell',
|
||
onTradeAlertSound,
|
||
tradeAlertPopupPosition = 'right',
|
||
onTradeAlertPopupPosition,
|
||
tradeAlertPopupLayout = 'stack',
|
||
onTradeAlertPopupLayout,
|
||
tradeAlertPopupGridCols = 2,
|
||
onTradeAlertPopupGridCols,
|
||
paperTradingEnabled = true,
|
||
onPaperTradingEnabled,
|
||
paperInitialCapital = 10_000_000,
|
||
onPaperInitialCapital,
|
||
paperFeeRatePct = 0.05,
|
||
onPaperFeeRatePct,
|
||
paperSlippagePct = 0,
|
||
onPaperSlippagePct,
|
||
paperMinOrderKrw = 5000,
|
||
onPaperMinOrderKrw,
|
||
paperAutoTradeEnabled = false,
|
||
onPaperAutoTradeEnabled,
|
||
paperAutoTradeBudgetPct = 95,
|
||
onPaperAutoTradeBudgetPct,
|
||
paperOrderFillMode = 'LIMIT_ONLY',
|
||
onPaperOrderFillMode,
|
||
paperOrderAutoCancelPct = 1.0,
|
||
onPaperOrderAutoCancelPct,
|
||
virtualTargetMaxCount = 20,
|
||
onVirtualTargetMaxCount,
|
||
onPaperAccountReset,
|
||
tradingMode = 'PAPER',
|
||
onTradingMode,
|
||
liveAutoTradeEnabled = false,
|
||
onLiveAutoTradeEnabled,
|
||
hasUpbitKeys = false,
|
||
upbitAccessKeyMasked = null,
|
||
onUpbitKeys,
|
||
chartRealtimeSource = 'BACKEND_STOMP',
|
||
onChartRealtimeSource,
|
||
liveAutoTradeBudgetPct = 95,
|
||
onLiveAutoTradeBudgetPct,
|
||
fcmPushEnabled = false,
|
||
onFcmPushEnabled,
|
||
onFcmTest,
|
||
displayTimezone = 'Asia/Seoul',
|
||
onDisplayTimezoneChange,
|
||
chartTimeFormat = 'MM-dd HH:mm',
|
||
onChartTimeFormatChange,
|
||
tradeAlertTimeFormat = 'MM-dd HH:mm',
|
||
onTradeAlertTimeFormatChange,
|
||
menuPermissions,
|
||
verificationIssueNotify = true,
|
||
onVerificationIssueNotify,
|
||
trendSearchSettings,
|
||
onTrendSearchSettingsChange,
|
||
llmSettings,
|
||
onLlmSettingsChange,
|
||
initialCategory,
|
||
isFormalLogin = true,
|
||
onRequireFormalLogin,
|
||
}) => {
|
||
const categories = filterCategories(menuPermissions);
|
||
const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general');
|
||
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (categories.length === 0) return;
|
||
if (!categories.some(c => c.id === active)) {
|
||
setActive(categories[0].id);
|
||
}
|
||
}, [categories, active]);
|
||
|
||
useEffect(() => {
|
||
if (!initialCategory) return;
|
||
if (!isFormalLogin && isTradingSettingsCategory(initialCategory)) {
|
||
onRequireFormalLogin?.();
|
||
return;
|
||
}
|
||
if (categories.some(c => c.id === initialCategory)) {
|
||
setActive(initialCategory);
|
||
}
|
||
}, [initialCategory, categories, isFormalLogin, onRequireFormalLogin]);
|
||
|
||
useEffect(() => {
|
||
if (active !== 'indicators') setIndicatorSaveUi(null);
|
||
}, [active]);
|
||
|
||
const activeCat = categories.find(c => c.id === active) ?? categories[0];
|
||
|
||
if (!activeCat) {
|
||
return (
|
||
<div className="settings-page">
|
||
<p className="stg-ind-unavailable">접근 가능한 설정 메뉴가 없습니다.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const renderPanel = () => {
|
||
switch (active) {
|
||
case 'chart': return (
|
||
<ChartPanel
|
||
chartRealtimeSource={chartRealtimeSource}
|
||
onChartRealtimeSource={onChartRealtimeSource}
|
||
magnetMode={magnetMode}
|
||
onMagnetMode={onMagnetMode}
|
||
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
||
onChartCandleAreaPriceLabels={onChartCandleAreaPriceLabels}
|
||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||
onChartSeriesPriceLabels={onChartSeriesPriceLabels}
|
||
chartVolumeVisible={chartVolumeVisible}
|
||
onChartVolumeVisible={onChartVolumeVisible}
|
||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
|
||
chartLegendOptions={chartLegendOptions}
|
||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||
onChartCrosshairInfoVisible={onChartCrosshairInfoVisible}
|
||
chartCrosshairMarkersVisible={chartCrosshairMarkersVisible}
|
||
onChartCrosshairMarkersVisible={onChartCrosshairMarkersVisible}
|
||
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
||
onChartHoverToolbarVisible={onChartHoverToolbarVisible}
|
||
chartPaneSeparator={chartPaneSeparator}
|
||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||
chartTimeFormat={chartTimeFormat}
|
||
onChartTimeFormatChange={onChartTimeFormatChange}
|
||
btShowPrice={btShowPrice}
|
||
onBtShowPrice={onBtShowPrice}
|
||
/>
|
||
);
|
||
case 'indicators':
|
||
if (
|
||
!getIndicatorParams || !saveIndicatorParams || !getIndicatorVisual || !saveIndicatorVisual
|
||
|| !saveAllIndicatorDefaults
|
||
|| !onAddIndicatorToChart || !onRemoveIndicatorFromChart
|
||
) {
|
||
return (
|
||
<p className="stg-ind-unavailable">보조지표 설정을 불러올 수 없습니다. 차트 화면에서 다시 시도해 주세요.</p>
|
||
);
|
||
}
|
||
return (
|
||
<IndicatorMainDefaultsPanel
|
||
chartIndicators={chartIndicators}
|
||
onAddToChart={onAddIndicatorToChart}
|
||
onRemoveFromChart={onRemoveIndicatorFromChart}
|
||
getParams={getIndicatorParams}
|
||
saveParams={saveIndicatorParams}
|
||
getVisualConfig={getIndicatorVisual}
|
||
saveVisual={saveIndicatorVisual}
|
||
saveAllDefaults={saveAllIndicatorDefaults}
|
||
onSaveUiState={setIndicatorSaveUi}
|
||
settingsRevision={indicatorSettingsRevision}
|
||
onListOrderChange={onIndicatorListOrderChange}
|
||
/>
|
||
);
|
||
case 'backtest': return (
|
||
<BacktestSettingsPanel
|
||
btAutoPopup={btAutoPopup}
|
||
onBtAutoPopup={onBtAutoPopup}
|
||
btShowPrice={btShowPrice}
|
||
onBtShowPrice={onBtShowPrice}
|
||
/>
|
||
);
|
||
case 'general': return (
|
||
<GeneralPanel
|
||
displayTimezone={displayTimezone}
|
||
onDisplayTimezoneChange={onDisplayTimezoneChange ?? (() => {})}
|
||
chartTimeFormat={chartTimeFormat}
|
||
onChartTimeFormatChange={onChartTimeFormatChange}
|
||
tradeAlertTimeFormat={tradeAlertTimeFormat}
|
||
onTradeAlertTimeFormatChange={onTradeAlertTimeFormatChange}
|
||
verificationIssueNotify={verificationIssueNotify}
|
||
onVerificationIssueNotify={onVerificationIssueNotify}
|
||
showVerificationNotify={menuPermissions == null || menuPermissions['verification-board'] === true}
|
||
/>
|
||
);
|
||
case 'admin': return (
|
||
<AdminPasswordGate>
|
||
<AdminSettingsPanel />
|
||
</AdminPasswordGate>
|
||
);
|
||
case 'trading':
|
||
case 'paper':
|
||
case 'virtual':
|
||
return (
|
||
<TradingSettingsPanel
|
||
paperTradingEnabled={paperTradingEnabled}
|
||
onPaperTradingEnabled={onPaperTradingEnabled}
|
||
paperInitialCapital={paperInitialCapital}
|
||
onPaperInitialCapital={onPaperInitialCapital}
|
||
paperFeeRatePct={paperFeeRatePct}
|
||
onPaperFeeRatePct={onPaperFeeRatePct}
|
||
paperSlippagePct={paperSlippagePct}
|
||
onPaperSlippagePct={onPaperSlippagePct}
|
||
paperMinOrderKrw={paperMinOrderKrw}
|
||
onPaperMinOrderKrw={onPaperMinOrderKrw}
|
||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
||
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
|
||
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
|
||
paperOrderFillMode={paperOrderFillMode}
|
||
onPaperOrderFillMode={onPaperOrderFillMode}
|
||
paperOrderAutoCancelPct={paperOrderAutoCancelPct}
|
||
onPaperOrderAutoCancelPct={onPaperOrderAutoCancelPct}
|
||
virtualTargetMaxCount={virtualTargetMaxCount}
|
||
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
|
||
onPaperAccountReset={onPaperAccountReset}
|
||
/>
|
||
);
|
||
case 'live': return (
|
||
<LiveTradingSettingsPanel
|
||
tradingMode={tradingMode}
|
||
onTradingMode={onTradingMode}
|
||
liveAutoTradeEnabled={liveAutoTradeEnabled}
|
||
onLiveAutoTradeEnabled={onLiveAutoTradeEnabled}
|
||
hasUpbitKeys={hasUpbitKeys}
|
||
upbitAccessKeyMasked={upbitAccessKeyMasked}
|
||
onUpbitKeys={onUpbitKeys}
|
||
liveAutoTradeBudgetPct={liveAutoTradeBudgetPct}
|
||
onLiveAutoTradeBudgetPct={onLiveAutoTradeBudgetPct}
|
||
/>
|
||
);
|
||
case 'trend-search': return (
|
||
trendSearchSettings && onTrendSearchSettingsChange ? (
|
||
<TrendSearchSettingsPanel
|
||
settings={trendSearchSettings}
|
||
onChange={onTrendSearchSettingsChange}
|
||
/>
|
||
) : (
|
||
<p className="stg-ind-unavailable">추세검색 설정을 불러올 수 없습니다.</p>
|
||
)
|
||
);
|
||
case 'llm': return (
|
||
llmSettings && onLlmSettingsChange ? (
|
||
<LlmSettingsPanel
|
||
settings={llmSettings}
|
||
onChange={onLlmSettingsChange}
|
||
/>
|
||
) : (
|
||
<p className="stg-ind-unavailable">LLM 설정을 불러올 수 없습니다.</p>
|
||
)
|
||
);
|
||
case 'strategy': return (
|
||
<StrategyPanel
|
||
liveMarket={liveMarket}
|
||
liveStrategies={liveStrategies}
|
||
liveSettings={liveSettings}
|
||
watchlistCount={watchlistCount}
|
||
monitoredMarkets={monitoredMarkets}
|
||
virtualDriven={virtualDriven}
|
||
onClearLiveMarkers={onClearLiveMarkers}
|
||
tradeAlertPopup={tradeAlertPopup}
|
||
onTradeAlertPopup={onTradeAlertPopup}
|
||
onLiveSettingsChange={onLiveSettingsChange}
|
||
/>
|
||
);
|
||
case 'alert': return (
|
||
<AlertPanel
|
||
tradeAlertPopup={tradeAlertPopup}
|
||
onTradeAlertPopup={onTradeAlertPopup}
|
||
tradeAlertSoundEnabled={tradeAlertSoundEnabled}
|
||
onTradeAlertSoundEnabled={onTradeAlertSoundEnabled}
|
||
tradeAlertSound={tradeAlertSound}
|
||
onTradeAlertSound={onTradeAlertSound}
|
||
tradeAlertPopupPosition={tradeAlertPopupPosition}
|
||
onTradeAlertPopupPosition={onTradeAlertPopupPosition}
|
||
tradeAlertPopupLayout={tradeAlertPopupLayout}
|
||
onTradeAlertPopupLayout={onTradeAlertPopupLayout}
|
||
tradeAlertPopupGridCols={tradeAlertPopupGridCols}
|
||
onTradeAlertPopupGridCols={onTradeAlertPopupGridCols}
|
||
fcmPushEnabled={fcmPushEnabled}
|
||
onFcmPushEnabled={onFcmPushEnabled}
|
||
onFcmTest={onFcmTest}
|
||
/>
|
||
);
|
||
case 'network': return <NetworkPanel />;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={`stg-page stg-page--${theme}`}>
|
||
{/* ── 좌측 사이드바 ── */}
|
||
<aside className="stg-sidebar">
|
||
<div className="stg-sidebar-title">설정</div>
|
||
<nav className="stg-sidebar-nav">
|
||
{categories.map(cat => (
|
||
<button
|
||
key={cat.id}
|
||
className={`stg-sidebar-item ${active === cat.id ? 'stg-sidebar-item--active' : ''}`}
|
||
onClick={() => {
|
||
if (!isFormalLogin && isTradingSettingsCategory(cat.id)) {
|
||
onRequireFormalLogin?.();
|
||
return;
|
||
}
|
||
setActive(cat.id);
|
||
}}
|
||
>
|
||
<span className="stg-sidebar-icon">{cat.icon}</span>
|
||
<span className="stg-sidebar-label" style={{ fontSize: 13.5, fontWeight: 'inherit' }}>{cat.label}</span>
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</aside>
|
||
|
||
{/* ── 우측 콘텐츠 ── */}
|
||
<main className="stg-content">
|
||
{/* 헤더 */}
|
||
<div className="stg-content-header">
|
||
<div className="stg-content-icon">{activeCat.icon}</div>
|
||
<div className="stg-content-header-text">
|
||
<h2 className="stg-content-title">{activeCat.label}</h2>
|
||
<p className="stg-content-desc">{activeCat.desc}</p>
|
||
</div>
|
||
{active === 'indicators' && indicatorSaveUi && (
|
||
<div className="stg-content-header-actions">
|
||
{indicatorSaveUi.saveMessage && (
|
||
<span
|
||
className={`stg-ind-save-msg stg-ind-save-msg--inline${
|
||
indicatorSaveUi.saveMessage.includes('실패') ? ' stg-ind-save-msg--err' : ''
|
||
}`}
|
||
>
|
||
{indicatorSaveUi.saveMessage}
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="stg-btn-save"
|
||
disabled={!indicatorSaveUi.dirty || indicatorSaveUi.saving}
|
||
onClick={() => void indicatorSaveUi.save()}
|
||
>
|
||
{indicatorSaveUi.saving ? '저장 중…' : '저장'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 설정 패널 */}
|
||
<div className="stg-content-body">
|
||
{renderPanel()}
|
||
</div>
|
||
|
||
{/* 저장 버튼 (보조지표 설정은 변경 시 DB 자동 저장) */}
|
||
{active !== 'indicators' && (
|
||
<div className="stg-footer">
|
||
<button className="stg-btn-reset">초기화</button>
|
||
<button className="stg-btn-save">저장</button>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|