goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,597 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Button,
Box,
Typography,
TextField,
Switch,
Slider,
Divider,
Paper,
Grid,
IconButton,
Tooltip,
Alert,
Select,
MenuItem,
FormControl,
} from '@mui/material';
import { Close, Refresh } from '@mui/icons-material';
import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext';
import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext';
import type { MultiChartBollingerState } from '../stores/useDashboardStore';
import {
bollingerDialogDefaultsFromStore,
bollingerDialogToStore,
bollingerStoreToDialogColors,
bollingerStoreToDialogSettings,
} from '../utils/multiChartToolbarSettings';
import DraggablePaper from './DraggablePaper';
interface BollingerSettingsDialogProps {
open: boolean;
onClose: () => void;
multiChartToolbar?: {
bollinger: MultiChartBollingerState;
setBollinger: (
value: MultiChartBollingerState | ((prev: MultiChartBollingerState) => MultiChartBollingerState)
) => void;
};
}
const BollingerSettingsDialog: React.FC<BollingerSettingsDialogProps> = ({ open, onClose, multiChartToolbar }) => {
const { settings, updateSettings } = useIndicatorVisibility();
const { colors, updateColors } = useChartColors();
// 로컬 상태 (볼린저 밴드 관련 설정만 포함)
const [localSettings, setLocalSettings] = useState<{
bollingerUpper: boolean;
bollingerMiddle: boolean;
bollingerLower: boolean;
bollingerPeriod: number;
bollingerStdDev: number;
}>({
bollingerUpper: settings.bollingerUpper,
bollingerMiddle: settings.bollingerMiddle,
bollingerLower: settings.bollingerLower,
bollingerPeriod: settings.bollingerPeriod || 20,
bollingerStdDev: settings.bollingerStdDev || 2.0,
});
const [localColors, setLocalColors] = useState({
bollingerUpperColor: colors.bollingerUpperColor,
bollingerMiddleColor: colors.bollingerMiddleColor,
bollingerLowerColor: colors.bollingerLowerColor,
bollingerUpperWidth: colors.bollingerUpperWidth,
bollingerMiddleWidth: colors.bollingerMiddleWidth,
bollingerLowerWidth: colors.bollingerLowerWidth,
bollingerUpperLineStyle: colors.bollingerUpperLineStyle,
bollingerMiddleLineStyle: colors.bollingerMiddleLineStyle,
bollingerLowerLineStyle: colors.bollingerLowerLineStyle,
bollingerBandBackgroundColor: colors.bollingerBandBackgroundColor,
});
// 배경 투명도 상태
const [bandOpacity, setBandOpacity] = useState<number>(0.15); // ✅ 기본값 증가 (더 잘 보이도록)
// rgba에서 alpha 값 추출 함수
const extractOpacity = (color: string): number => {
const match = color.match(/rgba?\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
return match ? parseFloat(match[1]) : 0.1;
};
// rgba에서 rgb hex 추출 함수
const extractRgbFromRgba = (rgba: string): string => {
const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1]).toString(16).padStart(2, '0');
const g = parseInt(match[2]).toString(16).padStart(2, '0');
const b = parseInt(match[3]).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
}
return '#000000';
};
// hex to rgba 변환 함수
const hexToRgba = (hex: string, opacity: number): string => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
return `rgba(0, 0, 0, ${opacity})`;
};
useEffect(() => {
if (!open || !multiChartToolbar) return;
const b = multiChartToolbar.bollinger;
setLocalSettings(bollingerStoreToDialogSettings(b));
setLocalColors(bollingerStoreToDialogColors(b));
setBandOpacity(extractOpacity(b.bandBackgroundColor));
}, [open, multiChartToolbar]);
useEffect(() => {
if (!open || multiChartToolbar) return;
setLocalSettings({
bollingerUpper: settings.bollingerUpper,
bollingerMiddle: settings.bollingerMiddle,
bollingerLower: settings.bollingerLower,
bollingerPeriod: settings.bollingerPeriod || 20,
bollingerStdDev: settings.bollingerStdDev || 2.0,
});
setLocalColors({
bollingerUpperColor: colors.bollingerUpperColor,
bollingerMiddleColor: colors.bollingerMiddleColor,
bollingerLowerColor: colors.bollingerLowerColor,
bollingerUpperWidth: colors.bollingerUpperWidth,
bollingerMiddleWidth: colors.bollingerMiddleWidth,
bollingerLowerWidth: colors.bollingerLowerWidth,
bollingerBandBackgroundColor: colors.bollingerBandBackgroundColor,
bollingerUpperLineStyle: colors.bollingerUpperLineStyle,
bollingerMiddleLineStyle: colors.bollingerMiddleLineStyle,
bollingerLowerLineStyle: colors.bollingerLowerLineStyle,
});
setBandOpacity(extractOpacity(colors.bollingerBandBackgroundColor));
}, [open, multiChartToolbar, settings, colors]);
const handleSave = () => {
if (multiChartToolbar) {
multiChartToolbar.setBollinger(bollingerDialogToStore(localSettings, localColors));
} else {
console.log('💾 [볼린저밴드 설정] 저장 시작:', {
: {
period: settings.bollingerPeriod,
stdDev: settings.bollingerStdDev,
upper: settings.bollingerUpper,
middle: settings.bollingerMiddle,
lower: settings.bollingerLower,
},
새설정: localSettings,
});
updateSettings({
...settings,
...localSettings,
});
updateColors(localColors);
console.log('✅ [볼린저밴드 설정] updateSettings 및 updateColors 호출 완료');
}
onClose();
};
const handleReset = () => {
if (multiChartToolbar) {
const { localSettings: ls, localColors: lc } = bollingerDialogDefaultsFromStore();
setLocalSettings(ls);
setLocalColors(lc);
setBandOpacity(extractOpacity(lc.bollingerBandBackgroundColor));
} else {
setLocalSettings({
bollingerUpper: false,
bollingerMiddle: false,
bollingerLower: false,
bollingerPeriod: 20,
bollingerStdDev: 2.0,
});
setLocalColors({
bollingerUpperColor: '#FF9800',
bollingerMiddleColor: '#2196F3',
bollingerLowerColor: '#4CAF50',
bollingerUpperWidth: 1.5,
bollingerMiddleWidth: 1.5,
bollingerLowerWidth: 1.5,
bollingerUpperLineStyle: 'solid' as const,
bollingerMiddleLineStyle: 'solid' as const,
bollingerLowerLineStyle: 'solid' as const,
bollingerBandBackgroundColor: 'rgba(33, 150, 243, 0.15)',
});
setBandOpacity(0.15);
}
};
const handlePeriodChange = (value: number) => {
setLocalSettings({ ...localSettings, bollingerPeriod: value });
};
const handleStdDevChange = (value: number) => {
setLocalSettings({ ...localSettings, bollingerStdDev: value });
};
// 색상 테이블 컴포넌트
const ColorTable = () => {
const configs: Array<{
label: string;
enableKey: 'bollingerUpper' | 'bollingerMiddle' | 'bollingerLower';
colorKey: 'bollingerUpperColor' | 'bollingerMiddleColor' | 'bollingerLowerColor';
widthKey: 'bollingerUpperWidth' | 'bollingerMiddleWidth' | 'bollingerLowerWidth';
lineStyleKey: 'bollingerUpperLineStyle' | 'bollingerMiddleLineStyle' | 'bollingerLowerLineStyle';
}> = [
{
label: '상한선',
enableKey: 'bollingerUpper',
colorKey: 'bollingerUpperColor',
widthKey: 'bollingerUpperWidth',
lineStyleKey: 'bollingerUpperLineStyle'
},
{
label: '중간선',
enableKey: 'bollingerMiddle',
colorKey: 'bollingerMiddleColor',
widthKey: 'bollingerMiddleWidth',
lineStyleKey: 'bollingerMiddleLineStyle'
},
{
label: '하한선',
enableKey: 'bollingerLower',
colorKey: 'bollingerLowerColor',
widthKey: 'bollingerLowerWidth',
lineStyleKey: 'bollingerLowerLineStyle'
},
];
return (
<Box sx={{ mb: 0.5 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary" sx={{ display: 'block', mb: 1, fontSize: '12px' }}>
🎨 , ,
</Typography>
{/* 헤더 행 */}
<Box sx={{
p: 1,
mb: 0.5,
bgcolor: (theme) => theme.palette.mode === 'dark' ? '#616161' : '#9e9e9e',
borderRadius: 1,
}}>
<Grid container spacing={1.5} alignItems="center">
<Grid item xs={1}>
<Typography variant="caption" fontWeight="bold" color="white">ON/OFF</Typography>
</Grid>
<Grid item xs={1.5}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={1.5}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={2.5}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={2.5}>
<Typography variant="caption" fontWeight="bold" color="white"> </Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
</Grid>
</Box>
{/* 각 색상 설정 행 */}
{configs.map((cfg) => {
const colorValue = String(localColors[cfg.colorKey] || '#666666');
const widthValue = Number(localColors[cfg.widthKey] ?? 1.5);
const lineStyleValue = String(localColors[cfg.lineStyleKey] ?? 'solid');
const enabledValue = Boolean(localSettings[cfg.enableKey]);
return (
<Box key={cfg.colorKey} sx={{ p: 1, mb: 0.5, bgcolor: 'background.paper', borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Grid container spacing={1} alignItems="center">
{/* ON/OFF 토글 */}
<Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Switch
checked={enabledValue}
onChange={(e) => setLocalSettings({ ...localSettings, [cfg.enableKey]: e.target.checked })}
size="small"
/>
</Grid>
{/* 항목 이름 */}
<Grid item xs={1.5} sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontSize: '12px', fontWeight: 'medium' }}>
{cfg.label}
</Typography>
</Grid>
{/* 색상 선택 */}
<Grid item xs={1.5} sx={{ display: 'flex', alignItems: 'center' }}>
<input
type="color"
value={colorValue}
onChange={(e) => setLocalColors({ ...localColors, [cfg.colorKey]: e.target.value })}
style={{
width: '100%',
height: '30px',
border: '1px solid rgba(0, 0, 0, 0.23)',
borderRadius: '4px',
cursor: 'pointer',
backgroundColor: colorValue,
}}
/>
</Grid>
{/* 굵기 슬라이더 */}
<Grid item xs={2.5} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Slider
value={widthValue}
onChange={(_, value) => setLocalColors({ ...localColors, [cfg.widthKey]: value as number })}
min={1}
max={5}
step={0.5}
size="small"
sx={{ flex: 1 }}
/>
<Typography variant="caption" sx={{ minWidth: 35, textAlign: 'right', fontSize: '11px' }}>
{widthValue}px
</Typography>
</Grid>
{/* 선 유형 */}
<Grid item xs={2.5}>
<FormControl size="small" fullWidth>
<Select
value={lineStyleValue}
onChange={(e) => setLocalColors({ ...localColors, [cfg.lineStyleKey]: e.target.value as 'solid' | 'dashed' | 'dotted' })}
sx={{ height: 30, fontSize: '12px' }}
>
<MenuItem value="solid" sx={{ fontSize: '12px' }}></MenuItem>
<MenuItem value="dashed" sx={{ fontSize: '12px' }}></MenuItem>
<MenuItem value="dotted" sx={{ fontSize: '12px' }}></MenuItem>
</Select>
</FormControl>
</Grid>
{/* 미리보기 */}
<Grid item xs={3} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 30 }}>
<svg width="100%" height="100%" style={{ display: 'block' }}>
<line
x1="0"
y1="50%"
x2="100%"
y2="50%"
stroke={colorValue}
strokeWidth={widthValue}
strokeDasharray={lineStyleValue === 'dashed' ? '5,5' : lineStyleValue === 'dotted' ? '2,3' : '0'}
/>
</svg>
</Grid>
</Grid>
</Box>
);
})}
</Box>
);
};
return (
<Dialog
open={open}
onClose={onClose}
PaperComponent={DraggablePaper}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
maxHeight: '90vh',
},
}}
>
<DialogTitle sx={{
cursor: 'move',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<Tooltip title="초기화">
<IconButton size="small" onClick={handleReset} sx={{ color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.1)' } }}>
<Refresh />
</IconButton>
</Tooltip>
</Box>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
sx={{
color: '#fff',
borderColor: '#fff',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.1)',
borderColor: '#fff'
},
minWidth: '70px',
fontSize: '13px',
fontWeight: 600,
}}
variant="outlined"
size="small"
>
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleSave();
}}
sx={{
bgcolor: '#fff',
color: '#1976d2',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.9)'
},
minWidth: '70px',
fontSize: '13px',
fontWeight: 600,
}}
variant="contained"
size="small"
>
</Button>
</Box>
</DialogTitle>
<DialogContent sx={{ pt: 3 }}>
<Alert severity="info" sx={{ mb: 3 }}>
. .
</Alert>
{/* 기간 설정 */}
<Paper sx={{ p: 2, mb: 2, border: '1px solid', borderColor: 'divider', bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 2 }}>
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>
(Period): {localSettings.bollingerPeriod}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Slider
value={localSettings.bollingerPeriod}
onChange={(_, value) => handlePeriodChange(value as number)}
min={5}
max={50}
step={1}
marks={[
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 30, label: '30' },
{ value: 40, label: '40' },
]}
valueLabelDisplay="auto"
sx={{ flex: 1 }}
/>
<TextField
type="number"
value={localSettings.bollingerPeriod}
onChange={(e) => handlePeriodChange(Math.max(5, Math.min(50, Number(e.target.value))))}
size="small"
sx={{ width: 70 }}
inputProps={{ min: 5, max: 50 }}
/>
</Box>
</Box>
<Box>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>
(StdDev): {localSettings.bollingerStdDev}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Slider
value={localSettings.bollingerStdDev}
onChange={(_, value) => handleStdDevChange(value as number)}
min={1.0}
max={3.0}
step={0.1}
marks={[
{ value: 1.5, label: '1.5' },
{ value: 2.0, label: '2.0' },
{ value: 2.5, label: '2.5' },
]}
valueLabelDisplay="auto"
sx={{ flex: 1 }}
/>
<TextField
type="number"
value={localSettings.bollingerStdDev}
onChange={(e) => handleStdDevChange(Math.max(1.0, Math.min(3.0, Number(e.target.value))))}
size="small"
sx={{ width: 70 }}
inputProps={{ min: 1.0, max: 3.0, step: 0.1 }}
/>
</Box>
</Box>
</Paper>
{/* 색상 및 선 설정 테이블 */}
<ColorTable />
{/* 배경색 설정 */}
<Paper sx={{ p: 2, mt: 2, border: '1px solid', borderColor: 'divider', bgcolor: 'action.hover' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 2 }}>
</Typography>
<Box sx={{ mb: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={3}>
<Typography variant="body2"> </Typography>
</Grid>
<Grid item xs={9}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
border: '1px solid rgba(0, 0, 0, 0.23)',
position: 'relative',
cursor: 'pointer',
backgroundColor: localColors.bollingerBandBackgroundColor,
}}
>
<input
type="color"
value={extractRgbFromRgba(localColors.bollingerBandBackgroundColor)}
onChange={(e) => {
const rgbaColor = hexToRgba(e.target.value, bandOpacity);
setLocalColors({ ...localColors, bollingerBandBackgroundColor: rgbaColor });
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: 'pointer',
}}
/>
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '12px', minWidth: 160 }}>
{localColors.bollingerBandBackgroundColor}
</Typography>
</Box>
</Grid>
</Grid>
</Box>
<Box>
<Typography variant="body2" sx={{ mb: 1 }}>
: {(bandOpacity * 100).toFixed(0)}%
</Typography>
<Slider
value={bandOpacity}
onChange={(_, value) => {
const newOpacity = value as number;
setBandOpacity(newOpacity);
const baseColor = extractRgbFromRgba(localColors.bollingerBandBackgroundColor);
const rgbaColor = hexToRgba(baseColor, newOpacity);
setLocalColors({ ...localColors, bollingerBandBackgroundColor: rgbaColor });
}}
min={0}
max={1}
step={0.05}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${(value * 100).toFixed(0)}%`}
sx={{ width: '100%' }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
</Typography>
</Paper>
</DialogContent>
</Dialog>
);
};
export default BollingerSettingsDialog;
@@ -0,0 +1,263 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Grid,
Button,
Tooltip,
Slider,
Switch,
FormControlLabel,
Divider,
} from '@mui/material';
import { Refresh } from '@mui/icons-material';
import { useChartColors } from '../contexts/ChartColorContext';
import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext';
// 색상 선택 팔레트
const colorPalette = [
'#EF5350', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4',
'#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', '#FF5722',
'#c62828', '#1565c0', '#2E7D32', '#F57C00', '#5D4037', '#455A64', '#000000', '#FFFFFF',
];
const CandleSettingsPanel: React.FC = () => {
const { colors, updateColors, resetColors } = useChartColors();
const { settings, updateSettings } = useIndicatorVisibility();
const [colorPickerTarget, setColorPickerTarget] = useState<string | null>(null);
// 색상 미리보기 박스
const ColorBox: React.FC<{ colorKey: string; label: string }> = ({ colorKey, label }) => {
const color = (colors as any)[colorKey] || '#666';
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ minWidth: 80 }}>
{label}
</Typography>
<Box sx={{ position: 'relative' }}>
<Box
onClick={() => setColorPickerTarget(colorPickerTarget === colorKey ? null : colorKey)}
sx={{
width: 40,
height: 40,
backgroundColor: color,
borderRadius: 1,
border: '2px solid',
borderColor: 'divider',
cursor: 'pointer',
'&:hover': {
boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.5)',
},
}}
/>
{/* 색상 팔레트 팝업 */}
{colorPickerTarget === colorKey && (
<Paper
sx={{
position: 'absolute',
top: 44,
left: 0,
zIndex: 1000,
p: 1,
display: 'grid',
gridTemplateColumns: 'repeat(8, 1fr)',
gap: 0.5,
width: 220,
boxShadow: 3,
}}
>
{colorPalette.map((c) => (
<Box
key={c}
onClick={() => {
updateColors({ [colorKey]: c });
setColorPickerTarget(null);
}}
sx={{
width: 22,
height: 22,
backgroundColor: c,
borderRadius: 0.5,
border: color === c ? '2px solid #1976d2' : '1px solid #ccc',
cursor: 'pointer',
'&:hover': { transform: 'scale(1.15)' },
}}
/>
))}
<Box sx={{ gridColumn: 'span 8', mt: 1 }}>
<input
type="color"
value={color}
onChange={(e) => {
updateColors({ [colorKey]: e.target.value });
setColorPickerTarget(null);
}}
style={{ width: '100%', height: 28, cursor: 'pointer', border: 'none' }}
/>
</Box>
</Paper>
)}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
{color}
</Typography>
</Box>
);
};
const handleReset = () => {
updateColors({
candleRising: '#c62828',
candleFalling: '#1565c0',
});
};
return (
<Box>
{/* 헤더 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="subtitle1" fontWeight="bold">
🕯
</Typography>
<Tooltip title="초기화">
<Button startIcon={<Refresh />} onClick={handleReset} size="small" color="inherit">
</Button>
</Tooltip>
</Box>
{/* 캔들 색상 설정 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="body1" fontWeight="bold" sx={{ mb: 2 }}>
🎨
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<ColorBox colorKey="candleRising" label="상승 캔들" />
<ColorBox colorKey="candleFalling" label="하락 캔들" />
</Box>
<Box sx={{ mt: 3, p: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
💡
</Typography>
<Box sx={{ display: 'flex', gap: 3, mt: 1, alignItems: 'flex-end' }}>
{/* 상승 캔들 미리보기 */}
<Box sx={{ textAlign: 'center' }}>
<Box
sx={{
width: 24,
height: 60,
bgcolor: colors.candleRising,
borderRadius: 0.5,
position: 'relative',
mx: 'auto',
'&::before': {
content: '""',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
top: -10,
width: 2,
height: 10,
bgcolor: colors.candleRising,
},
'&::after': {
content: '""',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: -10,
width: 2,
height: 10,
bgcolor: colors.candleRising,
},
}}
/>
<Typography variant="caption" sx={{ mt: 2, display: 'block' }}></Typography>
</Box>
{/* 하락 캔들 미리보기 */}
<Box sx={{ textAlign: 'center' }}>
<Box
sx={{
width: 24,
height: 40,
bgcolor: colors.candleFalling,
borderRadius: 0.5,
position: 'relative',
mx: 'auto',
'&::before': {
content: '""',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
top: -12,
width: 2,
height: 12,
bgcolor: colors.candleFalling,
},
'&::after': {
content: '""',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: -8,
width: 2,
height: 8,
bgcolor: colors.candleFalling,
},
}}
/>
<Typography variant="caption" sx={{ mt: 2, display: 'block' }}></Typography>
</Box>
</Box>
</Box>
</Paper>
{/* 차트 표시 옵션 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="body1" fontWeight="bold" sx={{ mb: 2 }}>
📊
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.showGrid !== false}
onChange={(e) => updateSettings({ showGrid: e.target.checked })}
/>
}
label="그리드 표시"
/>
<FormControlLabel
control={
<Switch
checked={settings.tooltipEnabled !== false}
onChange={(e) => updateSettings({ tooltipEnabled: e.target.checked })}
/>
}
label="툴팁 표시"
/>
</Box>
</Paper>
{/* 안내 */}
<Box sx={{ p: 2, bgcolor: 'info.light', borderRadius: 1 }}>
<Typography variant="caption" color="info.contrastText">
💡 <strong> </strong><br />
캔들: 종가 &gt; ( )<br />
캔들: 종가 &lt; ( )<br />
몸통: 시가~ <br />
(): ~
</Typography>
</Box>
</Box>
);
};
export default CandleSettingsPanel;
@@ -0,0 +1,217 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography } from '@mui/material';
import { Schedule } from '@mui/icons-material';
interface CandleTimeRemainingProps {
/** 마지막 캔들 시간 (ISO string or timestamp) */
lastCandleTime: string | number;
/** 차트 interval (예: '1m', '5m', '1h', '1d') */
interval: string;
/** 표시 여부 */
visible?: boolean;
/** 차트 컨테이너 너비 */
chartWidth?: number;
}
/**
* interval 문자열을 밀리초로 변환
* @param interval 예: '1m', '5m', '15m', '1h', '4h', '1d', '1w'
* @returns 밀리초
*/
function intervalToMs(interval: string): number {
const match = interval.match(/^(\d+)([mhdw])$/);
if (!match) return 60000; // 기본값 1분
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 'm': return value * 60 * 1000; // 분
case 'h': return value * 60 * 60 * 1000; // 시간
case 'd': return value * 24 * 60 * 60 * 1000; // 일
case 'w': return value * 7 * 24 * 60 * 60 * 1000; // 주
default: return 60000;
}
}
/**
* 마지막 캔들 시간을 기준으로 다음 캔들 시작 시간 계산
* @param lastCandleTime 마지막 캔들의 시작 시간 (timestamp)
* @param intervalMs interval 밀리초
* @returns 다음 캔들 시작 시간 (timestamp)
*/
function getNextCandleTime(lastCandleTime: number, intervalMs: number): number {
// 마지막 캔들의 시작 시간을 interval 경계로 정렬
const alignedLastTime = Math.floor(lastCandleTime / intervalMs) * intervalMs;
// 다음 캔들 시작 시간 = 마지막 캔들 시작 + interval
return alignedLastTime + intervalMs;
}
/**
* 남은 시간을 포맷팅 (MM:SS 형식)
*/
function formatTimeRemaining(ms: number): string {
if (ms <= 0) return '00:00';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
// 1시간 이상인 경우 HH:MM:SS
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
// 1시간 미만인 경우 MM:SS
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
/**
* 업비트 스타일의 봉 완성 시간 표시 컴포넌트
* 마지막 캔들 우측에 다음 캔들까지 남은 시간을 표시
*/
const CandleTimeRemaining: React.FC<CandleTimeRemainingProps> = ({
lastCandleTime,
interval,
visible = true,
chartWidth = 0,
}) => {
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const prevLastCandleTimeRef = useRef<number>(0);
const isRefreshingRef = useRef<boolean>(false);
// lastCandleTime 변경 감지 - 새로운 캔들이 추가되면 갱신 완료
useEffect(() => {
const lastTime = typeof lastCandleTime === 'string'
? new Date(lastCandleTime).getTime()
: lastCandleTime;
// lastCandleTime이 실제로 변경되면 갱신 완료
if (prevLastCandleTimeRef.current !== 0 && prevLastCandleTimeRef.current !== lastTime) {
console.log(`✅ [캔들 갱신 완료] 새 캔들 추가 감지: ${new Date(lastTime).toISOString()}`);
setIsRefreshing(false);
isRefreshingRef.current = false;
}
prevLastCandleTimeRef.current = lastTime;
}, [lastCandleTime]);
useEffect(() => {
if (!visible) return;
const updateTimeRemaining = () => {
const now = Date.now();
const intervalMs = intervalToMs(interval);
// ✅ 마지막 캔들 시간 기준으로 다음 캔들 시작 시간 계산
const lastTime = typeof lastCandleTime === 'string'
? new Date(lastCandleTime).getTime()
: lastCandleTime;
const nextCandleTime = getNextCandleTime(lastTime, intervalMs);
let remaining = nextCandleTime - now;
// 음수가 되면 갱신 시작
if (remaining < 0) {
if (!isRefreshingRef.current) {
console.log(`🔄 [캔들 갱신 시작] remaining < 0, 다음 캔들 대기 중...`);
setIsRefreshing(true);
isRefreshingRef.current = true;
}
// 다음 주기까지의 시간 계산 (갱신 중에도 시간 표시)
remaining = intervalMs + (remaining % intervalMs);
}
const newTimeRemaining = Math.max(0, remaining);
setTimeRemaining(newTimeRemaining);
};
// 초기 업데이트
updateTimeRemaining();
// 1초마다 업데이트
const timer = setInterval(updateTimeRemaining, 1000);
return () => clearInterval(timer);
}, [lastCandleTime, interval, visible]);
// ✅ visible이 false일 때만 숨김 (timeRemaining === 0일 때도 계속 표시)
if (!visible) {
return null;
}
return (
<Box
sx={{
position: 'absolute',
right: 8, // ✅ 범례가 좌측으로 이동하여 우측에 배치
top: 8,
zIndex: 100,
display: 'flex',
flexDirection: 'row', // ✅ 가로 배치
alignItems: 'center',
gap: 1,
pointerEvents: 'none', // 클릭 이벤트 통과
}}
>
{/* 다음 캔들 생성 시간 - 아이콘과 텍스트 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
bgcolor: isRefreshing ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(4px)',
px: 1.5,
py: 0.5,
borderRadius: 1,
border: isRefreshing ? '1px solid rgba(255, 152, 0, 0.3)' : '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<Schedule sx={{ fontSize: 14, color: isRefreshing ? '#ff9800' : '#90caf9' }} />
<Typography
variant="caption"
sx={{
fontSize: '11px',
color: isRefreshing ? '#ff9800' : '#90caf9',
fontWeight: 500,
letterSpacing: '0.5px',
}}
>
{isRefreshing ? '갱신중...' : '다음 봉'}
</Typography>
</Box>
{/* 남은 시간 카운트다운 - 바로 옆에 배치 */}
<Box
sx={{
bgcolor: isRefreshing ? 'rgba(255, 152, 0, 0.15)' : 'rgba(33, 150, 243, 0.15)',
backdropFilter: 'blur(4px)',
px: 1.5,
py: 0.5,
borderRadius: 1,
border: isRefreshing ? '1px solid rgba(255, 152, 0, 0.3)' : '1px solid rgba(33, 150, 243, 0.3)',
boxShadow: isRefreshing ? '0 2px 8px rgba(255, 152, 0, 0.2)' : '0 2px 8px rgba(33, 150, 243, 0.2)',
}}
>
<Typography
variant="body2"
sx={{
fontSize: '14px',
fontWeight: 600,
color: isRefreshing ? '#ff9800' : '#2196f3',
fontFamily: 'monospace',
letterSpacing: '0.5px',
}}
>
{formatTimeRemaining(timeRemaining)}
</Typography>
</Box>
</Box>
);
};
export default CandleTimeRemaining;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Grid,
TextField,
Typography,
Box,
Divider,
useTheme,
useMediaQuery,
IconButton,
} from '@mui/material';
import { Close } from '@mui/icons-material';
import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext';
import DraggablePaper from './DraggablePaper';
interface ChartColorSettingsDialogProps {
open: boolean;
onClose: () => void;
}
const ChartColorSettingsDialog: React.FC<ChartColorSettingsDialogProps> = ({ open, onClose }) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { colors, updateColors, resetColors } = useChartColors();
const [tempColors, setTempColors] = useState<ChartColorSettings>(colors);
// 다이얼로그가 열릴 때마다 현재 색상으로 초기화
React.useEffect(() => {
if (open) {
setTempColors(colors);
}
}, [open, colors]);
const handleColorChange = (key: keyof ChartColorSettings, value: string) => {
setTempColors(prev => ({ ...prev, [key]: value }));
};
const handleSave = () => {
updateColors(tempColors);
onClose();
};
const handleReset = () => {
if (window.confirm('모든 색상 설정을 기본값으로 초기화하시겠습니까?')) {
resetColors();
onClose();
}
};
const ColorInput = ({ label, colorKey }: { label: string; colorKey: keyof ChartColorSettings }) => {
const colorValue = String(tempColors[colorKey]);
return (
<Grid container spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Grid item xs={6}>
<Typography variant="body2">{label}</Typography>
</Grid>
<Grid item xs={4}>
<TextField
fullWidth
size="small"
type="text"
value={colorValue}
onChange={(e) => handleColorChange(colorKey, e.target.value)}
inputProps={{ style: { fontSize: isMobile ? '12px' : '14px' } }}
/>
</Grid>
<Grid item xs={2}>
<Box
sx={{
width: '100%',
height: 36,
backgroundColor: colorValue,
border: '1px solid #ccc',
borderRadius: 1,
cursor: 'pointer',
}}
onClick={() => {
const input = document.createElement('input');
input.type = 'color';
input.value = colorValue;
input.onchange = (e) => {
handleColorChange(colorKey, (e.target as HTMLInputElement).value);
};
input.click();
}}
/>
</Grid>
</Grid>
);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
📊
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{/* 캔들 색상 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
🕯
</Typography>
<ColorInput label="상승 캔들" colorKey="candleRising" />
<ColorInput label="하락 캔들" colorKey="candleFalling" />
</Box>
<Divider sx={{ my: 2 }} />
{/* 이동평균선 색상 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📈
</Typography>
<ColorInput label="12일 이동평균선" colorKey="ma12" />
<ColorInput label="26일 이동평균선" colorKey="ma26" />
<ColorInput label="60일 이동평균선" colorKey="ma60" />
</Box>
<Divider sx={{ my: 2 }} />
{/* 거래량 색상 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📊
</Typography>
<ColorInput label="상승 거래량" colorKey="volumeRising" />
<ColorInput label="하락 거래량" colorKey="volumeFalling" />
</Box>
<Divider sx={{ my: 2 }} />
{/* MACD 색상 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📉 MACD
</Typography>
<ColorInput label="MACD 라인" colorKey="macdLine" />
<ColorInput label="Signal 라인" colorKey="signalLine" />
<ColorInput label="히스토그램 (양수)" colorKey="histogramPositive" />
<ColorInput label="히스토그램 (음수)" colorKey="histogramNegative" />
</Box>
<Divider sx={{ my: 2 }} />
{/* Stochastic 색상 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📊 Stochastic Slow
</Typography>
<ColorInput label="%K 라인" colorKey="stochasticK" />
<ColorInput label="%D 라인" colorKey="stochasticD" />
</Box>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={handleReset} color="error" variant="outlined">
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={onClose} variant="outlined">
</Button>
<Button onClick={handleSave} variant="contained" color="primary">
</Button>
</DialogActions>
</Dialog>
);
};
export default ChartColorSettingsDialog;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
import React from 'react';
import { Box, IconButton, Tooltip } from '@mui/material';
import {
ZoomIn,
ZoomOut,
ChevronLeft,
ChevronRight,
RestartAlt,
} from '@mui/icons-material';
interface ChartNavigationToolbarProps {
visible: boolean;
onZoomIn: () => void;
onZoomOut: () => void;
onScrollLeft: () => void;
onScrollRight: () => void;
onReset: () => void;
}
const ChartNavigationToolbar: React.FC<ChartNavigationToolbarProps> = ({
visible,
onZoomIn,
onZoomOut,
onScrollLeft,
onScrollRight,
onReset,
}) => {
if (!visible) return null;
return (
<Box
sx={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 100,
bgcolor: 'background.paper',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 0.5,
py: 0.5,
}}
>
{/* 작게보기 (Zoom Out) */}
<Tooltip title="작게보기">
<IconButton
size="small"
onClick={onZoomOut}
sx={{
width: 32,
height: 32,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ZoomOut sx={{ fontSize: 20 }} />
</IconButton>
</Tooltip>
{/* 크게보기 (Zoom In) */}
<Tooltip title="크게보기">
<IconButton
size="small"
onClick={onZoomIn}
sx={{
width: 32,
height: 32,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ZoomIn sx={{ fontSize: 20 }} />
</IconButton>
</Tooltip>
{/* 좌로 스크롤 (차트를 우측으로 이동 - 과거 데이터) */}
<Tooltip title="좌로 스크롤">
<IconButton
size="small"
onClick={onScrollLeft}
sx={{
width: 32,
height: 32,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ChevronLeft sx={{ fontSize: 20 }} />
</IconButton>
</Tooltip>
{/* 우로 스크롤 (차트를 좌측으로 이동 - 최신 데이터) */}
<Tooltip title="우로 스크롤">
<IconButton
size="small"
onClick={onScrollRight}
sx={{
width: 32,
height: 32,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ChevronRight sx={{ fontSize: 20 }} />
</IconButton>
</Tooltip>
{/* 차트 재설정 */}
<Tooltip title="차트 재설정">
<IconButton
size="small"
onClick={onReset}
sx={{
width: 32,
height: 32,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<RestartAlt sx={{ fontSize: 20 }} />
</IconButton>
</Tooltip>
</Box>
);
};
export default ChartNavigationToolbar;
@@ -0,0 +1,169 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Tabs,
Tab,
IconButton,
Typography,
useTheme,
} from '@mui/material';
import { Close, ShowChart, Insights, BarChart, CandlestickChart } from '@mui/icons-material';
import MASettingsPanel from './MASettingsPanel';
import IchimokuSettingsPanel from './IchimokuSettingsPanel';
import IndicatorSettingsPanel from './IndicatorSettingsPanel';
import CandleSettingsPanel from './CandleSettingsPanel';
import DraggablePaper from './DraggablePaper';
interface ChartSettingsDialogProps {
open: boolean;
onClose: () => void;
initialTab?: number;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
return (
<Box
role="tabpanel"
hidden={value !== index}
sx={{
height: 'calc(100% - 48px)',
overflow: 'auto',
p: 2,
}}
>
{value === index && children}
</Box>
);
};
const ChartSettingsDialog: React.FC<ChartSettingsDialogProps> = ({
open,
onClose,
initialTab = 0,
}) => {
const theme = useTheme();
const [activeTab, setActiveTab] = useState(initialTab);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
height: '85vh',
maxHeight: 850,
bgcolor: theme.palette.mode === 'dark' ? '#1a1a2e' : '#fff',
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: theme.palette.mode === 'dark' ? '2px solid rgba(80, 100, 160, 0.5)' : '1px solid #ddd',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
px: 2,
}}
>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<Close />
</IconButton>
</DialogTitle>
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 1 }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{
'& .MuiTab-root': {
minHeight: 48,
fontSize: '13px',
fontWeight: 500,
},
}}
>
<Tab
icon={<ShowChart sx={{ fontSize: 18 }} />}
iconPosition="start"
label="이동평균선"
/>
<Tab
icon={<Insights sx={{ fontSize: 18 }} />}
iconPosition="start"
label="일목균형표"
/>
<Tab
icon={<BarChart sx={{ fontSize: 18 }} />}
iconPosition="start"
label="보조지표"
/>
<Tab
icon={<CandlestickChart sx={{ fontSize: 18 }} />}
iconPosition="start"
label="캔들차트"
/>
</Tabs>
</Box>
<DialogContent sx={{ p: 0, overflow: 'hidden', height: '100%' }}>
<TabPanel value={activeTab} index={0}>
<MASettingsPanel />
</TabPanel>
<TabPanel value={activeTab} index={1}>
<IchimokuSettingsPanel />
</TabPanel>
<TabPanel value={activeTab} index={2}>
<IndicatorSettingsPanel />
</TabPanel>
<TabPanel value={activeTab} index={3}>
<CandleSettingsPanel />
</TabPanel>
</DialogContent>
<DialogActions sx={{ borderTop: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
<Button onClick={onClose} variant="contained" color="primary">
</Button>
</DialogActions>
</Dialog>
);
};
export default ChartSettingsDialog;
@@ -0,0 +1,294 @@
import React, { useState } from 'react';
import {
Box,
IconButton,
Tooltip,
Divider,
Paper,
ClickAwayListener,
} from '@mui/material';
import {
Add,
TrendingUp,
Straighten,
TextFields,
Delete,
Undo,
Redo,
ZoomIn,
ZoomOut,
LinearScale,
Brush,
CropFree,
KeyboardArrowDown,
SquareFoot,
Timeline,
Search,
ShowChart,
ChangeHistory,
RadioButtonUnchecked,
RotateRight,
ViewWeek,
} from '@mui/icons-material';
// 도구 타입 정의
export type ChartTool =
| 'crosshair'
| 'magnifier'
| 'trendLine'
| 'horizontalLine'
| 'verticalLine'
| 'rectangle'
| 'fibonacci'
| 'fibonacciTimezone'
| 'fibonacciFan'
| 'fibonacciCircle'
| 'fibonacciWedge'
| 'text'
| 'brush'
| 'measure'
| 'diagonalLine'
| 'angleLine'
| 'parallelChannel';
interface ChartToolbarProps {
activeTool: ChartTool | null;
onToolChange: (tool: ChartTool) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onClearAll?: () => void;
mode?: 'basic' | 'grid' | 'clean';
onModeChange?: (mode: 'basic' | 'grid' | 'clean') => void;
}
// 개별 도구 정의
interface ToolItem {
id: ChartTool;
icon: React.ReactNode;
label: string;
hasSubmenu?: boolean;
}
const ChartToolbar: React.FC<ChartToolbarProps> = ({
activeTool,
onToolChange,
onZoomIn,
onZoomOut,
onUndo,
onRedo,
onClearAll,
mode,
onModeChange,
}) => {
const [expandedTool, setExpandedTool] = useState<string | null>(null);
// 도구 정의 (업비트 트레이딩뷰 스타일 - 가로 배치)
const tools: ToolItem[] = [
{ id: 'crosshair', icon: <Add sx={{ fontSize: 18 }} />, label: '십자선' },
{ id: 'magnifier', icon: <Search sx={{ fontSize: 18 }} />, label: '돋보기' },
{ id: 'trendLine', icon: <TrendingUp sx={{ fontSize: 18 }} />, label: '추세선', hasSubmenu: true },
{ id: 'horizontalLine', icon: <Straighten sx={{ fontSize: 18 }} />, label: '수평선', hasSubmenu: true },
{ id: 'fibonacci', icon: <LinearScale sx={{ fontSize: 18 }} />, label: '피보나치', hasSubmenu: true },
{ id: 'rectangle', icon: <CropFree sx={{ fontSize: 18 }} />, label: '사각형' },
{ id: 'measure', icon: <Timeline sx={{ fontSize: 18 }} />, label: '기간 측정' },
{ id: 'brush', icon: <Brush sx={{ fontSize: 18 }} />, label: '브러시' },
{ id: 'text', icon: <TextFields sx={{ fontSize: 18 }} />, label: '텍스트' },
];
// 하위 도구 메뉴 정의
const subMenus: { [key: string]: ToolItem[] } = {
trendLine: [
{ id: 'trendLine', icon: <TrendingUp sx={{ fontSize: 16 }} />, label: '추세선' },
{ id: 'diagonalLine', icon: <ShowChart sx={{ fontSize: 16, transform: 'rotate(45deg)' }} />, label: '대각선' },
{ id: 'angleLine', icon: <RotateRight sx={{ fontSize: 16 }} />, label: '각도선' },
{ id: 'parallelChannel', icon: <Timeline sx={{ fontSize: 16 }} />, label: '평행채널' },
],
horizontalLine: [
{ id: 'horizontalLine', icon: <Straighten sx={{ fontSize: 16 }} />, label: '수평선' },
{ id: 'verticalLine', icon: <Straighten sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />, label: '수직선' },
],
fibonacci: [
{ id: 'fibonacci', icon: <LinearScale sx={{ fontSize: 16 }} />, label: '피보나치 되돌림' },
{ id: 'fibonacciTimezone', icon: <ViewWeek sx={{ fontSize: 16 }} />, label: '피보나치 타임존' },
{ id: 'fibonacciFan', icon: <ChangeHistory sx={{ fontSize: 16 }} />, label: '피보나치 팬' },
{ id: 'fibonacciCircle', icon: <RadioButtonUnchecked sx={{ fontSize: 16 }} />, label: '피보나치 서클' },
{ id: 'fibonacciWedge', icon: <ShowChart sx={{ fontSize: 16 }} />, label: '피보나치 웻지' },
],
};
const handleToolClick = (tool: ToolItem) => {
if (tool.hasSubmenu) {
setExpandedTool(expandedTool === tool.id ? null : tool.id);
} else {
onToolChange(tool.id);
setExpandedTool(null);
}
};
const handleSubToolSelect = (toolId: ChartTool) => {
onToolChange(toolId);
setExpandedTool(null);
};
return (
<Paper
elevation={0}
sx={{
backgroundColor: 'background.paper',
borderRadius: 0,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 0,
px: 0.5,
py: 0.25,
borderBottom: 1,
borderColor: 'divider',
boxShadow: 1,
}}
>
{/* 도구 버튼들 */}
{tools.map((tool) => (
<Box key={tool.id} sx={{ position: 'relative' }}>
<Tooltip title={tool.label} placement="bottom" arrow>
<Box
onClick={() => handleToolClick(tool)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0,
px: 0.75,
py: 0.5,
cursor: 'pointer',
borderRadius: 0.5,
color: activeTool === tool.id ? 'primary.main' : 'text.secondary',
backgroundColor: activeTool === tool.id ? 'action.selected' : 'transparent',
'&:hover': {
backgroundColor: 'action.hover',
color: 'primary.main',
},
height: 28,
}}
>
{tool.icon}
{tool.hasSubmenu && <KeyboardArrowDown sx={{ fontSize: 14, ml: -0.25 }} />}
</Box>
</Tooltip>
{/* 하위 메뉴 */}
{tool.hasSubmenu && expandedTool === tool.id && subMenus[tool.id] && (
<ClickAwayListener onClickAway={() => setExpandedTool(null)}>
<Paper
elevation={8}
sx={{
position: 'absolute',
left: 0,
top: '100%',
mt: 0.5,
backgroundColor: 'background.paper',
borderRadius: 1,
overflow: 'hidden',
minWidth: 120,
zIndex: 1000,
border: 1,
borderColor: 'divider',
}}
>
{subMenus[tool.id].map((subTool) => (
<Box
key={subTool.id}
onClick={() => handleSubToolSelect(subTool.id)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 0.75,
cursor: 'pointer',
color: activeTool === subTool.id ? 'primary.main' : 'text.secondary',
backgroundColor: activeTool === subTool.id ? 'action.selected' : 'transparent',
'&:hover': {
backgroundColor: 'action.hover',
color: 'primary.main',
},
fontSize: '12px',
}}
>
{subTool.icon}
<span>{subTool.label}</span>
</Box>
))}
</Paper>
</ClickAwayListener>
)}
</Box>
))}
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 줌 컨트롤 */}
<Tooltip title="확대" placement="bottom" arrow>
<IconButton size="small" onClick={onZoomIn} sx={iconButtonStyle}>
<ZoomIn sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="축소" placement="bottom" arrow>
<IconButton size="small" onClick={onZoomOut} sx={iconButtonStyle}>
<ZoomOut sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 실행 취소/다시 실행 */}
<Tooltip title="실행 취소" placement="bottom" arrow>
<IconButton size="small" onClick={onUndo} sx={iconButtonStyle}>
<Undo sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="다시 실행" placement="bottom" arrow>
<IconButton size="small" onClick={onRedo} sx={iconButtonStyle}>
<Redo sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 모두 지우기 */}
<Tooltip title="모두 지우기" placement="bottom" arrow>
<IconButton
size="small"
onClick={onClearAll}
sx={{
...iconButtonStyle,
'&:hover': {
backgroundColor: 'rgba(244, 67, 54, 0.1)',
color: '#f44336',
},
}}
>
<Delete sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Paper>
);
};
// 공통 아이콘 버튼 스타일
const iconButtonStyle = {
width: 28,
height: 28,
borderRadius: 0.5,
color: 'text.secondary',
'&:hover': {
backgroundColor: 'action.hover',
color: 'primary.main',
},
};
export default ChartToolbar;
@@ -0,0 +1,262 @@
import React, { useState, useMemo } from 'react';
import {
Box,
Drawer,
Typography,
IconButton,
Divider,
TextField,
List,
ListItem,
ListItemButton,
Tooltip,
InputAdornment,
} from '@mui/material';
import {
Menu as MenuIcon,
Close as CloseIcon,
Search as SearchIcon,
CurrencyBitcoin,
} from '@mui/icons-material';
const DRAWER_WIDTH = 340;
// 암호화폐 목록 (주요 종목)
const CRYPTO_LIST = [
{ symbol: 'KRW-BTC', name: '비트코인', shortName: 'BTC', englishName: 'Bitcoin' },
{ symbol: 'KRW-ETH', name: '이더리움', shortName: 'ETH', englishName: 'Ethereum' },
{ symbol: 'KRW-XRP', name: '리플', shortName: 'XRP', englishName: 'Ripple' },
{ symbol: 'KRW-SOL', name: '솔라나', shortName: 'SOL', englishName: 'Solana' },
{ symbol: 'KRW-DOGE', name: '도지코인', shortName: 'DOGE', englishName: 'Dogecoin' },
{ symbol: 'KRW-ADA', name: '에이다', shortName: 'ADA', englishName: 'Cardano' },
{ symbol: 'KRW-AVAX', name: '아발란체', shortName: 'AVAX', englishName: 'Avalanche' },
{ symbol: 'KRW-DOT', name: '폴카닷', shortName: 'DOT', englishName: 'Polkadot' },
{ symbol: 'KRW-MATIC', name: '폴리곤', shortName: 'MATIC', englishName: 'Polygon' },
{ symbol: 'KRW-LINK', name: '체인링크', shortName: 'LINK', englishName: 'Chainlink' },
{ symbol: 'KRW-TRX', name: '트론', shortName: 'TRX', englishName: 'TRON' },
{ symbol: 'KRW-ATOM', name: '코스모스', shortName: 'ATOM', englishName: 'Cosmos' },
{ symbol: 'KRW-ETC', name: '이더리움클래식', shortName: 'ETC', englishName: 'Ethereum Classic' },
{ symbol: 'KRW-BCH', name: '비트코인캐시', shortName: 'BCH', englishName: 'Bitcoin Cash' },
{ symbol: 'KRW-SHIB', name: '시바이누', shortName: 'SHIB', englishName: 'Shiba Inu' },
{ symbol: 'KRW-APT', name: '앱토스', shortName: 'APT', englishName: 'Aptos' },
{ symbol: 'KRW-NEAR', name: '니어프로토콜', shortName: 'NEAR', englishName: 'NEAR Protocol' },
{ symbol: 'KRW-ARB', name: '아비트럼', shortName: 'ARB', englishName: 'Arbitrum' },
{ symbol: 'KRW-OP', name: '옵티미즘', shortName: 'OP', englishName: 'Optimism' },
{ symbol: 'KRW-SAND', name: '샌드박스', shortName: 'SAND', englishName: 'The Sandbox' },
{ symbol: 'KRW-MANA', name: '디센트럴랜드', shortName: 'MANA', englishName: 'Decentraland' },
{ symbol: 'KRW-AXS', name: '엑시인피니티', shortName: 'AXS', englishName: 'Axie Infinity' },
{ symbol: 'KRW-AAVE', name: '에이브', shortName: 'AAVE', englishName: 'Aave' },
{ symbol: 'KRW-EOS', name: '이오스', shortName: 'EOS', englishName: 'EOS' },
{ symbol: 'KRW-XLM', name: '스텔라루멘', shortName: 'XLM', englishName: 'Stellar Lumens' },
{ symbol: 'KRW-HBAR', name: '헤데라', shortName: 'HBAR', englishName: 'Hedera' },
{ symbol: 'KRW-ALGO', name: '알고랜드', shortName: 'ALGO', englishName: 'Algorand' },
{ symbol: 'KRW-VET', name: '비체인', shortName: 'VET', englishName: 'VeChain' },
{ symbol: 'KRW-THETA', name: '세타토큰', shortName: 'THETA', englishName: 'THETA' },
{ symbol: 'KRW-FTM', name: '팬텀', shortName: 'FTM', englishName: 'Fantom' },
{ symbol: 'KRW-1INCH', name: '1인치네트워크', shortName: '1INCH', englishName: '1inch Network' },
{ symbol: 'KRW-GAS', name: '가스', shortName: 'GAS', englishName: 'GAS' },
];
interface CryptoListPanelProps {
currentSymbol: string;
onSelectSymbol: (symbol: string) => void;
}
const CryptoListPanel: React.FC<CryptoListPanelProps> = ({
currentSymbol,
onSelectSymbol,
}) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleToggle = () => {
setOpen(!open);
};
const handleSelect = (symbol: string) => {
onSelectSymbol(symbol);
setOpen(false);
};
// 검색 필터링
const filteredList = useMemo(() => {
if (!searchQuery) return CRYPTO_LIST;
const query = searchQuery.toLowerCase();
return CRYPTO_LIST.filter(
crypto =>
crypto.name.toLowerCase().includes(query) ||
crypto.shortName.toLowerCase().includes(query) ||
crypto.symbol.toLowerCase().includes(query)
);
}, [searchQuery]);
return (
<>
{/* Floating Icon Button - 좌측 하단 */}
<Tooltip title="종목 선택" placement="right">
<IconButton
onClick={handleToggle}
sx={{
position: 'fixed',
bottom: 24,
left: 24,
zIndex: 1200,
color: 'white',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.1)',
},
}}
>
<MenuIcon sx={{ fontSize: 28 }} />
</IconButton>
</Tooltip>
{/* Crypto List Drawer */}
<Drawer
anchor="left"
open={open}
onClose={handleToggle}
sx={{
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
},
}}
>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: 'primary.main',
color: 'primary.contrastText',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CurrencyBitcoin />
<Typography variant="h6" fontWeight="bold">
</Typography>
</Box>
<IconButton onClick={handleToggle} sx={{ color: 'inherit' }}>
<CloseIcon />
</IconButton>
</Box>
{/* Search */}
<Box sx={{ p: 2 }}>
<TextField
fullWidth
size="small"
placeholder="종목명 또는 심볼 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
</Box>
<Divider />
{/* Crypto List */}
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{filteredList.map((crypto, index) => {
const isSelected = currentSymbol === crypto.symbol;
return (
<React.Fragment key={crypto.symbol}>
<ListItem disablePadding>
<ListItemButton
selected={isSelected}
onClick={() => handleSelect(crypto.symbol)}
sx={{
py: 1.5,
px: 2,
'&.Mui-selected': {
bgcolor: 'action.selected',
'&:hover': {
bgcolor: 'action.selected',
},
},
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}>
{/* 왼쪽: 한글명 + 영문명 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="body1"
fontWeight="bold"
>
{crypto.name}
</Typography>
<Typography
variant="body2"
color="text.secondary"
>
{crypto.englishName}
</Typography>
</Box>
{/* 오른쪽: 주식코드 */}
<Typography
variant="body2"
sx={{
color: 'primary.light',
fontWeight: 500,
fontSize: 12,
}}
>
{crypto.symbol}
</Typography>
</Box>
</ListItemButton>
</ListItem>
{index < filteredList.length - 1 && <Divider />}
</React.Fragment>
);
})}
</List>
</Box>
{/* Footer */}
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
{filteredList.length}
</Typography>
<IconButton
onClick={handleToggle}
size="small"
sx={{
color: 'text.secondary',
'&:hover': {
color: 'primary.main',
},
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Drawer>
</>
);
};
export default CryptoListPanel;
@@ -0,0 +1,536 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Autocomplete, TextField, CircularProgress, Box, SxProps, Theme, IconButton, Typography, Divider } from '@mui/material';
import { Star, StarBorder } from '@mui/icons-material';
import upbitApiService, { UpbitMarket, UpbitTicker } from '../services/upbitApi';
import { toggleFavorite, getAllFavorites } from '../services/cryptoFavoriteApi';
interface CryptoNameComboBoxProps {
value: string;
onChange: (symbol: string, cryptoInfo?: UpbitMarket) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
helperText?: string;
size?: 'small' | 'medium';
sx?: SxProps<Theme>;
}
type SortKey = 'favorite' | 'korean_name' | 'trade_price' | 'signed_change_rate' | 'acc_trade_price_24h';
type SortOrder = 'asc' | 'desc';
const HEADER_ROW_HEIGHT = 30;
const formatPrice = (price?: number): string => {
if (price == null) return '-';
if (price >= 1000) return price.toLocaleString();
if (price >= 1) return price.toLocaleString(undefined, { maximumFractionDigits: 4 });
return price.toLocaleString(undefined, { maximumFractionDigits: 8 });
};
const formatChangeRate = (rate?: number): string => {
if (rate == null) return '-';
const percent = rate * 100;
const sign = percent > 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
const formatTradeValue = (value?: number): string => {
if (value == null) return '-';
if (value >= 1_0000_0000_0000) return `${(value / 1_0000_0000_0000).toFixed(2)}`;
if (value >= 1_0000_0000) return `${(value / 1_0000_0000).toFixed(1)}`;
if (value >= 1_0000) return `${(value / 1_0000).toFixed(1)}`;
return value.toLocaleString();
};
const compareNumber = (a?: number, b?: number): number => {
const av = a ?? Number.NEGATIVE_INFINITY;
const bv = b ?? Number.NEGATIVE_INFINITY;
return av - bv;
};
const compareString = (a?: string, b?: string): number => (a ?? '').localeCompare(b ?? '', 'ko');
const CryptoNameComboBox: React.FC<CryptoNameComboBoxProps> = ({
value,
onChange,
disabled = false,
label = '암호화폐명',
placeholder = '암호화폐명을 입력하거나 선택하세요',
helperText,
size = 'medium',
sx,
}) => {
const [cryptos, setCryptos] = useState<UpbitMarket[]>([]);
const [loading, setLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
const [selectedCrypto, setSelectedCrypto] = useState<UpbitMarket | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isUserTyping, setIsUserTyping] = useState(false);
const [favorites, setFavorites] = useState<Record<string, boolean>>({});
const [tickerMap, setTickerMap] = useState<Record<string, UpbitTicker>>({});
const [sortKey, setSortKey] = useState<SortKey>('favorite');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [dropdownWidth, setDropdownWidth] = useState<number>(520);
const inputRef = useRef<HTMLInputElement>(null);
const comboRootRef = useRef<HTMLDivElement>(null);
const fetchTickers = useCallback(async (markets: string[]) => {
if (markets.length === 0) return;
const chunkSize = 80;
const chunks: string[][] = [];
for (let i = 0; i < markets.length; i += chunkSize) {
chunks.push(markets.slice(i, i + chunkSize));
}
try {
const results = await Promise.all(chunks.map((chunk) => upbitApiService.getCurrentPrice(chunk)));
const next: Record<string, UpbitTicker> = {};
results.flat().forEach((ticker) => {
if (ticker?.market) next[ticker.market] = ticker;
});
setTickerMap(next);
} catch (error) {
console.error('티커 로드 실패:', error);
}
}, []);
// 컴포넌트 마운트 시 암호화폐 목록 로드
useEffect(() => {
const loadAllCryptos = async () => {
try {
setLoading(true);
const activeCryptos = await upbitApiService.getActiveCryptos();
const convertedCryptos = activeCryptos.map(crypto => ({
market: crypto.symbol,
korean_name: crypto.koreanName,
english_name: crypto.englishName
}));
setCryptos(convertedCryptos);
await fetchTickers(convertedCryptos.map((c) => c.market));
// 즐겨찾기 목록 한 번에 로드
try {
const favList = await getAllFavorites();
const favMap: Record<string, boolean> = {};
favList.forEach(fav => {
favMap[fav.symbol] = true;
});
setFavorites(favMap);
} catch (error) {
console.error('즐겨찾기 목록 로드 실패:', error);
setFavorites({});
}
} catch (error) {
console.error('암호화폐 목록 로드 실패:', error);
const defaultCryptos = [
{ market: 'KRW-BTC', korean_name: '비트코인', english_name: 'Bitcoin' },
{ market: 'KRW-ETH', korean_name: '이더리움', english_name: 'Ethereum' },
{ market: 'KRW-XRP', korean_name: '리플', english_name: 'Ripple' },
{ market: 'KRW-ADA', korean_name: '에이다', english_name: 'Cardano' },
{ market: 'KRW-SOL', korean_name: '솔라나', english_name: 'Solana' },
];
setCryptos(defaultCryptos);
} finally {
setLoading(false);
}
};
loadAllCryptos();
}, [fetchTickers]);
useEffect(() => {
if (!isOpen || cryptos.length === 0) return;
void fetchTickers(cryptos.map((c) => c.market));
}, [isOpen, cryptos, fetchTickers]);
// 부모에서 종목이 바뀌면(알림·FAB 등) 검색 중 플래그 해제 → 아래 동기화 effect가 한글명 반영
useEffect(() => {
setIsUserTyping(false);
}, [value]);
// 외부 value 변경 시 선택 상태 동기화
useEffect(() => {
// 사용자가 타이핑 중이거나 드롭다운이 열려있을 때는 동기화하지 않음
if (isUserTyping || isOpen) {
return;
}
if (value && cryptos.length > 0) {
const crypto = cryptos.find(c => c.market === value);
if (crypto && crypto !== selectedCrypto) {
setSelectedCrypto(crypto);
setInputValue(crypto.korean_name);
}
} else if (!value) {
setSelectedCrypto(null);
setInputValue('');
}
}, [value, cryptos, selectedCrypto, isUserTyping, isOpen]);
// 필터링된 종목 목록
// - 드롭다운이 열릴 때 (isOpen=true) 사용자가 타이핑 중일 때만 필터링
// - 목록을 펼칠 때는 모든 종목 표시
// - 즐겨찾기를 먼저 표시하도록 정렬
const getFilteredOptions = useCallback(() => {
let filteredList: UpbitMarket[];
// 사용자가 타이핑 중일 때만 필터링
if (isUserTyping && inputValue) {
const lowerCaseInput = inputValue.toLowerCase();
filteredList = cryptos.filter(crypto => {
if (!crypto || !crypto.korean_name || !crypto.english_name || !crypto.market) return false;
return crypto.korean_name.toLowerCase().includes(lowerCaseInput) ||
crypto.english_name.toLowerCase().includes(lowerCaseInput) ||
crypto.market.toLowerCase().includes(lowerCaseInput);
});
} else {
// 목록을 펼칠 때는 모든 종목 표시
filteredList = cryptos;
}
return [...filteredList].sort((a, b) => {
const tA = tickerMap[a.market];
const tB = tickerMap[b.market];
let cmp = 0;
if (sortKey === 'favorite') {
cmp = Number(!!favorites[a.market]) - Number(!!favorites[b.market]);
} else if (sortKey === 'korean_name') {
cmp = compareString(a.korean_name, b.korean_name);
} else if (sortKey === 'trade_price') {
cmp = compareNumber(tA?.trade_price, tB?.trade_price);
} else if (sortKey === 'signed_change_rate') {
cmp = compareNumber(tA?.signed_change_rate, tB?.signed_change_rate);
} else if (sortKey === 'acc_trade_price_24h') {
cmp = compareNumber(tA?.acc_trade_price_24h, tB?.acc_trade_price_24h);
}
if (cmp === 0) {
cmp = compareString(a.korean_name, b.korean_name);
}
return sortOrder === 'asc' ? cmp : -cmp;
});
}, [cryptos, inputValue, isUserTyping, favorites, tickerMap, sortKey, sortOrder]);
const handleSort = useCallback((key: SortKey) => {
if (sortKey === key) {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortOrder(key === 'korean_name' ? 'asc' : 'desc');
}, [sortKey]);
// 드롭다운 열림/닫힘 핸들러
const handleOpen = () => {
const inputWidth = comboRootRef.current?.clientWidth ?? 0;
// 입력란 좌측 기준으로 붙이고, 너무 좁지 않게 최소 폭 보장
setDropdownWidth(Math.max(520, inputWidth));
setIsOpen(true);
setIsUserTyping(false); // 목록을 펼칠 때는 필터링 비활성화
};
const handleClose = () => {
setIsOpen(false);
setIsUserTyping(false);
// 닫힐 때 입력값이 비어있거나 매칭되는 종목이 없으면 선택된 종목으로 복원
if (selectedCrypto && !inputValue) {
setInputValue(selectedCrypto.korean_name);
} else if (!selectedCrypto && !inputValue) {
// 선택된 종목도 없고 입력값도 없으면 그대로 유지
setInputValue('');
}
};
// 종목 선택 핸들러 — 목록에서 고를 때만 부모 onChange(차트 갱신). Clear는 검색칸만 비움.
const handleChange = (event: React.SyntheticEvent, newValue: UpbitMarket | null) => {
if (newValue) {
setSelectedCrypto(newValue);
setIsUserTyping(false);
setInputValue(newValue.korean_name);
onChange(newValue.market, newValue);
return;
}
// Clear(X): 실제 선택·부모 symbol·차트는 유지, 입력만 비워 다음 종목 검색 가능
const keep =
(value && cryptos.length > 0 ? cryptos.find((c) => c.market === value) : null) ?? selectedCrypto;
if (keep) {
setSelectedCrypto(keep);
setInputValue('');
setIsUserTyping(true);
} else {
setSelectedCrypto(null);
setInputValue('');
setIsUserTyping(false);
}
};
// 입력 변경 핸들러
const handleInputChange = (event: React.SyntheticEvent, newInputValue: string, reason: string) => {
setInputValue(newInputValue);
// 사용자가 키보드로 직접 입력하거나 clear할 때 필터링 활성화
if (reason === 'input' || reason === 'clear') {
setIsUserTyping(true);
} else if (reason === 'reset') {
// 선택이 완료되었을 때만 타이핑 상태 해제
setIsUserTyping(false);
}
};
// 키 입력 핸들러 - 엔터키 처리
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && isUserTyping) {
const filteredOptions = getFilteredOptions();
// 필터링된 결과가 하나이면 해당 종목 선택
if (filteredOptions.length === 1) {
const crypto = filteredOptions[0];
setSelectedCrypto(crypto);
setInputValue(crypto.korean_name);
setIsUserTyping(false);
setIsOpen(false);
onChange(crypto.market, crypto);
} else if (filteredOptions.length > 1) {
// 필터링된 결과 중 정확히 일치하는 종목이 있으면 선택
const exactMatch = filteredOptions.find(c =>
c.korean_name.toLowerCase() === inputValue.toLowerCase() ||
c.english_name.toLowerCase() === inputValue.toLowerCase() ||
c.market.toLowerCase() === inputValue.toLowerCase()
);
if (exactMatch) {
setSelectedCrypto(exactMatch);
setInputValue(exactMatch.korean_name);
setIsUserTyping(false);
setIsOpen(false);
onChange(exactMatch.market, exactMatch);
}
}
}
};
// 즐겨찾기 토글 핸들러
const handleToggleFavorite = async (e: React.MouseEvent, option: UpbitMarket) => {
e.stopPropagation();
e.preventDefault();
try {
const result = await toggleFavorite({
symbol: option.market,
koreanName: option.korean_name,
englishName: option.english_name,
});
setFavorites(prev => ({ ...prev, [option.market]: result }));
} catch (error) {
console.error('즐겨찾기 토글 실패:', error);
}
};
return (
<Box ref={comboRootRef}>
<Autocomplete
options={getFilteredOptions()}
getOptionLabel={(option) => option.korean_name}
isOptionEqualToValue={(option, val) => option.market === val.market}
loading={loading}
disabled={disabled}
value={selectedCrypto}
open={isOpen}
onOpen={handleOpen}
onClose={handleClose}
onChange={handleChange}
inputValue={inputValue}
onInputChange={handleInputChange}
filterOptions={(options) => options} // 커스텀 필터링 사용
size={size}
freeSolo={false} // 목록에서만 선택 가능
clearOnBlur={false} // 포커스를 잃어도 입력값 유지
selectOnFocus={false} // 포커스 시 자동 선택 안함
handleHomeEndKeys={false}
disableClearable={false} // X 버튼 표시
sx={sx}
renderInput={(params) => (
<TextField
{...params}
inputRef={inputRef}
label={label || undefined}
placeholder={placeholder}
helperText={helperText || undefined}
fullWidth
size={size}
onKeyDown={handleKeyDown}
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
renderOption={(props, option, state) => {
const isFav = favorites[option.market] || false;
const ticker = tickerMap[option.market];
const options = getFilteredOptions();
const currentIndex = state.index;
const isFirstRow = currentIndex === 0;
// 현재 항목이 즐겨찾기이고, 다음 항목이 일반 항목인지 확인
const nextOption = options[currentIndex + 1];
const isLastFavorite = isFav && nextOption && !favorites[nextOption.market];
return (
<React.Fragment key={option.market}>
{isFirstRow && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
display: 'grid',
gridTemplateColumns: '28px minmax(120px, 1.2fr) minmax(92px, 0.9fr) minmax(82px, 0.8fr) minmax(82px, 0.8fr)',
alignItems: 'center',
px: 1.5,
height: HEADER_ROW_HEIGHT,
bgcolor: 'background.paper',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Typography
variant="caption"
sx={{ textAlign: 'center', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('favorite'); }}
>
</Typography>
<Typography
variant="caption"
sx={{ cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('korean_name'); }}
>
{sortKey === 'korean_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
<Typography
variant="caption"
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('trade_price'); }}
>
{sortKey === 'trade_price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
<Typography
variant="caption"
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('signed_change_rate'); }}
>
{sortKey === 'signed_change_rate' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
<Typography
variant="caption"
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('acc_trade_price_24h'); }}
>
{sortKey === 'acc_trade_price_24h' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
</Box>
)}
<Box
component="li"
{...props}
sx={{
display: 'grid !important',
gridTemplateColumns: '28px minmax(120px, 1.2fr) minmax(92px, 0.9fr) minmax(82px, 0.8fr) minmax(82px, 0.8fr) !important',
alignItems: 'center !important',
gap: 1,
padding: '8px 12px !important',
}}
>
<IconButton
size="small"
onClick={(e) => handleToggleFavorite(e, option)}
sx={{
color: isFav ? '#fbbf24' : 'action.disabled',
padding: '2px',
}}
>
{isFav ? <Star fontSize="small" /> : <StarBorder fontSize="small" />}
</IconButton>
{/* 한글명 */}
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ fontWeight: 'bold', fontSize: '14px' }}>
{option.korean_name}
</Typography>
<Typography sx={{ fontSize: '12px', color: 'text.secondary' }}>
{option.market.replace('KRW-', '')}
</Typography>
</Box>
{/* 현재가 */}
<Typography sx={{ textAlign: 'right', fontWeight: 600, fontSize: '13px' }}>
{formatPrice(ticker?.trade_price)}
</Typography>
{/* 전일대비 */}
<Typography
sx={{
textAlign: 'right',
fontWeight: 600,
fontSize: '13px',
color:
(ticker?.signed_change_rate ?? 0) > 0
? 'error.main'
: (ticker?.signed_change_rate ?? 0) < 0
? 'info.main'
: 'text.secondary',
}}
>
{formatChangeRate(ticker?.signed_change_rate)}
</Typography>
{/* 거래대금 */}
<Typography sx={{ textAlign: 'right', fontSize: '12px', color: 'text.secondary' }}>
{formatTradeValue(ticker?.acc_trade_price_24h)}
</Typography>
</Box>
{/* 즐겨찾기와 일반 종목 사이 divider */}
{isLastFavorite && (
<Divider sx={{ my: 0.5 }} />
)}
</React.Fragment>
);
}}
ListboxProps={{
sx: {
width: '100%',
maxHeight: '70vh',
pt: 0,
},
}}
componentsProps={{
popper: {
style: {
width: `${dropdownWidth}px`,
},
placement: 'bottom-start',
},
paper: {
sx: {
maxHeight: '72vh',
},
},
}}
/>
</Box>
);
};
export default CryptoNameComboBox;
@@ -0,0 +1,87 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Paper } from '@mui/material';
/**
* 드래그 가능한 Dialog Paper 컴포넌트
* 모든 Dialog에서 재사용 가능
*/
const DraggablePaper = (props: any) => {
const paperRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 60 }); // 초기 위치를 더 아래로 (60px)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const handleMouseDown = (e: React.MouseEvent) => {
// 타이틀바 영역에서만 드래그 시작
const target = e.target as HTMLElement;
// 버튼, 아이콘버튼, 또는 그 내부 요소를 클릭한 경우 드래그 시작 안 함
if (target.closest('.MuiDialogTitle-root') &&
!target.closest('button') &&
!target.closest('.MuiButton-root') &&
!target.closest('.MuiIconButton-root')) {
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
}
};
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDragging && paperRef.current) {
// 새 위치 계산
let newX = e.clientX - dragStart.x;
let newY = e.clientY - dragStart.y;
// 상단만 제한 (0보다 작아지지 않도록)
const minY = 0;
newY = Math.max(minY, newY);
setPosition({
x: newX,
y: newY,
});
}
}, [isDragging, dragStart]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<Paper
{...props}
ref={paperRef}
onMouseDown={handleMouseDown}
sx={{
...props.sx,
position: 'fixed',
top: position.y,
left: position.x,
margin: 0,
cursor: isDragging ? 'grabbing' : 'default',
'& .MuiDialogTitle-root': {
cursor: 'grab',
userSelect: 'none',
'&:active': {
cursor: 'grabbing',
},
},
}}
/>
);
};
export default DraggablePaper;
@@ -0,0 +1,313 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Grid,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Box,
IconButton,
Slider,
Switch,
FormControlLabel,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { DrawingObject } from './ChartDrawingOverlay';
import DraggablePaper from './DraggablePaper';
interface DrawingSettingsDialogProps {
open: boolean;
onClose: () => void;
drawing: DrawingObject | null;
onSave: (updatedDrawing: DrawingObject) => void;
onDelete: (drawingId: string) => void;
}
interface ExtendedSettings {
extended?: boolean;
showLabel?: boolean;
customText?: string;
}
const colors = [
'#2962ff', // Blue (Default)
'#f44336', // Red
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#00bcd4', // Cyan
'#ffffff', // White
'#000000', // Black
];
type DrawingStyle = NonNullable<DrawingObject['style']>;
const DrawingSettingsDialog: React.FC<DrawingSettingsDialogProps> = ({
open,
onClose,
drawing,
onSave,
onDelete,
}) => {
const [settings, setSettings] = useState<DrawingStyle>({
color: '#2962ff',
lineWidth: 2,
lineStyle: 'solid',
fillColor: 'rgba(41, 98, 255, 0.2)',
fillOpacity: 0.2,
fontSize: 14,
text: '',
});
const [extendedSettings, setExtendedSettings] = useState<ExtendedSettings>({
extended: false,
showLabel: true,
customText: '',
});
useEffect(() => {
if (drawing && drawing.style) {
setSettings({
color: drawing.style.color,
lineWidth: drawing.style.lineWidth,
lineStyle: drawing.style.lineStyle,
fillColor: drawing.style.fillColor || 'rgba(41, 98, 255, 0.2)',
fillOpacity: drawing.style.fillOpacity || 0.2,
fontSize: drawing.style.fontSize || 14,
text: drawing.style.text || '',
});
setExtendedSettings({
extended: drawing.extended || false,
showLabel: drawing.showLabel !== false,
customText: drawing.customText || '',
});
}
}, [drawing]);
if (!drawing) return null;
const handleSave = () => {
if (drawing) {
onSave({
...drawing,
style: settings,
extended: extendedSettings.extended,
showLabel: extendedSettings.showLabel,
customText: extendedSettings.customText,
});
onClose();
}
};
const handleDelete = () => {
if (drawing) {
onDelete(drawing.id);
onClose();
}
};
const isTextTool = drawing.type === 'text';
const isFilledTool = drawing.type === 'rectangle' || drawing.type === 'brush'; // Brush technically not filled usually, but reusing logic
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
{/* Line Color */}
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{colors.map((c) => (
<Box
key={c}
onClick={() => setSettings({ ...settings, color: c })}
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: c,
cursor: 'pointer',
border: settings.color === c ? '2px solid #fff' : '1px solid #ddd',
boxShadow: settings.color === c ? '0 0 0 2px #2962ff' : 'none',
}}
/>
))}
</Box>
</Grid>
{/* Line Width */}
{!isTextTool && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> : {settings.lineWidth}px</Typography>
<Slider
value={settings.lineWidth}
min={1}
max={10}
step={1}
onChange={(_, val) => setSettings({ ...settings, lineWidth: val as number })}
/>
</Grid>
)}
{/* Line Style */}
{!isTextTool && (
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel> </InputLabel>
<Select
value={settings.lineStyle}
label="선 스타일"
onChange={(e) => setSettings({ ...settings, lineStyle: e.target.value as any })}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed"></MenuItem>
<MenuItem value="dotted"></MenuItem>
</Select>
</FormControl>
</Grid>
)}
{/* Text Content & Size (Text Tool Only) */}
{isTextTool && (
<>
<Grid item xs={12}>
<TextField
fullWidth
label="텍스트 내용"
value={settings.text}
onChange={(e) => setSettings({ ...settings, text: e.target.value })}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> : {settings.fontSize}px</Typography>
<Slider
value={settings.fontSize}
min={10}
max={40}
step={1}
onChange={(_, val) => setSettings({ ...settings, fontSize: val as number })}
/>
</Grid>
</>
)}
{/* Fill Settings (Rectangle Only) */}
{drawing.type === 'rectangle' && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> : {(settings.fillOpacity! * 100).toFixed(0)}%</Typography>
<Slider
value={settings.fillOpacity}
min={0}
max={1}
step={0.1}
onChange={(_, val) => setSettings({ ...settings, fillOpacity: val as number })}
/>
</Grid>
)}
{/* Extended Options (for lines that can extend) */}
{(drawing.type === 'trendLine' || drawing.type === 'diagonalLine' || drawing.type === 'angleLine' || drawing.type === 'horizontalLine') && (
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={extendedSettings.extended}
onChange={(e) => setExtendedSettings({ ...extendedSettings, extended: e.target.checked })}
/>
}
label="보조지표 영역까지 확장"
/>
</Grid>
)}
{/* Label Options */}
{(drawing.type === 'trendLine' || drawing.type === 'diagonalLine' || drawing.type === 'angleLine' || drawing.type === 'horizontalLine') && (
<>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={extendedSettings.showLabel}
onChange={(e) => setExtendedSettings({ ...extendedSettings, showLabel: e.target.checked })}
/>
}
label="라벨 표시"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
size="small"
label="커스텀 텍스트 (선택사항)"
value={extendedSettings.customText}
onChange={(e) => setExtendedSettings({ ...extendedSettings, customText: e.target.value })}
placeholder="예: 저항선, 지지선"
/>
</Grid>
</>
)}
</Grid>
</DialogContent>
<DialogActions sx={{ justifyContent: 'space-between', px: 3, py: 2 }}>
<Button onClick={handleDelete} color="error" variant="outlined">
</Button>
<Box>
<Button onClick={onClose} sx={{ mr: 1 }}>
</Button>
<Button onClick={handleSave} variant="contained" color="primary">
</Button>
</Box>
</DialogActions>
</Dialog>
);
};
export default DrawingSettingsDialog;
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
IconButton,
Typography,
Divider,
} from '@mui/material';
import {
Star as StarIcon,
Close as CloseIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { getAllFavorites, removeFavorite, CryptoFavoriteDto } from '../services/cryptoFavoriteApi';
interface FavoritesPanelProps {
open: boolean;
onClose: () => void;
onSelectSymbol: (symbol: string) => void;
currentSymbol?: string;
}
/**
* 즐겨찾기 패널 컴포넌트
*/
const FavoritesPanel: React.FC<FavoritesPanelProps> = ({
open,
onClose,
onSelectSymbol,
currentSymbol,
}) => {
const [favorites, setFavorites] = useState<CryptoFavoriteDto[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
loadFavorites();
}
}, [open]);
const loadFavorites = async () => {
try {
setLoading(true);
const data = await getAllFavorites();
setFavorites(data);
} catch (error) {
console.error('즐겨찾기 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleRemoveFavorite = async (e: React.MouseEvent, symbol: string) => {
e.stopPropagation(); // 이벤트 버블링 방지
e.preventDefault();
try {
console.log('즐겨찾기 삭제 시도:', symbol);
await removeFavorite(symbol);
console.log('즐겨찾기 삭제 성공:', symbol);
// 삭제 후 목록 새로고침
await loadFavorites();
} catch (error) {
console.error('즐겨찾기 삭제 실패:', error);
}
};
const handleSelectFavorite = (symbol: string) => {
onSelectSymbol(symbol);
onClose();
};
return (
<Drawer
anchor="left"
open={open}
onClose={onClose}
sx={{
'& .MuiDrawer-paper': {
width: 280,
bgcolor: 'background.paper',
color: 'text.primary',
borderRight: 1,
borderColor: 'divider',
},
}}
>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: 'background.default' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StarIcon sx={{ color: '#fbbf24' }} />
<Typography variant="h6" fontWeight="bold">
</Typography>
</Box>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Divider />
<Box sx={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography color="text.secondary"> ...</Typography>
</Box>
) : favorites.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography color="text.secondary">
.
<br />
<br />
.
</Typography>
</Box>
) : (
<List dense>
{favorites.map((fav) => (
<ListItem
key={fav.symbol}
disablePadding
sx={{
position: 'relative', // absolute 포지셔닝을 위한 기준점
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ListItemButton
selected={currentSymbol === fav.symbol}
onClick={() => handleSelectFavorite(fav.symbol)}
sx={{
pr: 6, // 우측 여백 확보 (삭제 버튼 공간)
'&:hover': {
bgcolor: 'action.hover',
},
'&.Mui-selected': {
bgcolor: 'action.selected',
'&:hover': {
bgcolor: 'action.selected',
},
},
}}
>
<ListItemText
primary={fav.koreanName}
secondary={fav.symbol.replace('KRW-', '')}
primaryTypographyProps={{
fontSize: '14px',
fontWeight: currentSymbol === fav.symbol ? 600 : 400,
}}
secondaryTypographyProps={{
fontSize: '12px',
}}
/>
</ListItemButton>
{/* 삭제 버튼 - 명시적으로 배치 */}
<IconButton
size="small"
onClick={(e) => handleRemoveFavorite(e, fav.symbol)}
sx={{
position: 'absolute',
right: 8,
color: '#ef4444',
'&:hover': {
bgcolor: 'rgba(239, 68, 68, 0.1)',
},
}}
title="즐겨찾기에서 삭제"
>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItem>
))}
</List>
)}
</Box>
</Drawer>
);
};
export default FavoritesPanel;
@@ -0,0 +1,124 @@
import React, { useRef } from 'react';
import { Box } from '@mui/material';
export interface FibonacciCircleObject {
id: string;
type: 'fibonacciCircle';
centerX: number;
centerY: number;
radius: number;
settings: {
color: string;
lineWidth: number;
lineStyle: 'solid' | 'dashed' | 'dotted';
fillOpacity: number;
levels: number[];
showLabels: boolean;
};
}
interface FibonacciCircleProps {
objects: FibonacciCircleObject[];
chartWidth: number;
chartHeight: number;
totalChartHeight?: number;
timeToX: (time: number) => number | null;
priceToY: (price: number) => number | null;
onClick?: (objectId: string, event: React.MouseEvent) => void;
onDoubleClick?: (objectId: string) => void;
onDragStart?: (objectId: string, startX: number, startTime: number) => void;
}
const FibonacciCircle: React.FC<FibonacciCircleProps> = ({
objects,
chartWidth,
chartHeight,
totalChartHeight,
timeToX,
priceToY,
onClick,
onDoubleClick,
onDragStart,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const calculateCircleLevels = (radius: number, levels: number[]) => {
return levels.map(level => ({
level,
radius: radius * level
}));
};
const renderCircle = (circleObject: FibonacciCircleObject) => {
const circleLevels = calculateCircleLevels(circleObject.radius, circleObject.settings.levels);
return (
<g key={circleObject.id}>
{circleLevels.map((circleLevel, index) => {
const strokeDasharray = circleObject.settings.lineStyle === 'dashed'
? '5,5'
: circleObject.settings.lineStyle === 'dotted'
? '2,2'
: undefined;
return (
<g key={index}>
<circle
cx={circleObject.centerX}
cy={circleObject.centerY}
r={circleLevel.radius}
fill="none"
stroke={circleObject.settings.color}
strokeWidth={circleObject.settings.lineWidth}
strokeDasharray={strokeDasharray}
fillOpacity={circleObject.settings.fillOpacity}
opacity={0.8}
style={{ cursor: 'pointer' }}
onClick={(e) => onClick?.(circleObject.id, e)}
onDoubleClick={() => onDoubleClick?.(circleObject.id)}
/>
{circleObject.settings.showLabels && (
<text
x={circleObject.centerX + circleLevel.radius + 5}
y={circleObject.centerY}
fill={circleObject.settings.color}
fontSize={10}
fontFamily="Arial, sans-serif"
>
{`${(circleLevel.level * 100).toFixed(1)}%`}
</text>
)}
</g>
);
})}
</g>
);
};
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: chartWidth,
height: totalChartHeight || chartHeight,
pointerEvents: 'none',
zIndex: 100,
}}
>
<svg
ref={svgRef}
width={chartWidth}
height={totalChartHeight || chartHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<g style={{ pointerEvents: 'all' }}>
{objects.map(renderCircle)}
</g>
</svg>
</Box>
);
};
export default FibonacciCircle;
@@ -0,0 +1,281 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Grid,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Box,
IconButton,
Slider,
Switch,
FormControlLabel,
Chip,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import { DrawingObject } from './ChartDrawingOverlay';
import DraggablePaper from './DraggablePaper';
interface FibonacciDrawingSettingsDialogProps {
open: boolean;
onClose: () => void;
drawing: DrawingObject | null;
onSave: (updatedDrawing: DrawingObject) => void;
onDelete: (drawingId: string) => void;
}
const colors = [
'#2962ff', // Blue (Default)
'#f44336', // Red
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#00bcd4', // Cyan
'#ffffff', // White
'#ffeb3b', // Yellow
];
const defaultFibLevels = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
const FibonacciDrawingSettingsDialog: React.FC<FibonacciDrawingSettingsDialogProps> = ({
open,
onClose,
drawing,
onSave,
onDelete,
}) => {
const [color, setColor] = useState('#2962ff');
const [lineWidth, setLineWidth] = useState(2);
const [lineStyle, setLineStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
const [fillOpacity, setFillOpacity] = useState(0.1);
const [fibLevels, setFibLevels] = useState<number[]>(defaultFibLevels);
const [newLevel, setNewLevel] = useState('');
useEffect(() => {
if (drawing && drawing.style) {
setColor(drawing.style.color);
setLineWidth(drawing.style.lineWidth);
setLineStyle(drawing.style.lineStyle);
setFillOpacity(drawing.style.fillOpacity || 0.1);
setFibLevels(drawing.fibLevels || defaultFibLevels);
}
}, [drawing]);
if (!drawing) return null;
const handleSave = () => {
if (drawing) {
onSave({
...drawing,
style: {
...drawing.style,
color,
lineWidth,
lineStyle,
fillOpacity,
},
fibLevels,
});
onClose();
}
};
const handleDelete = () => {
if (drawing) {
onDelete(drawing.id);
onClose();
}
};
const handleAddLevel = () => {
const level = parseFloat(newLevel);
if (!isNaN(level) && level >= 0 && level <= 2) {
setFibLevels([...fibLevels, level].sort((a, b) => a - b));
setNewLevel('');
}
};
const handleRemoveLevel = (index: number) => {
setFibLevels(fibLevels.filter((_, i) => i !== index));
};
const handleResetLevels = () => {
setFibLevels(defaultFibLevels);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#9c27b0',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
- {drawing.type === 'fibonacciFan' ? '팬' : drawing.type === 'fibonacciCircle' ? '서클' : '웻지'}
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
{/* Line Color */}
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{colors.map((c) => (
<Box
key={c}
onClick={() => setColor(c)}
sx={{
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: c,
cursor: 'pointer',
border: color === c ? '3px solid #fff' : '1px solid #ddd',
boxShadow: color === c ? '0 0 0 2px #9c27b0' : 'none',
}}
/>
))}
</Box>
</Grid>
{/* Line Width */}
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> : {lineWidth}px</Typography>
<Slider
value={lineWidth}
min={1}
max={10}
step={1}
onChange={(_, val) => setLineWidth(val as number)}
/>
</Grid>
{/* Line Style */}
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel> </InputLabel>
<Select
value={lineStyle}
label="선 스타일"
onChange={(e) => setLineStyle(e.target.value as any)}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed"></MenuItem>
<MenuItem value="dotted"></MenuItem>
</Select>
</FormControl>
</Grid>
{/* Fill Opacity (Circle and Wedge) */}
{(drawing.type === 'fibonacciCircle' || drawing.type === 'fibonacciWedge') && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom> : {(fillOpacity * 100).toFixed(0)}%</Typography>
<Slider
value={fillOpacity}
min={0}
max={0.5}
step={0.05}
onChange={(_, val) => setFillOpacity(val as number)}
/>
</Grid>
)}
{/* Fibonacci Levels */}
<Grid item xs={12}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2"> </Typography>
<Button size="small" onClick={handleResetLevels} variant="outlined">
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
{fibLevels.map((level, idx) => (
<Chip
key={idx}
label={`${(level * 100).toFixed(1)}%`}
onDelete={() => handleRemoveLevel(idx)}
size="small"
color="primary"
variant="outlined"
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
label="새 레벨 추가"
value={newLevel}
onChange={(e) => setNewLevel(e.target.value)}
placeholder="0.5 (50%)"
sx={{ flex: 1 }}
/>
<Button
variant="contained"
onClick={handleAddLevel}
startIcon={<AddIcon />}
>
</Button>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
0.0 ~ 2.0 (: 0.5 = 50%, 1.618 = 161.8%)
</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ justifyContent: 'space-between', px: 3, py: 2 }}>
<Button onClick={handleDelete} color="error" variant="outlined" startIcon={<DeleteIcon />}>
</Button>
<Box>
<Button onClick={onClose} sx={{ mr: 1 }}>
</Button>
<Button onClick={handleSave} variant="contained" sx={{ bgcolor: '#9c27b0', '&:hover': { bgcolor: '#7b1fa2' } }}>
</Button>
</Box>
</DialogActions>
</Dialog>
);
};
export default FibonacciDrawingSettingsDialog;
@@ -0,0 +1,147 @@
import React, { useRef, useEffect } from 'react';
import { Box } from '@mui/material';
export interface FibonacciFanObject {
id: string;
type: 'fibonacciFan';
startPrice: number;
endPrice: number;
startTime: number;
endTime: number;
settings: {
color: string;
lineWidth: number;
lineStyle: 'solid' | 'dashed' | 'dotted';
levels: number[];
showLabels: boolean;
};
}
interface FibonacciFanProps {
objects: FibonacciFanObject[];
chartWidth: number;
chartHeight: number;
totalChartHeight?: number;
priceScale: {
from: number;
to: number;
};
timeScale: {
from: number;
to: number;
};
candleData: Array<{ time: string | number; open: number; high: number; low: number; close: number }>;
timeToX: (time: number) => number | null;
priceToY: (price: number) => number | null;
onClick?: (objectId: string, event: React.MouseEvent) => void;
onDoubleClick?: (objectId: string) => void;
onDragStart?: (objectId: string, startX: number, startTime: number) => void;
}
const FibonacciFan: React.FC<FibonacciFanProps> = ({
objects,
chartWidth,
chartHeight,
totalChartHeight,
priceScale,
timeScale,
candleData,
timeToX,
priceToY,
onClick,
onDoubleClick,
onDragStart,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const calculateFanLines = (startPrice: number, endPrice: number) => {
const priceRange = endPrice - startPrice;
const levels = [0.382, 0.5, 0.618]; // 기본 피보나치 팬 레벨
return levels.map(level => ({
level,
price: startPrice + (priceRange * level)
}));
};
const renderFan = (fanObject: FibonacciFanObject) => {
const startX = timeToX(fanObject.startTime);
const endX = timeToX(fanObject.endTime);
const startY = priceToY(fanObject.startPrice);
if (startX === null || endX === null || startY === null) return null;
const fanLines = calculateFanLines(fanObject.startPrice, fanObject.endPrice);
return (
<g key={fanObject.id}>
{fanLines.map((fanLine, index) => {
const endY = priceToY(fanLine.price);
if (endY === null) return null;
const strokeDasharray = fanObject.settings.lineStyle === 'dashed'
? '5,5'
: fanObject.settings.lineStyle === 'dotted'
? '2,2'
: undefined;
return (
<g key={index}>
<line
x1={startX}
y1={startY}
x2={endX}
y2={endY}
stroke={fanObject.settings.color}
strokeWidth={fanObject.settings.lineWidth}
strokeDasharray={strokeDasharray}
opacity={0.8}
style={{ cursor: 'pointer' }}
onClick={(e) => onClick?.(fanObject.id, e)}
onDoubleClick={() => onDoubleClick?.(fanObject.id)}
/>
{fanObject.settings.showLabels && (
<text
x={endX + 5}
y={endY}
fill={fanObject.settings.color}
fontSize={10}
fontFamily="Arial, sans-serif"
>
{`${(fanLine.level * 100).toFixed(1)}%`}
</text>
)}
</g>
);
})}
</g>
);
};
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: chartWidth,
height: totalChartHeight || chartHeight,
pointerEvents: 'none',
zIndex: 100,
}}
>
<svg
ref={svgRef}
width={chartWidth}
height={totalChartHeight || chartHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<g style={{ pointerEvents: 'all' }}>
{objects.map(renderFan)}
</g>
</svg>
</Box>
);
};
export default FibonacciFan;
@@ -0,0 +1,564 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Button,
Box,
TextField,
Checkbox,
Typography,
Divider,
Select,
MenuItem,
FormControl,
Tabs,
Tab,
Slider,
Popover,
} from '@mui/material';
import { Edit as EditIcon } from '@mui/icons-material';
import DraggablePaper from './DraggablePaper';
export interface FibonacciLevel {
value: number;
enabled: boolean;
color: string;
lineStyle: 'solid' | 'dashed' | 'dotted';
lineWidth: number;
opacity?: number; // 투명도 (0-100)
}
export interface FibonacciBaseline {
color: string;
lineStyle: 'solid' | 'dashed' | 'dotted';
lineWidth: number;
}
export interface FibonacciTimezoneSettings {
baseline: FibonacciBaseline; // ✅ 기준선 설정 추가
levels: FibonacciLevel[];
showAcrossIndicators?: boolean; // ✅ 보조지표까지 보기
}
interface FibonacciTimezoneDialogProps {
open: boolean;
onClose: () => void;
settings: FibonacciTimezoneSettings;
onSave: (settings: FibonacciTimezoneSettings) => void;
onDelete?: () => void; // ✅ 삭제 콜백 (개별 피보나치 편집 시에만 사용)
isEditingIndividual?: boolean; // ✅ 개별 피보나치 편집 여부
}
const defaultBaseline: FibonacciBaseline = {
color: '#808080',
lineStyle: 'solid',
lineWidth: 2,
};
const defaultLevels: FibonacciLevel[] = [
{ value: 5, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 8, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 7, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 11, enabled: true, color: '#FF5252', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 15, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 13, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 21, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 34, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 55, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
{ value: 89, enabled: true, color: '#2196F3', lineStyle: 'solid', lineWidth: 1, opacity: 100 },
];
const FibonacciTimezoneDialog: React.FC<FibonacciTimezoneDialogProps> = ({
open,
onClose,
settings,
onSave,
onDelete,
isEditingIndividual = false,
}) => {
const [localSettings, setLocalSettings] = useState<FibonacciTimezoneSettings>(() => ({
baseline: settings.baseline || defaultBaseline,
levels: settings.levels || defaultLevels,
}));
const [currentTab, setCurrentTab] = useState(0); // 0: 모습, 1: 좌표, 2: 보임
const [showBackground, setShowBackground] = useState(false);
const [showLabels, setShowLabels] = useState(true);
const [colorPickerAnchor, setColorPickerAnchor] = useState<{ el: HTMLElement; index: number; isBaseline: boolean } | null>(null);
useEffect(() => {
// ✅ baseline이 없으면 기본값 추가 (기존 저장된 데이터 호환)
setLocalSettings({
baseline: settings.baseline || defaultBaseline,
levels: settings.levels || defaultLevels,
showAcrossIndicators: settings.showAcrossIndicators || false,
});
}, [settings]);
const handleBaselineChange = (field: keyof FibonacciBaseline, value: any) => {
setLocalSettings({
...localSettings,
baseline: { ...(localSettings.baseline || defaultBaseline), [field]: value },
});
};
const handleLevelChange = (index: number, field: keyof FibonacciLevel, value: any) => {
const newLevels = [...localSettings.levels];
newLevels[index] = { ...newLevels[index], [field]: value };
setLocalSettings({ ...localSettings, levels: newLevels });
};
const handleSave = () => {
// ✅ baseline이 없으면 기본값 추가
const settingsToSave: FibonacciTimezoneSettings = {
baseline: localSettings.baseline || defaultBaseline,
levels: localSettings.levels,
showAcrossIndicators: localSettings.showAcrossIndicators || false,
};
onSave(settingsToSave);
onClose();
};
const handleReset = () => {
setLocalSettings({ baseline: defaultBaseline, levels: defaultLevels });
};
// 색상 팔레트
const colorPalette = [
['#000000', '#434651', '#666666', '#999999', '#B2B5BE', '#D1D4DC', '#E1E3EB', '#F0F3FA'],
['#F23645', '#FF9800', '#FDD835', '#26A69A', '#00BCD4', '#2196F3', '#3F51B5', '#9C27B0'],
['#880E4F', '#B71C1C', '#E65100', '#F57F17', '#827717', '#33691E', '#1B5E20', '#004D40'],
['#FF5252', '#FF6E40', '#FFAB40', '#FFD740', '#AEEA00', '#69F0AE', '#40C4FF', '#448AFF'],
['#FF80AB', '#FF4081', '#F50057', '#C51162', '#880E4F', '#4A148C', '#311B92', '#1A237E'],
['#EA80FC', '#E040FB', '#D500F9', '#AA00FF', '#6200EA', '#304FFE', '#2962FF', '#0091EA'],
['#8C9EFF', '#536DFE', '#3D5AFE', '#1976D2', '#0277BD', '#006064', '#004D40', '#1B5E20'],
];
const handleColorPickerOpen = (event: React.MouseEvent<HTMLElement>, index: number, isBaseline: boolean) => {
setColorPickerAnchor({ el: event.currentTarget, index, isBaseline });
};
const handleColorPickerClose = () => {
setColorPickerAnchor(null);
};
const handleColorSelect = (color: string) => {
if (colorPickerAnchor) {
if (colorPickerAnchor.isBaseline) {
handleBaselineChange('color', color);
} else {
handleLevelChange(colorPickerAnchor.index, 'color', color);
}
}
};
const handleShowAcrossIndicatorsChange = (checked: boolean) => {
setLocalSettings({ ...localSettings, showAcrossIndicators: checked });
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
bgcolor: 'background.paper',
borderRadius: 2,
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
},
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={onClose}
variant="outlined"
size="small"
sx={{
color: '#fff',
borderColor: 'rgba(255,255,255,0.5)',
'&:hover': {
borderColor: '#fff',
bgcolor: 'rgba(255,255,255,0.1)',
}
}}
>
</Button>
<Button
onClick={handleSave}
variant="contained"
size="small"
sx={{
bgcolor: '#fff',
color: '#1976d2',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.9)',
}
}}
>
</Button>
</Box>
</DialogTitle>
<Divider />
{/* 탭 */}
<Tabs
value={currentTab}
onChange={(e, newValue) => setCurrentTab(newValue)}
sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}
>
<Tab label="모습" />
<Tab label="좌표" />
<Tab label="보임" />
</Tabs>
<DialogContent sx={{ pt: 2 }}>
{/* 모습 탭 */}
{currentTab === 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* 기준선 설정 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
( )
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '50px 80px 100px 80px 80px',
gap: 1,
alignItems: 'center',
bgcolor: 'action.hover',
p: 1,
borderRadius: 1,
}}
>
<Checkbox
checked={true}
disabled={true}
size="small"
sx={{ justifySelf: 'center' }}
/>
<TextField
value="0"
disabled={true}
size="small"
type="number"
/>
<Box
onClick={(e) => handleColorPickerOpen(e, 0, true)}
sx={{
width: 60,
height: 30,
bgcolor: (localSettings.baseline || defaultBaseline).color,
borderRadius: 1,
border: '2px solid',
borderColor: 'primary.main',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
opacity: 0.8,
},
}}
>
<EditIcon sx={{ fontSize: 16, color: 'white', textShadow: '0 0 2px black' }} />
</Box>
<FormControl size="small" fullWidth>
<Select
value={(localSettings.baseline || defaultBaseline).lineStyle}
onChange={(e) => handleBaselineChange('lineStyle', e.target.value)}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed">- - -</MenuItem>
<MenuItem value="dotted">· · ·</MenuItem>
</Select>
</FormControl>
<FormControl size="small" fullWidth>
<Select
value={(localSettings.baseline || defaultBaseline).lineWidth}
onChange={(e) => handleBaselineChange('lineWidth', Number(e.target.value))}
>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
<MenuItem value={3}>3</MenuItem>
<MenuItem value={4}>4</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* 레벨 설정 */}
<Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '50px 80px 100px 80px 80px', gap: 1, mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 'bold', textAlign: 'center' }}>
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>
</Typography>
</Box>
{localSettings.levels.map((level, index) => (
<Box
key={index}
sx={{
display: 'grid',
gridTemplateColumns: '50px 80px 100px 80px 80px',
gap: 1,
alignItems: 'center',
mb: 0.5,
}}
>
<Checkbox
checked={level.enabled}
onChange={(e) => handleLevelChange(index, 'enabled', e.target.checked)}
size="small"
sx={{ justifySelf: 'center' }}
/>
<TextField
value={level.value}
onChange={(e) => handleLevelChange(index, 'value', Number(e.target.value))}
size="small"
type="number"
inputProps={{ min: 0 }}
/>
<Box
onClick={(e) => handleColorPickerOpen(e, index, false)}
sx={{
width: 60,
height: 30,
bgcolor: level.color,
borderRadius: 1,
border: '2px solid',
borderColor: 'primary.main',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
opacity: 0.8,
},
}}
>
<EditIcon sx={{ fontSize: 16, color: 'white', textShadow: '0 0 2px black' }} />
</Box>
<FormControl size="small" fullWidth>
<Select
value={level.lineStyle}
onChange={(e) => handleLevelChange(index, 'lineStyle', e.target.value)}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed">- - -</MenuItem>
<MenuItem value="dotted">· · ·</MenuItem>
</Select>
</FormControl>
<FormControl size="small" fullWidth>
<Select
value={level.lineWidth}
onChange={(e) => handleLevelChange(index, 'lineWidth', Number(e.target.value))}
>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
<MenuItem value={3}>3</MenuItem>
<MenuItem value={4}>4</MenuItem>
</Select>
</FormControl>
</Box>
))}
</Box>
{/* 보조지표까지 보기 + 삭제/초기화 버튼 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
<Checkbox
checked={localSettings.showAcrossIndicators || false}
onChange={(e) => handleShowAcrossIndicatorsChange(e.target.checked)}
size="small"
/>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}> </Typography>
<Box sx={{ flex: 1 }} />
{isEditingIndividual && onDelete && (
<Button
onClick={() => {
if (window.confirm('이 피보나치 타임존을 삭제하시겠습니까?')) {
onDelete();
onClose();
}
}}
variant="outlined"
color="error"
size="small"
>
</Button>
)}
<Button
onClick={handleReset}
variant="outlined"
color="secondary"
size="small"
>
</Button>
</Box>
</Box>
)}
{/* 좌표 탭 */}
{currentTab === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
.
</Typography>
</Box>
)}
{/* 보임 탭 */}
{currentTab === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Checkbox
checked={showBackground}
onChange={(e) => setShowBackground(e.target.checked)}
size="small"
/>
<Typography variant="body2"></Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Checkbox
checked={showLabels}
onChange={(e) => setShowLabels(e.target.checked)}
size="small"
/>
<Typography variant="body2"></Typography>
</Box>
</Box>
)}
{/* 색상 팔레트 팝오버 */}
<Popover
open={Boolean(colorPickerAnchor)}
anchorEl={colorPickerAnchor?.el}
onClose={handleColorPickerClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Box sx={{ p: 2, width: 240 }}>
{/* 색상 팔레트 */}
{colorPalette.map((row, rowIndex) => (
<Box key={rowIndex} sx={{ display: 'flex', gap: 0.5, mb: 0.5 }}>
{row.map((color, colIndex) => (
<Box
key={colIndex}
onClick={() => {
handleColorSelect(color);
handleColorPickerClose();
}}
sx={{
width: 24,
height: 24,
bgcolor: color,
cursor: 'pointer',
border: '1px solid rgba(0,0,0,0.1)',
'&:hover': {
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.3)',
},
}}
/>
))}
</Box>
))}
{/* 투명도 슬라이더 */}
<Box sx={{ mt: 2 }}>
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={colorPickerAnchor?.isBaseline
? 100
: (localSettings.levels[colorPickerAnchor?.index || 0]?.opacity || 100)
}
onChange={(e, value) => {
if (colorPickerAnchor && !colorPickerAnchor.isBaseline) {
handleLevelChange(colorPickerAnchor.index, 'opacity', value as number);
}
}}
min={0}
max={100}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
sx={{ width: '100%' }}
/>
</Box>
{/* 커스텀 색상 입력 */}
<Box sx={{ mt: 2, display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption"> :</Typography>
<input
type="text"
placeholder="#000000"
onChange={(e) => {
const color = e.target.value;
if (/^#[0-9A-F]{6}$/i.test(color)) {
handleColorSelect(color);
}
}}
style={{
flex: 1,
padding: '4px 8px',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px',
}}
/>
</Box>
</Box>
</Popover>
</DialogContent>
</Dialog>
);
};
export default FibonacciTimezoneDialog;
export { defaultBaseline, defaultLevels };
@@ -0,0 +1,372 @@
import React, { useRef, useEffect, useState } from 'react';
import { Box } from '@mui/material';
import { FibonacciTimezoneSettings, FibonacciLevel, FibonacciBaseline, defaultBaseline } from './FibonacciTimezoneDialog';
export interface FibonacciTimezoneObject {
id: string;
startTime: number; // Unix timestamp (seconds) - 기준점
endTime: number; // Unix timestamp (seconds) - 두 번째 클릭 지점 (방향 결정용)
baseInterval: number; // 방향 (1 = 오른쪽, -1 = 왼쪽)
settings: FibonacciTimezoneSettings;
}
interface FibonacciTimezoneOverlayProps {
objects: FibonacciTimezoneObject[];
chartWidth: number;
chartHeight: number; // 캔들차트 높이
totalChartHeight?: number; // 캔들차트 + 모든 보조지표 차트 높이 (보조지표까지 보기 옵션용)
timeScale: {
from: number;
to: number;
};
candleData: Array<{ time: string }>;
timeToX: (time: number) => number | null; // ✅ 차트의 timeToCoordinate 함수 전달 (null 가능)
onClick?: (objectId: string, event: React.MouseEvent) => void; // ✅ 클릭 핸들러
onDoubleClick?: (objectId: string) => void;
onDragStart?: (objectId: string, startX: number, startTime: number) => void;
isDrawing?: boolean; // 드로잉 중인지 여부
drawingStartTime?: number | null; // 드로잉 중 시작 시간
}
const FibonacciTimezoneOverlay: React.FC<FibonacciTimezoneOverlayProps> = ({
objects,
chartWidth,
chartHeight,
totalChartHeight,
timeScale,
candleData,
timeToX: timeToXProp, // ✅ prop으로 받은 함수
onClick,
onDoubleClick,
onDragStart,
isDrawing = false,
drawingStartTime = null,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
// 시간을 캔들 인덱스로 변환 (가장 가까운 캔들 찾기)
const timeToIndex = (time: number): number => {
if (candleData.length === 0) return -1;
let nearestIndex = 0;
let minDiff = Infinity;
for (let i = 0; i < candleData.length; i++) {
const candleTime = new Date(candleData[i].time).getTime() / 1000;
const diff = Math.abs(candleTime - time);
if (diff < minDiff) {
minDiff = diff;
nearestIndex = i;
}
}
return nearestIndex;
};
// 캔들 인덱스를 시간으로 변환
const indexToTime = (index: number): number => {
if (index < 0 || index >= candleData.length) return 0;
return new Date(candleData[index].time).getTime() / 1000;
};
// ✅ prop으로 받은 timeToX 함수 사용
const timeToX = timeToXProp;
// 선 스타일을 SVG stroke-dasharray로 변환
const getStrokeDasharray = (style: string): string => {
switch (style) {
case 'dashed':
return '5,5';
case 'dotted':
return '2,2';
default:
return '0';
}
};
const handleDoubleClick = (objectId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (onDoubleClick) {
onDoubleClick(objectId);
}
};
const handleMouseDown = (objectId: string, e: React.MouseEvent, startTime: number) => {
e.stopPropagation();
e.preventDefault(); // 기본 드래그 동작 방지
console.log(`🖱️ [오버레이] mouseDown - objectId: ${objectId}, startTime: ${startTime}`);
if (onDragStart) {
const startX = e.clientX;
onDragStart(objectId, startX, startTime);
}
};
// ✅ 최대 높이 계산 (보조지표까지 보기가 활성화된 피보나치가 있을 경우)
const maxHeight = objects.some(obj => obj.settings.showAcrossIndicators) && totalChartHeight
? totalChartHeight
: chartHeight;
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: maxHeight,
pointerEvents: 'none',
zIndex: 50,
overflow: 'visible', // ✅ 오버플로우 허용
}}
>
<svg
ref={svgRef}
width="100%"
height={maxHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
preserveAspectRatio="none"
>
{/* 드로잉 중일 때 기준선 표시 */}
{isDrawing && drawingStartTime !== null && (() => {
const x = timeToX(drawingStartTime);
if (x === null) return null;
return (
<g>
<line
x1={x}
y1={0}
x2={x}
y2={chartHeight}
stroke="rgba(255, 255, 0, 0.8)" // 노란색 기준선
strokeWidth={2}
strokeDasharray="5,5"
/>
<text
x={x + 5}
y={20}
fill="rgba(255, 255, 0, 0.8)"
fontSize="12px"
fontWeight="bold"
>
</text>
</g>
);
})()}
{/* 완성된 피보나치 타임존 표시 */}
{objects
.filter((obj) => obj.id !== 'temp-drawing') // 임시 드로잉 제외
.map((obj) => {
const startIndex = timeToIndex(obj.startTime);
const endIndex = timeToIndex(obj.endTime);
if (startIndex === -1 || endIndex === -1) {
return null;
}
// baseInterval은 방향 (1 = 오른쪽, -1 = 왼쪽)
const direction = obj.baseInterval;
const startX = timeToX(obj.startTime);
// ✅ 보조지표까지 보기 옵션에 따라 높이 결정
const effectiveHeight = obj.settings.showAcrossIndicators && totalChartHeight
? totalChartHeight
: chartHeight;
// 디버깅: 설정 확인
console.log(`🎨 [피보나치 렌더링] ID: ${obj.id}, 활성화된 레벨:`, obj.settings.levels.filter(l => l.enabled).map(l => l.value), `showAcrossIndicators: ${obj.settings.showAcrossIndicators}, effectiveHeight: ${effectiveHeight}`);
return (
<g
key={obj.id}
>
{/* ✅ 기준선 (baseline) 렌더링 */}
{(() => {
// ✅ baseline이 없으면 기본값 사용 (기존 저장된 데이터 호환)
const baseline = obj.settings.baseline || defaultBaseline;
console.log(`🎨 [피보나치 기준선 렌더링] ID: ${obj.id}, baseline:`, baseline);
const x = timeToX(obj.startTime);
if (x === null) return null;
return (
<g key={`${obj.id}-baseline`}>
{/* 투명한 클릭 영역 (드래그하기 쉽도록 넓게) */}
<line
x1={x}
y1={0}
x2={x}
y2={effectiveHeight}
stroke="transparent"
strokeWidth={40}
style={{ pointerEvents: 'auto', cursor: 'move' }}
onClick={(e) => {
e.stopPropagation();
if (onClick) {
onClick(obj.id, e);
}
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
if (onDragStart) {
const startX = e.clientX;
onDragStart(obj.id, startX, obj.startTime);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (onDoubleClick) {
onDoubleClick(obj.id);
}
}}
/>
{/* 기준선 */}
<line
x1={x}
y1={0}
x2={x}
y2={effectiveHeight}
stroke={baseline.color}
strokeWidth={baseline.lineWidth}
strokeDasharray={getStrokeDasharray(baseline.lineStyle)}
opacity={0.8}
style={{ pointerEvents: 'none' }}
/>
{/* 레이블 */}
<text
x={x + 5}
y={20}
fill={baseline.color}
fontSize="11px"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
0
</text>
</g>
);
})()}
{/* 피보나치 레벨 선들 */}
{obj.settings.levels
.filter((level) => level.enabled)
.map((level, index) => {
// 캔들 인덱스 계산: 기준점 + (레벨 값 * 방향)
// 레벨 값이 직접 캔들 간격이 됨 (예: 1, 2, 3, 5, 8...)
const targetIndex = startIndex + (level.value * direction);
// ✅ 시간 계산 (범위를 벗어나도 추정 가능)
let time: number;
if (targetIndex >= 0 && targetIndex < candleData.length) {
// 범위 내: 정확한 캔들 시간 사용
time = indexToTime(targetIndex);
console.log(`📍 [오버레이] 레벨 ${level.value}: 범위 내 정확한 시간 사용, targetIndex=${targetIndex}, time=${time}`);
} else if (candleData.length >= 2) {
// 범위 외: 캔들 간격을 사용하여 시간 추정
const lastIndex = candleData.length - 1;
const firstTime = new Date(candleData[0].time).getTime() / 1000;
const lastTime = new Date(candleData[lastIndex].time).getTime() / 1000;
const candleInterval = (lastTime - firstTime) / lastIndex; // 평균 캔들 간격
const startTime = new Date(candleData[Math.max(0, Math.min(startIndex, lastIndex))].time).getTime() / 1000;
time = startTime + (level.value * direction * candleInterval);
console.log(`📍 [오버레이] 레벨 ${level.value}: 범위 외 시간 추정, targetIndex=${targetIndex}, time=${time}, interval=${candleInterval}`);
} else {
// 캔들 데이터가 부족하면 건너뛰기
console.log(`❌ [오버레이] 레벨 ${level.value}: 캔들 데이터 부족`);
return null;
}
const x = timeToX(time);
console.log(`📏 [오버레이] 레벨 ${level.value}: time=${time}, x=${x}, chartWidth=${chartWidth}`);
// ✅ x가 null이면 건너뛰기 (범위 체크는 매우 관대하게)
if (x === null) {
console.log(`❌ [오버레이] 레벨 ${level.value}: x가 null`);
return null;
}
// ✅ 매우 먼 거리만 제외 (차트의 10배 범위까지 허용)
const maxDistance = chartWidth * 10;
if (x < -maxDistance || x > chartWidth + maxDistance) {
console.log(`❌ [오버레이] 레벨 ${level.value}: x가 너무 멀리 벗어남 (x=${x}, 허용범위: ${-maxDistance} ~ ${chartWidth + maxDistance})`);
return null;
}
console.log(`✅ [오버레이] 레벨 ${level.value}: 렌더링 (x=${x})`);
return (
<g key={`${obj.id}-level-${index}`}>
{/* 투명한 클릭 영역 (드래그하기 쉽도록 넓게) */}
<line
x1={x}
y1={0}
x2={x}
y2={effectiveHeight}
stroke="transparent"
strokeWidth={40} // 넓은 클릭 영역
style={{ pointerEvents: 'auto', cursor: 'move' }}
onClick={(e) => {
e.stopPropagation();
if (onClick) {
onClick(obj.id, e);
}
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
console.log(`🖱️ [투명 영역] mouseDown - objectId: ${obj.id}, level: ${level.value}, x: ${x}`);
if (onDragStart) {
const startX = e.clientX;
onDragStart(obj.id, startX, obj.startTime);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
console.log(`🖱️ [투명 영역] doubleClick - objectId: ${obj.id}, level: ${level.value}`);
if (onDoubleClick) {
onDoubleClick(obj.id);
}
}}
/>
{/* 실제 수직선 */}
<line
x1={x}
y1={0}
x2={x}
y2={effectiveHeight}
stroke={level.color}
strokeWidth={level.lineWidth}
strokeDasharray={getStrokeDasharray(level.lineStyle)}
opacity={0.8}
style={{ pointerEvents: 'none' }} // 투명 영역만 이벤트 받도록
/>
{/* 레이블 */}
<text
x={x + 5}
y={20}
fill={level.color}
fontSize="11px"
fontWeight="bold"
style={{ pointerEvents: 'none' }} // 투명 영역만 이벤트 받도록
>
{level.value}
</text>
</g>
);
})}
</g>
);
})}
</svg>
</Box>
);
};
export default FibonacciTimezoneOverlay;
@@ -0,0 +1,285 @@
import React, { useState } from 'react';
import {
Box,
IconButton,
Select,
MenuItem,
Popover,
Tooltip,
Divider,
ClickAwayListener,
} from '@mui/material';
import {
DragIndicator,
Palette,
Settings,
Delete,
Visibility,
VisibilityOff,
Lock,
LockOpen,
} from '@mui/icons-material';
import { FibonacciTimezoneSettings } from './FibonacciTimezoneDialog';
interface FibonacciToolbarProps {
open: boolean;
anchorPosition: { top: number; left: number } | null;
settings: FibonacciTimezoneSettings;
onClose: () => void;
onColorChange: (color: string) => void;
onLineWidthChange: (width: number) => void;
onLineStyleChange: (style: 'solid' | 'dashed' | 'dotted') => void;
onSettingsClick: () => void;
onDelete: () => void;
onVisibilityToggle: () => void;
isVisible: boolean;
}
const FibonacciToolbar: React.FC<FibonacciToolbarProps> = ({
open,
anchorPosition,
settings,
onClose,
onColorChange,
onLineWidthChange,
onLineStyleChange,
onSettingsClick,
onDelete,
onVisibilityToggle,
isVisible,
}) => {
const [colorPickerAnchor, setColorPickerAnchor] = useState<HTMLElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// 색상 팔레트
const colorPalette = [
['#000000', '#434651', '#666666', '#999999', '#B2B5BE', '#D1D4DC', '#E1E3EB', '#F0F3FA'],
['#F23645', '#FF9800', '#FDD835', '#26A69A', '#00BCD4', '#2196F3', '#3F51B5', '#9C27B0'],
['#880E4F', '#B71C1C', '#E65100', '#F57F17', '#827717', '#33691E', '#1B5E20', '#004D40'],
];
const handleColorPickerOpen = (event: React.MouseEvent<HTMLElement>) => {
setColorPickerAnchor(event.currentTarget);
};
const handleColorPickerClose = () => {
setColorPickerAnchor(null);
};
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
if (anchorPosition) {
setDragOffset({
x: e.clientX - anchorPosition.left,
y: e.clientY - anchorPosition.top,
});
}
};
if (!open || !anchorPosition) return null;
return (
<>
<ClickAwayListener
onClickAway={(event) => {
// 색상 팔레트 팝오버가 열려있으면 무시
if (colorPickerAnchor) return;
onClose();
}}
>
<Box
sx={{
position: 'fixed',
top: anchorPosition.top,
left: anchorPosition.left,
zIndex: 1300,
bgcolor: 'background.paper',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.5,
}}
>
{/* 드래그 핸들 */}
<Box
onMouseDown={handleMouseDown}
sx={{
cursor: 'move',
display: 'flex',
alignItems: 'center',
px: 0.5,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<DragIndicator sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 템플릿 */}
<Tooltip title="템플릿">
<Select
size="small"
value="default"
sx={{
minWidth: 80,
height: 28,
fontSize: '12px',
'.MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
>
<MenuItem value="default"></MenuItem>
<MenuItem value="custom1"> 1</MenuItem>
<MenuItem value="custom2"> 2</MenuItem>
</Select>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 색상 선택 */}
<Tooltip title="색상">
<IconButton
size="small"
onClick={handleColorPickerOpen}
sx={{
width: 28,
height: 28,
bgcolor: settings.baseline?.color || '#2196F3',
border: '2px solid',
borderColor: 'divider',
'&:hover': {
opacity: 0.8,
},
}}
>
<Palette sx={{ fontSize: 0, color: 'transparent' }} />
</IconButton>
</Tooltip>
{/* 선 굵기 */}
<Tooltip title="선 굵기">
<Select
size="small"
value={settings.baseline?.lineWidth || 2}
onChange={(e) => onLineWidthChange(Number(e.target.value))}
sx={{
minWidth: 50,
height: 28,
fontSize: '12px',
'.MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
<MenuItem value={3}>3</MenuItem>
<MenuItem value={4}>4</MenuItem>
</Select>
</Tooltip>
{/* 선 유형 */}
<Tooltip title="선 유형">
<Select
size="small"
value={settings.baseline?.lineStyle || 'solid'}
onChange={(e) => onLineStyleChange(e.target.value as any)}
sx={{
minWidth: 70,
height: 28,
fontSize: '12px',
'.MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed"></MenuItem>
<MenuItem value="dotted"></MenuItem>
</Select>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 보이기/숨기기 */}
<Tooltip title={isVisible ? '숨기기' : '보이기'}>
<IconButton size="small" onClick={onVisibilityToggle} sx={{ width: 28, height: 28 }}>
{isVisible ? (
<Visibility sx={{ fontSize: 18 }} />
) : (
<VisibilityOff sx={{ fontSize: 18 }} />
)}
</IconButton>
</Tooltip>
{/* 설정 */}
<Tooltip title="설정">
<IconButton size="small" onClick={onSettingsClick} sx={{ width: 28, height: 28 }}>
<Settings sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{/* 삭제 */}
<Tooltip title="삭제">
<IconButton
size="small"
onClick={() => {
if (window.confirm('이 피보나치 타임존을 삭제하시겠습니까?')) {
onDelete();
onClose();
}
}}
sx={{ width: 28, height: 28, color: 'error.main' }}
>
<Delete sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Box>
</ClickAwayListener>
{/* 색상 팔레트 팝오버 */}
<Popover
open={Boolean(colorPickerAnchor)}
anchorEl={colorPickerAnchor}
onClose={handleColorPickerClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Box sx={{ p: 2, width: 240 }}>
{colorPalette.map((row, rowIndex) => (
<Box key={rowIndex} sx={{ display: 'flex', gap: 0.5, mb: 0.5 }}>
{row.map((color, colIndex) => (
<Box
key={colIndex}
onClick={() => {
onColorChange(color);
handleColorPickerClose();
}}
sx={{
width: 24,
height: 24,
bgcolor: color,
cursor: 'pointer',
border: '1px solid rgba(0,0,0,0.1)',
'&:hover': {
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.3)',
},
}}
/>
))}
</Box>
))}
</Box>
</Popover>
</>
);
};
export default FibonacciToolbar;
@@ -0,0 +1,158 @@
import React, { useRef } from 'react';
import { Box } from '@mui/material';
export interface FibonacciWedgeObject {
id: string;
type: 'fibonacciWedge';
startTime: number;
endTime: number;
startPrice: number;
endPrice: number;
settings: {
color: string;
lineWidth: number;
lineStyle: 'solid' | 'dashed' | 'dotted';
fillOpacity: number;
levels: number[];
showLabels: boolean;
};
}
interface FibonacciWedgeProps {
objects: FibonacciWedgeObject[];
chartWidth: number;
chartHeight: number;
totalChartHeight?: number;
timeToX: (time: number) => number | null;
priceToY: (price: number) => number | null;
onClick?: (objectId: string, event: React.MouseEvent) => void;
onDoubleClick?: (objectId: string) => void;
onDragStart?: (objectId: string, startX: number, startTime: number) => void;
}
const FibonacciWedge: React.FC<FibonacciWedgeProps> = ({
objects,
chartWidth,
chartHeight,
totalChartHeight,
timeToX,
priceToY,
onClick,
onDoubleClick,
onDragStart,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const calculateWedgeLines = (startPrice: number, endPrice: number) => {
const priceRange = endPrice - startPrice;
const levels = [0.382, 0.5, 0.618]; // 기본 피보나치 웻지 레벨
return levels.map(level => ({
level,
price: startPrice + (priceRange * level)
}));
};
const renderWedge = (wedgeObject: FibonacciWedgeObject) => {
const startX = timeToX(wedgeObject.startTime);
const endX = timeToX(wedgeObject.endTime);
const startY = priceToY(wedgeObject.startPrice);
const endY = priceToY(wedgeObject.endPrice);
if (startX === null || endX === null || startY === null || endY === null) return null;
const wedgeLines = calculateWedgeLines(wedgeObject.startPrice, wedgeObject.endPrice);
return (
<g key={wedgeObject.id}>
{/* 웻지의 기본 삼각형 형태 */}
<path
d={`M ${startX} ${startY} L ${endX} ${startY} L ${endX} ${endY} Z`}
fill={wedgeObject.settings.color}
fillOpacity={wedgeObject.settings.fillOpacity}
stroke={wedgeObject.settings.color}
strokeWidth={wedgeObject.settings.lineWidth}
strokeDasharray={
wedgeObject.settings.lineStyle === 'dashed'
? '5,5'
: wedgeObject.settings.lineStyle === 'dotted'
? '2,2'
: undefined
}
opacity={0.6}
style={{ cursor: 'pointer' }}
onClick={(e) => onClick?.(wedgeObject.id, e)}
onDoubleClick={() => onDoubleClick?.(wedgeObject.id)}
/>
{/* 피보나치 레벨 라인들 */}
{wedgeLines.map((wedgeLine, index) => {
const lineY = priceToY(wedgeLine.price);
if (lineY === null) return null;
const strokeDasharray = wedgeObject.settings.lineStyle === 'dashed'
? '5,5'
: wedgeObject.settings.lineStyle === 'dotted'
? '2,2'
: undefined;
return (
<g key={index}>
<line
x1={startX}
y1={startY}
x2={endX}
y2={lineY}
stroke={wedgeObject.settings.color}
strokeWidth={wedgeObject.settings.lineWidth}
strokeDasharray={strokeDasharray}
opacity={0.8}
style={{ cursor: 'pointer' }}
onClick={(e) => onClick?.(wedgeObject.id, e)}
onDoubleClick={() => onDoubleClick?.(wedgeObject.id)}
/>
{wedgeObject.settings.showLabels && (
<text
x={endX + 5}
y={lineY}
fill={wedgeObject.settings.color}
fontSize={10}
fontFamily="Arial, sans-serif"
>
{`${(wedgeLine.level * 100).toFixed(1)}%`}
</text>
)}
</g>
);
})}
</g>
);
};
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: chartWidth,
height: totalChartHeight || chartHeight,
pointerEvents: 'none',
zIndex: 100,
}}
>
<svg
ref={svgRef}
width={chartWidth}
height={totalChartHeight || chartHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<g style={{ pointerEvents: 'all' }}>
{objects.map(renderWedge)}
</g>
</svg>
</Box>
);
};
export default FibonacciWedge;
@@ -0,0 +1,721 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Button,
Box,
Typography,
TextField,
Switch,
Slider,
Divider,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import { Close, Refresh } from '@mui/icons-material';
import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext';
import { useChartColors } from '../contexts/ChartColorContext';
import type { MultiChartIchimokuState } from '../stores/useDashboardStore';
import {
ichimokuDialogDefaultsFromStore,
ichimokuDialogToStore,
ichimokuStoreToDialogColors,
ichimokuStoreToDialogSettings,
} from '../utils/multiChartToolbarSettings';
import DraggablePaper from './DraggablePaper';
interface IchimokuSettingsDialogProps {
open: boolean;
onClose: () => void;
/** 멀티차트: 대시보드 스토어만 갱신(전역 지표/색상 컨텍스트는 건드리지 않음) */
multiChartToolbar?: {
ichimoku: MultiChartIchimokuState;
setIchimoku: (
value: MultiChartIchimokuState | ((prev: MultiChartIchimokuState) => MultiChartIchimokuState)
) => void;
};
}
interface IchimokuLine {
id: string;
label: string;
description: string;
enabledKey: keyof typeof defaultLocalSettings;
periodKey: keyof typeof defaultLocalSettings;
colorKey: keyof typeof defaultLocalColors;
widthKey: keyof typeof defaultLocalColors;
}
const defaultLocalSettings = {
ichimokuTenkan: false,
ichimokuKijun: false,
ichimokuChikou: false,
ichimokuSenkouA: false,
ichimokuSenkouB: false,
ichimokuTenkanPeriod: 9,
ichimokuKijunPeriod: 26,
ichimokuChikouPeriod: 26,
ichimokuSenkouAPeriod: 26,
ichimokuSenkouBPeriod: 52,
};
const defaultLocalColors = {
ichimokuTenkan: '#EF5350',
ichimokuKijun: '#42A5F5',
ichimokuChikou: '#66BB6A',
ichimokuSenkouA: '#26A69A',
ichimokuSenkouB: '#EF5350',
ichimokuTenkanWidth: 1,
ichimokuKijunWidth: 1.5,
ichimokuChikouWidth: 1,
ichimokuSenkouAWidth: 1,
ichimokuSenkouBWidth: 1,
ichimokuCloudBullish: 'rgba(239, 83, 80, 0.2)',
ichimokuCloudBearish: 'rgba(38, 166, 154, 0.2)',
};
const ichimokuLines: IchimokuLine[] = [
{
id: 'tenkan',
label: '전환선',
description: '단기',
enabledKey: 'ichimokuTenkan',
periodKey: 'ichimokuTenkanPeriod',
colorKey: 'ichimokuTenkan',
widthKey: 'ichimokuTenkanWidth',
},
{
id: 'kijun',
label: '기준선',
description: '중기',
enabledKey: 'ichimokuKijun',
periodKey: 'ichimokuKijunPeriod',
colorKey: 'ichimokuKijun',
widthKey: 'ichimokuKijunWidth',
},
{
id: 'chikou',
label: '후행스팬',
description: '후행',
enabledKey: 'ichimokuChikou',
periodKey: 'ichimokuChikouPeriod',
colorKey: 'ichimokuChikou',
widthKey: 'ichimokuChikouWidth',
},
{
id: 'senkouA',
label: '선행스팬1',
description: '선행',
enabledKey: 'ichimokuSenkouA',
periodKey: 'ichimokuSenkouAPeriod',
colorKey: 'ichimokuSenkouA',
widthKey: 'ichimokuSenkouAWidth',
},
{
id: 'senkouB',
label: '선행스팬2',
description: '장기',
enabledKey: 'ichimokuSenkouB',
periodKey: 'ichimokuSenkouBPeriod',
colorKey: 'ichimokuSenkouB',
widthKey: 'ichimokuSenkouBWidth',
},
];
const IchimokuSettingsDialog: React.FC<IchimokuSettingsDialogProps> = ({ open, onClose, multiChartToolbar }) => {
const { settings, updateSettings } = useIndicatorVisibility();
const { colors, updateColors } = useChartColors();
const [localSettings, setLocalSettings] = useState(defaultLocalSettings);
const [localColors, setLocalColors] = useState(defaultLocalColors);
const [cloudBullishOpacity, setCloudBullishOpacity] = useState<number>(0.2);
const [cloudBearishOpacity, setCloudBearishOpacity] = useState<number>(0.2);
// rgba에서 alpha 값 추출 함수
const extractOpacity = (color: string): number => {
const match = color.match(/rgba?\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
return match ? parseFloat(match[1]) : 0.2;
};
// rgba에서 rgb hex 추출 함수
const extractRgbFromRgba = (rgba: string): string => {
const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1]).toString(16).padStart(2, '0');
const g = parseInt(match[2]).toString(16).padStart(2, '0');
const b = parseInt(match[3]).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
}
return '#000000';
};
// hex to rgba 변환 함수
const hexToRgba = (hex: string, opacity: number): string => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
return `rgba(0, 0, 0, ${opacity})`;
};
useEffect(() => {
if (!open || !multiChartToolbar) return;
const ich = multiChartToolbar.ichimoku;
setLocalSettings(ichimokuStoreToDialogSettings(ich));
setLocalColors(ichimokuStoreToDialogColors(ich));
setCloudBullishOpacity(extractOpacity(ich.cloudBullish));
setCloudBearishOpacity(extractOpacity(ich.cloudBearish));
}, [open, multiChartToolbar]);
useEffect(() => {
if (!open || multiChartToolbar) return;
setLocalSettings({
ichimokuTenkan: settings.ichimokuTenkan,
ichimokuKijun: settings.ichimokuKijun,
ichimokuChikou: settings.ichimokuChikou,
ichimokuSenkouA: settings.ichimokuSenkouA,
ichimokuSenkouB: settings.ichimokuSenkouB,
ichimokuTenkanPeriod: settings.ichimokuTenkanPeriod || 9,
ichimokuKijunPeriod: settings.ichimokuKijunPeriod || 26,
ichimokuChikouPeriod: settings.ichimokuChikouPeriod || 26,
ichimokuSenkouAPeriod: settings.ichimokuSenkouAPeriod || 26,
ichimokuSenkouBPeriod: settings.ichimokuSenkouBPeriod || 52,
});
setLocalColors({
ichimokuTenkan: colors.ichimokuTenkan,
ichimokuKijun: colors.ichimokuKijun,
ichimokuChikou: colors.ichimokuChikou,
ichimokuSenkouA: colors.ichimokuSenkouA,
ichimokuSenkouB: colors.ichimokuSenkouB,
ichimokuTenkanWidth: colors.ichimokuTenkanWidth,
ichimokuKijunWidth: colors.ichimokuKijunWidth,
ichimokuChikouWidth: colors.ichimokuChikouWidth,
ichimokuSenkouAWidth: colors.ichimokuSenkouAWidth,
ichimokuSenkouBWidth: colors.ichimokuSenkouBWidth,
ichimokuCloudBullish: colors.ichimokuCloudBullish,
ichimokuCloudBearish: colors.ichimokuCloudBearish,
});
setCloudBullishOpacity(extractOpacity(colors.ichimokuCloudBullish));
setCloudBearishOpacity(extractOpacity(colors.ichimokuCloudBearish));
}, [open, multiChartToolbar, settings, colors]);
const handleSave = () => {
if (multiChartToolbar) {
multiChartToolbar.setIchimoku(ichimokuDialogToStore(localSettings, localColors));
} else {
updateSettings({
...settings,
...localSettings,
});
updateColors(localColors);
}
onClose();
};
const handleReset = () => {
if (multiChartToolbar) {
const { localSettings: ls, localColors: lc } = ichimokuDialogDefaultsFromStore();
setLocalSettings(ls);
setLocalColors(lc);
setCloudBullishOpacity(extractOpacity(lc.ichimokuCloudBullish));
setCloudBearishOpacity(extractOpacity(lc.ichimokuCloudBearish));
} else {
setLocalSettings(defaultLocalSettings);
setLocalColors(defaultLocalColors);
setCloudBullishOpacity(0.2);
setCloudBearishOpacity(0.2);
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="lg"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
borderRadius: 2,
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<Tooltip title="초기화">
<IconButton onClick={handleReset} size="small" sx={{ color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.1)' } }}>
<Refresh />
</IconButton>
</Tooltip>
</Box>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
sx={{
color: '#fff',
borderColor: '#fff',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.1)',
borderColor: '#fff'
},
minWidth: '70px',
fontSize: '13px',
fontWeight: 600,
}}
variant="outlined"
size="small"
>
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleSave();
}}
sx={{
bgcolor: '#fff',
color: '#1976d2',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.9)'
},
minWidth: '70px',
fontSize: '13px',
fontWeight: 600,
}}
variant="contained"
size="small"
>
</Button>
</Box>
</DialogTitle>
<Divider />
<DialogContent sx={{ pt: 2 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
p: 1.5,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(33, 150, 243, 0.15)' : '#e3f2fd',
borderRadius: 1,
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(33, 150, 243, 0.3)' : '#90caf9'
}}>
<Typography variant="body2" sx={{ fontSize: '12px' }}>
💡 <strong>, , </strong>
</Typography>
</Box>
{/* 헤더 행 */}
<Box sx={{
display: 'grid',
gridTemplateColumns: '60px 120px 100px 90px 150px 1fr 100px',
gap: 1,
p: 1.5,
mb: 1,
bgcolor: (theme) => theme.palette.mode === 'dark' ? '#616161' : '#9e9e9e',
borderRadius: 1,
}}>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}>ON/OFF</Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}> </Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
</Box>
{/* 각 일목균형표 구성요소 */}
{ichimokuLines.map((line) => (
<Box
key={line.id}
sx={{
display: 'grid',
gridTemplateColumns: '60px 120px 100px 90px 150px 1fr 100px',
gap: 1,
p: 1.5,
mb: 1,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
borderRadius: 1,
alignItems: 'center',
transition: 'all 0.2s',
}}
>
{/* 토글 스위치 */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Switch
checked={localSettings[line.enabledKey] as boolean}
onChange={(e) => setLocalSettings({ ...localSettings, [line.enabledKey]: e.target.checked })}
size="small"
/>
</Box>
{/* 이름 */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Typography
variant="body2"
fontWeight={localSettings[line.enabledKey] ? 'bold' : 'normal'}
color={localSettings[line.enabledKey] ? 'text.primary' : 'text.secondary'}
sx={{ fontSize: '13px' }}
>
{line.label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '10px' }}>
{line.description}
</Typography>
</Box>
{/* 기간 */}
<TextField
type="number"
value={localSettings[line.periodKey] as number}
onChange={(e) => setLocalSettings({ ...localSettings, [line.periodKey]: Math.max(1, Number(e.target.value)) })}
disabled={!(localSettings[line.enabledKey] as boolean)}
size="small"
inputProps={{ min: 1, max: 200 }}
sx={{
'& .MuiInputBase-input': {
textAlign: 'center',
fontSize: '0.875rem',
padding: '6px 8px',
}
}}
/>
{/* 색상 */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
bgcolor: localColors[line.colorKey] as string,
border: '2px solid',
borderColor: localSettings[line.enabledKey] ? '#1976d2' : '#ccc',
cursor: localSettings[line.enabledKey] ? 'pointer' : 'not-allowed',
opacity: localSettings[line.enabledKey] ? 1 : 0.5,
position: 'relative',
'&:hover': localSettings[line.enabledKey] ? {
boxShadow: '0 0 8px rgba(25, 118, 210, 0.6)',
transform: 'scale(1.05)',
} : {},
transition: 'all 0.2s',
}}
>
{localSettings[line.enabledKey] && (
<input
type="color"
value={localColors[line.colorKey] as string}
onChange={(e) => setLocalColors({ ...localColors, [line.colorKey]: e.target.value })}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: 'pointer',
border: 'none',
}}
/>
)}
</Box>
</Box>
{/* 선 굵기 슬라이더 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1 }}>
<Slider
value={localColors[line.widthKey] as number}
onChange={(_, value) => setLocalColors({ ...localColors, [line.widthKey]: value as number })}
disabled={!(localSettings[line.enabledKey] as boolean)}
min={0.5}
max={5}
step={0.5}
sx={{ flex: 1 }}
/>
<Typography variant="caption" sx={{ minWidth: 35, textAlign: 'right', fontSize: '11px' }}>
{(localColors[line.widthKey] as number).toFixed(1)}px
</Typography>
</Box>
{/* 선 유형 (고정) */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Select
value="실선"
size="small"
disabled
sx={{
width: '100%',
'& .MuiSelect-select': {
py: 0.5,
fontSize: '0.875rem',
}
}}
>
<MenuItem value="실선"></MenuItem>
</Select>
</Box>
{/* 미리보기 */}
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Box
sx={{
width: 60,
height: (localColors[line.widthKey] as number) * 2 + 2,
maxHeight: 12,
backgroundColor: localSettings[line.enabledKey] ? localColors[line.colorKey] : '#ccc',
borderRadius: 0.5,
}}
/>
</Box>
</Box>
))}
<Divider sx={{ my: 2 }} />
{/* 구름 영역 색상 설정 */}
<Typography variant="subtitle2" fontWeight="bold" sx={{ mb: 2 }}>
🌈
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
p: 1,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 193, 7, 0.15)' : '#fff3cd',
borderRadius: 1,
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 193, 7, 0.3)' : '#ffc107'
}}>
<Typography variant="caption" sx={{ fontSize: '11px' }}>
1 2 .
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
{/* 상승 구름 */}
<Box sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'action.hover' }}>
<Typography variant="body2" fontWeight="bold" color="success.main" sx={{ mb: 2 }}>
🟢 (1 &gt; 2)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box
sx={{
width: 50,
height: 50,
position: 'relative',
borderRadius: 1,
border: '2px solid #1976d2',
cursor: 'pointer',
overflow: 'hidden',
background: `
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)
`,
backgroundSize: '10px 10px',
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: localColors.ichimokuCloudBullish,
}}
/>
<input
type="color"
value={extractRgbFromRgba(localColors.ichimokuCloudBullish)}
onChange={(e) => {
const rgbaColor = hexToRgba(e.target.value, cloudBullishOpacity);
setLocalColors({ ...localColors, ichimokuCloudBullish: rgbaColor });
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: 'pointer',
border: 'none',
}}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '10px' }}>
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.7rem', fontFamily: 'monospace' }}>
{localColors.ichimokuCloudBullish}
</Typography>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block', fontSize: '11px' }}>
: {(cloudBullishOpacity * 100).toFixed(0)}%
</Typography>
<Slider
value={cloudBullishOpacity}
onChange={(_, value) => {
const newOpacity = value as number;
setCloudBullishOpacity(newOpacity);
const baseColor = extractRgbFromRgba(localColors.ichimokuCloudBullish);
const rgbaColor = hexToRgba(baseColor, newOpacity);
setLocalColors({ ...localColors, ichimokuCloudBullish: rgbaColor });
}}
min={0.05}
max={0.8}
step={0.05}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${(value * 100).toFixed(0)}%`}
size="small"
/>
</Box>
</Box>
{/* 하락 구름 */}
<Box sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'action.hover' }}>
<Typography variant="body2" fontWeight="bold" color="error.main" sx={{ mb: 2 }}>
🔴 (1 &lt; 2)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box
sx={{
width: 50,
height: 50,
position: 'relative',
borderRadius: 1,
border: '2px solid #1976d2',
cursor: 'pointer',
overflow: 'hidden',
background: `
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)
`,
backgroundSize: '10px 10px',
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: localColors.ichimokuCloudBearish,
}}
/>
<input
type="color"
value={extractRgbFromRgba(localColors.ichimokuCloudBearish)}
onChange={(e) => {
const rgbaColor = hexToRgba(e.target.value, cloudBearishOpacity);
setLocalColors({ ...localColors, ichimokuCloudBearish: rgbaColor });
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: 'pointer',
border: 'none',
}}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '10px' }}>
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.7rem', fontFamily: 'monospace' }}>
{localColors.ichimokuCloudBearish}
</Typography>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block', fontSize: '11px' }}>
: {(cloudBearishOpacity * 100).toFixed(0)}%
</Typography>
<Slider
value={cloudBearishOpacity}
onChange={(_, value) => {
const newOpacity = value as number;
setCloudBearishOpacity(newOpacity);
const baseColor = extractRgbFromRgba(localColors.ichimokuCloudBearish);
const rgbaColor = hexToRgba(baseColor, newOpacity);
setLocalColors({ ...localColors, ichimokuCloudBearish: rgbaColor });
}}
min={0.05}
max={0.8}
step={0.05}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${(value * 100).toFixed(0)}%`}
size="small"
/>
</Box>
</Box>
</Box>
</DialogContent>
</Dialog>
);
};
export default IchimokuSettingsDialog;
@@ -0,0 +1,305 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Switch,
Slider,
Paper,
Grid,
Button,
Tooltip,
Alert,
} from '@mui/material';
import { Refresh } from '@mui/icons-material';
import { useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext';
import { useChartColors } from '../contexts/ChartColorContext';
// 색상 선택 팔레트
const colorPalette = [
'#EF5350', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4',
'#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', '#FF5722',
'#795548', '#9E9E9E', '#607D8B', '#000000', '#26A69A', '#42A5F5', '#66BB6A', '#FFCA28',
];
const IchimokuSettingsPanel: React.FC = () => {
const { settings, updateSettings } = useIndicatorVisibility();
const { colors, updateColors } = useChartColors();
const [colorPickerTarget, setColorPickerTarget] = useState<string | null>(null);
// 색상 미리보기 박스
const ColorBox: React.FC<{ colorKey: string }> = ({ colorKey }) => {
const color = (colors as any)[colorKey] || '#666';
return (
<Box sx={{ position: 'relative' }}>
<Box
onClick={() => setColorPickerTarget(colorPickerTarget === colorKey ? null : colorKey)}
sx={{
width: 28,
height: 28,
backgroundColor: color,
borderRadius: 1,
border: '2px solid',
borderColor: 'divider',
cursor: 'pointer',
'&:hover': {
boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.5)',
},
}}
/>
{/* 색상 팔레트 팝업 */}
{colorPickerTarget === colorKey && (
<Paper
sx={{
position: 'absolute',
top: 32,
left: 0,
zIndex: 1000,
p: 1,
display: 'grid',
gridTemplateColumns: 'repeat(8, 1fr)',
gap: 0.5,
width: 200,
boxShadow: 3,
}}
>
{colorPalette.map((c) => (
<Box
key={c}
onClick={() => {
updateColors({ [colorKey]: c });
setColorPickerTarget(null);
}}
sx={{
width: 20,
height: 20,
backgroundColor: c,
borderRadius: 0.5,
border: color === c ? '2px solid #1976d2' : '1px solid #ccc',
cursor: 'pointer',
'&:hover': { transform: 'scale(1.2)' },
}}
/>
))}
<Box sx={{ gridColumn: 'span 8', mt: 1 }}>
<input
type="color"
value={color}
onChange={(e) => {
updateColors({ [colorKey]: e.target.value });
setColorPickerTarget(null);
}}
style={{ width: '100%', height: 24, cursor: 'pointer', border: 'none' }}
/>
</Box>
</Paper>
)}
</Box>
);
};
const handleReset = () => {
updateSettings({
ichimokuTenkan: false,
ichimokuKijun: false,
ichimokuChikou: false,
ichimokuSenkouA: false,
ichimokuSenkouB: false,
ichimokuTenkanPeriod: 9,
ichimokuKijunPeriod: 26,
ichimokuSenkouBPeriod: 52,
});
updateColors({
ichimokuTenkan: '#EF5350',
ichimokuKijun: '#42A5F5',
ichimokuChikou: '#66BB6A',
ichimokuSenkouA: '#26A69A',
ichimokuSenkouB: '#EF5350',
ichimokuTenkanWidth: 1,
ichimokuKijunWidth: 1.5,
ichimokuChikouWidth: 1,
ichimokuSenkouAWidth: 1,
ichimokuSenkouBWidth: 1,
ichimokuCloudBullish: 'rgba(239, 83, 80, 0.2)',
ichimokuCloudBearish: 'rgba(38, 166, 154, 0.2)',
});
};
// 일목균형표 항목 렌더링
const renderIchimokuItem = (
label: string,
description: string,
enabledKey: string,
colorKey: string,
widthKey: string,
periodKey?: string
) => {
const isEnabled = (settings as any)[enabledKey];
const width = (colors as any)[widthKey] || 1;
return (
<Paper
sx={{
p: 1.5,
mb: 1.5,
border: '1px solid',
borderColor: isEnabled ? 'primary.main' : 'divider',
bgcolor: isEnabled ? 'action.selected' : 'background.paper',
}}
>
<Grid container spacing={1} alignItems="center">
{/* 활성화 스위치 */}
<Grid item xs={1}>
<Switch
checked={isEnabled}
onChange={(e) => updateSettings({ [enabledKey]: e.target.checked })}
size="small"
/>
</Grid>
{/* 라벨 */}
<Grid item xs={2}>
<Typography
variant="body2"
fontWeight={isEnabled ? 'bold' : 'normal'}
color={isEnabled ? 'text.primary' : 'text.secondary'}
sx={{ fontSize: '12px' }}
>
{label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '10px' }}>
{description}
</Typography>
</Grid>
{/* 기간 (해당되는 경우) */}
<Grid item xs={2}>
{periodKey ? (
<TextField
type="number"
value={(settings as any)[periodKey] || 9}
onChange={(e) => updateSettings({ [periodKey]: parseInt(e.target.value) || 1 })}
size="small"
inputProps={{ min: 1, max: 100, style: { fontSize: 12, padding: '6px 8px' } }}
label="기간"
sx={{ width: 80 }}
/>
) : (
<Typography variant="caption" color="text.secondary">-</Typography>
)}
</Grid>
{/* 색상 */}
<Grid item xs={1.5}>
<ColorBox colorKey={colorKey} />
</Grid>
{/* 선 굵기 */}
<Grid item xs={5.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Slider
value={width}
onChange={(_, value) => updateColors({ [widthKey]: value as number })}
min={0.5}
max={4}
step={0.5}
size="small"
sx={{ flex: 1 }}
/>
<Typography variant="caption" sx={{ minWidth: 30 }}>
{width}px
</Typography>
<Box
sx={{
width: 30,
height: Math.max(width * 2, 2),
bgcolor: (colors as any)[colorKey],
borderRadius: 0.5,
}}
/>
</Box>
</Grid>
</Grid>
</Paper>
);
};
return (
<Box>
{/* 헤더 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
🗻
</Typography>
<Tooltip title="초기화">
<Button startIcon={<Refresh />} onClick={handleReset} size="small" color="inherit">
</Button>
</Tooltip>
</Box>
{/* 헤더 행 */}
<Paper sx={{ p: 1, mb: 2, bgcolor: 'primary.main', opacity: 0.9 }}>
<Grid container spacing={1} alignItems="center">
<Grid item xs={1}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={1.5}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Grid>
<Grid item xs={5.5}>
<Typography variant="caption" fontWeight="bold" color="white"> </Typography>
</Grid>
</Grid>
</Paper>
{/* 각 일목균형표 항목 */}
{renderIchimokuItem('전환선', '단기 추세', 'ichimokuTenkan', 'ichimokuTenkan', 'ichimokuTenkanWidth', 'ichimokuTenkanPeriod')}
{renderIchimokuItem('기준선', '중기 추세', 'ichimokuKijun', 'ichimokuKijun', 'ichimokuKijunWidth', 'ichimokuKijunPeriod')}
{renderIchimokuItem('후행스팬', '현재가격 26일 후행', 'ichimokuChikou', 'ichimokuChikou', 'ichimokuChikouWidth')}
{renderIchimokuItem('선행스팬1', '구름대 상단', 'ichimokuSenkouA', 'ichimokuSenkouA', 'ichimokuSenkouAWidth')}
{renderIchimokuItem('선행스팬2', '구름대 하단', 'ichimokuSenkouB', 'ichimokuSenkouB', 'ichimokuSenkouBWidth', 'ichimokuSenkouBPeriod')}
{/* 구름대 색상 설정 */}
<Paper sx={{ p: 2, mt: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="body2" fontWeight="bold" sx={{ mb: 1.5 }}>
</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ minWidth: 60 }}> </Typography>
<ColorBox colorKey="ichimokuCloudBullish" />
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ minWidth: 60 }}> </Typography>
<ColorBox colorKey="ichimokuCloudBearish" />
</Box>
</Grid>
</Grid>
</Paper>
{/* 안내 */}
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
💡 .<br />
= , = <br />
= , =
</Typography>
</Alert>
</Box>
);
};
export default IchimokuSettingsPanel;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,897 @@
/**
* IndicatorGaugePanel
* 투자분析3 화면 우측 슬라이드 패널 — 보조지표 현재값을 스피도미터 게이지로 표시
*/
import React, { useState, useMemo } from 'react';
import {
Box,
Typography,
IconButton,
Tooltip,
Divider,
Chip,
} from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DashboardIcon from '@mui/icons-material/Dashboard';
import ScheduleIcon from '@mui/icons-material/Schedule';
import type { IndicatorData } from '../services/backtestApi';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
interface GaugeZone {
from: number;
to: number;
color: string;
label: string;
}
interface GaugeConfig {
id: string;
label: string;
key: string;
min: number;
max: number;
zones: GaugeZone[];
centerValue?: number;
formatValue?: (v: number) => string;
signal?: (v: number) => 'buy' | 'sell' | 'neutral';
group: string;
}
/** REST/WS·프론트 계산 간 필드명 차이 — 게이지는 첫 번째로 읽히는 유효 숫자를 사용 */
const GAUGE_VALUE_KEYS: Record<string, readonly string[]> = {
stochK: ['stochK', 'stochasticK'],
newPsychological: ['newPsychological', 'newPsy'],
investPsychological: ['investPsychological', 'investPsy'],
macdHistogram: ['macdHistogram', 'histogram'],
};
function readGaugeNumeric(row: IndicatorData | Record<string, unknown> | null | undefined, fieldKey: string): number | undefined {
if (row == null) return undefined;
const keys = GAUGE_VALUE_KEYS[fieldKey] ?? [fieldKey];
const obj = row as Record<string, unknown>;
for (const k of keys) {
const raw = obj[k];
if (raw !== undefined && raw !== null && Number.isFinite(Number(raw))) return Number(raw);
}
return undefined;
}
// ─────────────────────────────────────────────────────────────────────────────
// SVG Gauge geometry (컴팩트 · 대시보드 실시간 게이지와 유사한 비율)
// ─────────────────────────────────────────────────────────────────────────────
const G = {
cx: 58,
cy: 52,
r: 42,
sw: 7,
nl: 32,
vbW: 116,
vbH: 94,
} as const;
/** 퀀타일 구간 사이 시각적 간격 (0~1 스케일 비율) */
const QUINTILE_PAD = 0.014;
/** 그룹별 5단계 팔레트 (WebSocket 대시보드 게이지와 유사한 그라데이션) */
const THEME_PALETTES: Record<string, string[]> = {
: ['#4ade80', '#a3e635', '#fbbf24', '#fb923c', '#f87171'],
: ['#fef08a', '#fbbf24', '#fb923c', '#f87171', '#b91c1c'],
: ['#38bdf8', '#7dd3fc', '#bae6fd', '#e0f2fe', '#f8fafc'],
: ['#fcd34d', '#fb923c', '#f97316', '#ef4444', '#991b1b'],
: ['#a78bfa', '#c4b5fd', '#ddd6fe', '#e9d5ff', '#f0abfc'],
};
const DEFAULT_PALETTE = THEME_PALETTES.;
// ─────────────────────────────────────────────────────────────────────────────
// SVG helpers
// ─────────────────────────────────────────────────────────────────────────────
/** value → ratio [0,1] clamped */
const toRatio = (v: number, min: number, max: number) =>
Math.max(0, Math.min(1, (v - min) / (max - min)));
/** ratio → math angle in degrees (180° at min-left, 0° at max-right) */
const ratioToMathDeg = (r: number) => 180 - r * 180;
/** math angle → SVG point on arc */
const mathDegToPoint = (deg: number, rad: number = G.r) => ({
x: G.cx + rad * Math.cos((deg * Math.PI) / 180),
y: G.cy - rad * Math.sin((deg * Math.PI) / 180),
});
/** SVG arc path string for a value range */
const arcPath = (fromVal: number, toVal: number, min: number, max: number, rad: number = G.r) => {
const r1 = toRatio(fromVal, min, max);
const r2 = toRatio(toVal, min, max);
const a1 = ratioToMathDeg(r1);
const a2 = ratioToMathDeg(r2);
const p1 = mathDegToPoint(a1, rad);
const p2 = mathDegToPoint(a2, rad);
const span = Math.abs(a1 - a2);
const largeArc = span > 180 ? 1 : 0;
const sweep = 1;
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} A ${rad} ${rad} 0 ${largeArc} ${sweep} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
};
/** needle tip for a value */
const needleEndpoint = (v: number, min: number, max: number) => {
const ratio = toRatio(v, min, max);
const svgRot = ratio * 180 - 90;
const rad = (svgRot * Math.PI) / 180;
return {
x: G.cx + G.nl * Math.sin(rad),
y: G.cy - G.nl * Math.cos(rad),
svgRot,
};
};
/** 삼각 바늘 polygon points "x1,y1 x2,y2 x3,y3" */
const needleTrianglePoints = (tipX: number, tipY: number, halfBase = 3.8) => {
const vx = tipX - G.cx;
const vy = tipY - G.cy;
const len = Math.hypot(vx, vy) || 1;
const ux = vx / len;
const uy = vy / len;
const px = -uy * halfBase;
const py = ux * halfBase;
const lx = G.cx + px;
const ly = G.cy + py;
const rx = G.cx - px;
const ry = G.cy - py;
return `${lx.toFixed(2)},${ly.toFixed(2)} ${tipX.toFixed(2)},${tipY.toFixed(2)} ${rx.toFixed(2)},${ry.toFixed(2)}`;
};
function formatTickValue(min: number, max: number, pct: number): string {
const v = min + (max - min) * (pct / 100);
if (!Number.isFinite(v)) return '';
const span = Math.abs(max - min);
if (span >= 10000 && Math.abs(v) >= 1000) return `${(v / 1000).toFixed(0)}k`;
if (span <= 1 && span > 0) return v.toFixed(2);
if (span <= 20) return v.toFixed(1);
if (Number.isInteger(min) && Number.isInteger(max) && Math.abs(v) < 1000) return String(Math.round(v));
return v.toFixed(0);
}
// ─────────────────────────────────────────────────────────────────────────────
// Gauge Configurations
// ─────────────────────────────────────────────────────────────────────────────
const GAUGE_CONFIGS: GaugeConfig[] = [
// ── 오실레이터 ───────────────────────────────────────────────────────────
{
id: 'rsi', label: 'RSI', key: 'rsi', min: 0, max: 100, centerValue: 50,
group: '오실레이터',
zones: [
{ from: 0, to: 30, color: '#1565C0', label: '과매도' },
{ from: 30, to: 50, color: '#42A5F5', label: '약세' },
{ from: 50, to: 70, color: '#66BB6A', label: '강세' },
{ from: 70, to: 100, color: '#EF5350', label: '과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < 30 ? 'buy' : v > 70 ? 'sell' : 'neutral',
},
{
id: 'stoch', label: 'Stochastic %K', key: 'stochK', min: 0, max: 100, centerValue: 50,
group: '오실레이터',
zones: [
{ from: 0, to: 20, color: '#1565C0', label: '과매도' },
{ from: 20, to: 80, color: '#26A69A', label: '중립' },
{ from: 80, to: 100, color: '#EF5350', label: '과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < 20 ? 'buy' : v > 80 ? 'sell' : 'neutral',
},
{
id: 'cci', label: 'CCI', key: 'cci', min: -200, max: 200, centerValue: 0,
group: '오실레이터',
zones: [
{ from: -200, to: -100, color: '#1565C0', label: '과매도' },
{ from: -100, to: -0, color: '#42A5F5', label: '약세' },
{ from: 0, to: 100, color: '#66BB6A', label: '강세' },
{ from: 100, to: 200, color: '#EF5350', label: '과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < -100 ? 'buy' : v > 100 ? 'sell' : 'neutral',
},
{
id: 'williamsR', label: 'Williams %R', key: 'williamsR', min: -100, max: 0, centerValue: -50,
group: '오실레이터',
zones: [
{ from: -100, to: -80, color: '#1565C0', label: '과매도' },
{ from: -80, to: -20, color: '#26A69A', label: '중립' },
{ from: -20, to: 0, color: '#EF5350', label: '과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < -80 ? 'buy' : v > -20 ? 'sell' : 'neutral',
},
{
id: 'newPsy', label: '신심리도', key: 'newPsychological', min: 0, max: 100, centerValue: 50,
group: '오실레이터',
zones: [
{ from: 0, to: 25, color: '#1565C0', label: '과매도' },
{ from: 25, to: 75, color: '#26A69A', label: '중립' },
{ from: 75, to: 100, color: '#EF5350', label: '과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < 25 ? 'buy' : v > 75 ? 'sell' : 'neutral',
},
{
id: 'investPsy', label: '투자심리도', key: 'investPsychological', min: 0, max: 100, centerValue: 50,
group: '오실레이터',
zones: [
{ from: 0, to: 25, color: '#1565C0', label: '과매도' },
{ from: 25, to: 75, color: '#26A69A', label: '중립' },
{ from: 75, to: 100, color: '#EF5350', label: '과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < 25 ? 'buy' : v > 75 ? 'sell' : 'neutral',
},
// ── 추세 ─────────────────────────────────────────────────────────────────
{
id: 'adx', label: 'ADX', key: 'adx', min: 0, max: 100, centerValue: 25,
group: '추세',
zones: [
{ from: 0, to: 25, color: '#78909C', label: '약한추세' },
{ from: 25, to: 50, color: '#66BB6A', label: '추세중' },
{ from: 50, to: 75, color: '#FFA726', label: '강한추세' },
{ from: 75, to: 100, color: '#EF5350', label: '매우강함' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v > 25 ? 'buy' : 'neutral',
},
{
id: 'plusDI', label: '+DI', key: 'plusDI', min: 0, max: 60, centerValue: 25,
group: '추세',
zones: [
{ from: 0, to: 20, color: '#78909C', label: '약' },
{ from: 20, to: 40, color: '#66BB6A', label: '중' },
{ from: 40, to: 60, color: '#4CAF50', label: '강' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v > 25 ? 'buy' : 'neutral',
},
{
id: 'minusDI', label: '-DI', key: 'minusDI', min: 0, max: 60, centerValue: 25,
group: '추세',
zones: [
{ from: 0, to: 20, color: '#78909C', label: '약' },
{ from: 20, to: 40, color: '#EF9A9A', label: '중' },
{ from: 40, to: 60, color: '#EF5350', label: '강' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v > 25 ? 'sell' : 'neutral',
},
{
// MACD 히스토그램은 종가 스케일에 비례해 절댓값이 매우 클 수 있음(고가 종목). 좁은 ±500은 의미 구간만 왜곡함.
id: 'macd', label: 'MACD Hist', key: 'macdHistogram', min: -250000, max: 250000, centerValue: 0,
group: '추세',
zones: [
{ from: -250000, to: -100000, color: '#F44336', label: '강하락' },
{ from: -100000, to: 0, color: '#EF9A9A', label: '하락' },
{ from: 0, to: 100000, color: '#A5D6A7', label: '상승' },
{ from: 100000, to: 250000, color: '#4CAF50', label: '강상승' },
],
formatValue: (v) => {
if (Math.abs(v) >= 100000) return `${(v / 1000).toFixed(1)}k`;
if (Math.abs(v) >= 10000) return `${(v / 1000).toFixed(2)}k`;
return v.toFixed(2);
},
signal: (v) => v < -25000 ? 'sell' : v > 25000 ? 'buy' : 'neutral',
},
{
id: 'momentum', label: 'Momentum', key: 'momentum', min: -10000, max: 10000, centerValue: 0,
group: '추세',
zones: [
{ from: -10000, to: -3000, color: '#F44336', label: '강하락' },
{ from: -3000, to: 0, color: '#EF9A9A', label: '하락' },
{ from: 0, to: 3000, color: '#A5D6A7', label: '상승' },
{ from: 3000, to: 10000, color: '#4CAF50', label: '강상승' },
],
formatValue: (v) => {
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
return v.toFixed(0);
},
signal: (v) => v < 0 ? 'sell' : 'buy',
},
{
// lightweight-charts-indicators TRIX는 ROC에 1e4를 곱한 값을 plot0으로 반환함(차트와 동일 스케일).
id: 'trix', label: 'TRIX', key: 'trix', min: -15, max: 15, centerValue: 0,
group: '추세',
zones: [
{ from: -15, to: -2, color: '#F44336', label: '하락' },
{ from: -2, to: 2, color: '#9E9E9E', label: '중립' },
{ from: 2, to: 15, color: '#4CAF50', label: '상승' },
],
formatValue: (v) => v.toFixed(4),
signal: (v) => v < -2 ? 'sell' : v > 2 ? 'buy' : 'neutral',
},
// ── 거래량 ───────────────────────────────────────────────────────────────
{
id: 'vr', label: 'VR (거래량비율)', key: 'vr', min: 0, max: 500, centerValue: 100,
group: '거래량',
zones: [
{ from: 0, to: 70, color: '#1565C0', label: '과매도' },
{ from: 70, to: 150, color: '#26A69A', label: '중립' },
{ from: 150, to: 300, color: '#FFA726', label: '과매수' },
{ from: 300, to: 500, color: '#EF5350', label: '극과매수' },
],
formatValue: (v) => v.toFixed(1),
signal: (v) => v < 70 ? 'buy' : v > 300 ? 'sell' : 'neutral',
},
{
id: 'volumeOsc', label: 'Volume OSC', key: 'volumeOsc', min: -100, max: 100, centerValue: 0,
group: '거래량',
zones: [
{ from: -100, to: -30, color: '#F44336', label: '거래감소' },
{ from: -30, to: 30, color: '#9E9E9E', label: '중립' },
{ from: 30, to: 100, color: '#4CAF50', label: '거래증가' },
],
formatValue: (v) => v.toFixed(2),
signal: (v) => v < -30 ? 'sell' : v > 30 ? 'buy' : 'neutral',
},
// ── 변동성 ───────────────────────────────────────────────────────────────
{
id: 'bwi', label: 'BWI (밴드폭)', key: 'bwi', min: 0, max: 30, centerValue: 5,
group: '변동성',
zones: [
{ from: 0, to: 3, color: '#1565C0', label: '수렴' },
{ from: 3, to: 10, color: '#26A69A', label: '보통' },
{ from: 10, to: 18, color: '#FFA726', label: '확장' },
{ from: 18, to: 30, color: '#EF5350', label: '강확장' },
],
formatValue: (v) => v.toFixed(2),
signal: (v) => v < 3 ? 'neutral' : v > 15 ? 'sell' : 'neutral',
},
// ── 이격도 ───────────────────────────────────────────────────────────────
{
id: 'disparityUS', label: '이격도(초단기)', key: 'disparityUltraShort', min: -10, max: 10, centerValue: 0,
group: '이격도',
zones: [
{ from: -10, to: -3, color: '#1565C0', label: '과매도' },
{ from: -3, to: 3, color: '#26A69A', label: '정상' },
{ from: 3, to: 10, color: '#EF5350', label: '과열' },
],
formatValue: (v) => `${v.toFixed(2)}%`,
signal: (v) => v < -3 ? 'buy' : v > 3 ? 'sell' : 'neutral',
},
{
id: 'disparityS', label: '이격도(단기)', key: 'disparityShort', min: -10, max: 10, centerValue: 0,
group: '이격도',
zones: [
{ from: -10, to: -3, color: '#1565C0', label: '과매도' },
{ from: -3, to: 3, color: '#26A69A', label: '정상' },
{ from: 3, to: 10, color: '#EF5350', label: '과열' },
],
formatValue: (v) => `${v.toFixed(2)}%`,
signal: (v) => v < -3 ? 'buy' : v > 3 ? 'sell' : 'neutral',
},
];
// Group labels
const GROUP_COLORS: Record<string, string> = {
'오실레이터': '#42A5F5',
'추세': '#FFA726',
'거래량': '#66BB6A',
'변동성': '#EF5350',
'이격도': '#AB47BC',
};
// ─────────────────────────────────────────────────────────────────────────────
// GaugeWidget component
// ─────────────────────────────────────────────────────────────────────────────
interface GaugeWidgetProps {
config: GaugeConfig;
value: number | undefined;
isDark: boolean;
}
/** 값이 zones 정의 밖이면, 음수 초과 → 첫 구간·양수 초과 → 마지막 구간 (MACD 등 대용량 음수 오표시 방지) */
function resolveCurrentZone(zones: GaugeZone[], v: number, min: number, max: number): GaugeZone | null {
if (!zones.length) return null;
if (v < min) return zones[0];
if (v > max) return zones[zones.length - 1];
return zones.find(z => v >= z.from && v <= z.to) ?? zones[Math.floor(zones.length / 2)];
}
const GaugeWidget: React.FC<GaugeWidgetProps> = ({ config, value, isDark }) => {
const { min, max, zones, label, formatValue, signal, group } = config;
const safeVal = value !== undefined && Number.isFinite(value) ? value : undefined;
const clampedVal = safeVal !== undefined ? Math.max(min, Math.min(max, safeVal)) : undefined;
const outOfRange = safeVal !== undefined && (safeVal < min || safeVal > max);
const palette = THEME_PALETTES[group] ?? DEFAULT_PALETTE;
const accent = GROUP_COLORS[group] ?? palette[2];
const needleTip = clampedVal !== undefined ? needleEndpoint(clampedVal, min, max) : null;
const currentZone = safeVal !== undefined ? resolveCurrentZone(zones, safeVal, min, max) : null;
const signalResult = safeVal !== undefined && signal ? signal(safeVal) : undefined;
const signalColor = signalResult === 'buy'
? '#4ade80'
: signalResult === 'sell'
? '#f87171'
: isDark ? 'rgba(226,232,240,0.65)' : '#64748b';
const valueDisplayColor = isDark ? '#f8fafc' : '#0f172a';
const textColor = isDark ? '#f1f5f9' : '#0f172a';
const subText = isDark ? 'rgba(148,163,184,0.95)' : '#64748b';
const needleFill = '#ffffff';
const trackBg = isDark ? 'rgba(30,41,59,0.85)' : 'rgba(226,232,240,0.95)';
const displayVal = safeVal !== undefined
? (formatValue ? formatValue(safeVal) : safeVal.toFixed(2))
: '—';
const tickPcts = [0, 20, 40, 60, 80, 100] as const;
const cardBg = isDark ? 'rgba(15,23,42,0.75)' : 'rgba(255,255,255,0.92)';
const pillMuted = isDark ? 'rgba(51,65,85,0.95)' : 'rgba(241,245,249,0.98)';
const signalPillText = signalResult === 'buy' ? '매수' : signalResult === 'sell' ? '매도' : '중립';
return (
<Box
sx={{
bgcolor: cardBg,
border: `1px solid ${isDark ? `${accent}55` : `${accent}40`}`,
borderRadius: 2,
boxShadow: isDark ? `0 0 14px ${accent}22` : `0 1px 6px ${accent}18`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minWidth: 0,
}}
>
<Typography
sx={{
fontSize: '0.62rem',
fontWeight: 700,
color: textColor,
textAlign: 'center',
pt: 0.45,
px: 0.5,
lineHeight: 1.2,
}}
>
{label}
{outOfRange && (
<Typography component="span" sx={{ fontSize: '0.5rem', color: '#fb923c', ml: 0.25 }}>
!
</Typography>
)}
</Typography>
<svg
viewBox={`0 0 ${G.vbW} ${G.vbH}`}
style={{ display: 'block', width: '100%', height: 'auto' }}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter id={`gglow-${config.id}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="1.6" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* 배경 트랙 */}
<path
d={arcPath(min, max, min, max)}
fill="none"
stroke={trackBg}
strokeWidth={G.sw}
strokeLinecap="round"
/>
{/* 5등분 컬러 세그먼트 (간격 있음) */}
{palette.map((color, i) => {
const t0 = i / 5 + QUINTILE_PAD;
const t1 = (i + 1) / 5 - QUINTILE_PAD;
const fromV = min + (max - min) * t0;
const toV = min + (max - min) * t1;
return (
<path
key={i}
d={arcPath(fromV, toV, min, max)}
fill="none"
stroke={color}
strokeWidth={G.sw}
strokeLinecap="round"
opacity={0.92}
/>
);
})}
{/* 눈금 */}
{tickPcts.map((pct, i) => {
const r2 = pct / 100;
const deg = ratioToMathDeg(r2);
const rad = (deg * Math.PI) / 180;
const ri = G.r + G.sw / 2 + 0.5;
const ro = G.r + G.sw / 2 + 4;
const lx = G.cx + ri * Math.cos(rad);
const ly = G.cy - ri * Math.sin(rad);
const hx = G.cx + ro * Math.cos(rad);
const hy = G.cy - ro * Math.sin(rad);
const labelStr = formatTickValue(min, max, pct);
const tx = G.cx + (ro + 7) * Math.cos(rad);
const ty = G.cy - (ro + 7) * Math.sin(rad) + 2.5;
return (
<g key={i}>
<line
x1={lx.toFixed(2)}
y1={ly.toFixed(2)}
x2={hx.toFixed(2)}
y2={hy.toFixed(2)}
stroke={isDark ? 'rgba(148,163,184,0.55)' : 'rgba(100,116,139,0.55)'}
strokeWidth={1}
/>
<text
x={tx.toFixed(1)}
y={ty.toFixed(1)}
textAnchor="middle"
fill={subText}
fontSize="5.5"
fontWeight={600}
>
{labelStr}
</text>
</g>
);
})}
{/* 중앙 값 (글로우 — 그룹 악센트) */}
<text
x={G.cx}
y={G.cy - G.r * 0.28}
textAnchor="middle"
fill={accent}
fontSize="11"
fontWeight={800}
filter={`url(#gglow-${config.id})`}
>
{displayVal}
</text>
<text
x={G.cx}
y={G.cy - G.r * 0.28}
textAnchor="middle"
fill={valueDisplayColor}
fontSize="11"
fontWeight={800}
>
{displayVal}
</text>
{/* 바늘 그림자 */}
{needleTip && (
<polygon
points={needleTrianglePoints(needleTip.x, needleTip.y, 4.2)}
fill="rgba(0,0,0,0.35)"
transform="translate(0.6,0.8)"
/>
)}
{needleTip && (
<polygon
points={needleTrianglePoints(needleTip.x, needleTip.y, 3.6)}
fill={needleFill}
stroke="rgba(15,23,42,0.25)"
strokeWidth={0.35}
/>
)}
<circle cx={G.cx} cy={G.cy} r={3.2} fill={isDark ? '#334155' : '#94a3b8'} />
<circle cx={G.cx} cy={G.cy} r={1.6} fill={isDark ? '#e2e8f0' : '#1e293b'} />
{currentZone && (
<text x={G.cx} y={G.cy + 14} textAnchor="middle" fill={subText} fontSize="5.8" fontWeight={600}>
{currentZone.label}
</text>
)}
</svg>
{/* 하단 필 배지 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 0.4, px: 0.5, pb: 0.45, pt: 0.1 }}>
<Box
sx={{
flex: 1,
minWidth: 0,
py: 0.2,
px: 0.45,
borderRadius: 10,
bgcolor: `${palette[0]}33`,
border: `1px solid ${palette[0]}44`,
}}
>
<Typography sx={{ fontSize: '0.52rem', fontWeight: 800, color: textColor, lineHeight: 1.2, textAlign: 'center' }}>
{displayVal}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.15,
py: 0.2,
px: 0.4,
borderRadius: 10,
bgcolor: pillMuted,
border: isDark ? '1px solid rgba(71,85,105,0.6)' : '1px solid rgba(203,213,225,0.9)',
flexShrink: 0,
}}
>
<ScheduleIcon sx={{ fontSize: 11, color: subText }} />
<Typography sx={{ fontSize: '0.52rem', fontWeight: 700, color: signalColor, lineHeight: 1 }}>
{signalPillText}
</Typography>
</Box>
</Box>
</Box>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Signal Summary Badge
// ─────────────────────────────────────────────────────────────────────────────
interface SummaryProps {
indicatorData: IndicatorData | null | undefined;
isDark: boolean;
}
const SignalSummary: React.FC<SummaryProps> = ({ indicatorData, isDark }) => {
const signals = useMemo(() => {
if (!indicatorData) return { buy: 0, sell: 0, neutral: 0 };
let buy = 0, sell = 0, neutral = 0;
GAUGE_CONFIGS.forEach(cfg => {
if (!cfg.signal) return;
const v = readGaugeNumeric(indicatorData, cfg.key);
if (v === undefined) return;
const s = cfg.signal(v);
if (s === 'buy') buy++;
else if (s === 'sell') sell++;
else neutral++;
});
return { buy, sell, neutral };
}, [indicatorData]);
const total = signals.buy + signals.sell + signals.neutral;
const bullPct = total > 0 ? Math.round((signals.buy / total) * 100) : 0;
const bearPct = total > 0 ? Math.round((signals.sell / total) * 100) : 0;
const overall = signals.buy > signals.sell + signals.neutral ? '강한매수'
: signals.sell > signals.buy + signals.neutral ? '강한매도'
: signals.buy > signals.sell ? '매수우세'
: signals.sell > signals.buy ? '매도우세' : '중립';
const overallColor = overall.includes('매수') ? '#4ade80'
: overall.includes('매도') ? '#f87171' : '#94a3b8';
return (
<Box
sx={{
bgcolor: isDark ? 'rgba(15,23,42,0.55)' : 'rgba(248,250,252,0.98)',
border: `1px solid ${isDark ? 'rgba(71,85,105,0.55)' : '#e2e8f0'}`,
borderRadius: 1.5,
p: 0.65,
mb: 0.75,
}}
>
<Typography sx={{ fontSize: '0.58rem', color: isDark ? '#94a3b8' : '#64748b', mb: 0.35 }}>
</Typography>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 800, color: overallColor, lineHeight: 1.2, mb: 0.4 }}>
{overall}
</Typography>
<Box sx={{ display: 'flex', height: 5, borderRadius: 2.5, overflow: 'hidden', mb: 0.45 }}>
<Box sx={{ width: `${bullPct}%`, bgcolor: '#4ade80', transition: 'width 0.4s' }} />
<Box sx={{ width: `${bearPct}%`, bgcolor: '#f87171', transition: 'width 0.4s' }} />
<Box sx={{ flex: 1, bgcolor: isDark ? 'rgba(51,65,85,0.6)' : '#e2e8f0' }} />
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.35 }}>
<Chip label={`매수 ${signals.buy}`} size="small" sx={{ fontSize: '0.52rem', height: 16, bgcolor: 'rgba(74,222,128,0.15)', color: '#4ade80' }} />
<Chip label={`매도 ${signals.sell}`} size="small" sx={{ fontSize: '0.52rem', height: 16, bgcolor: 'rgba(248,113,113,0.15)', color: '#f87171' }} />
<Chip label={`중립 ${signals.neutral}`} size="small" sx={{ fontSize: '0.52rem', height: 16, bgcolor: isDark ? 'rgba(51,65,85,0.5)' : '#f1f5f9', color: isDark ? '#94a3b8' : '#64748b' }} />
</Box>
</Box>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// IndicatorGaugePanel (main export)
// ─────────────────────────────────────────────────────────────────────────────
export interface IndicatorGaugePanelProps {
open: boolean;
onToggle: () => void;
indicatorData: IndicatorData | null | undefined;
isDark: boolean;
}
const PANEL_WIDTH = 292;
const IndicatorGaugePanel: React.FC<IndicatorGaugePanelProps> = ({
open,
onToggle,
indicatorData,
isDark,
}) => {
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
'오실레이터': true,
'추세': true,
'거래량': true,
'변동성': true,
'이격도': false,
});
const toggleGroup = (group: string) =>
setOpenGroups(prev => ({ ...prev, [group]: !prev[group] }));
const groups = useMemo(() => {
const map: Record<string, GaugeConfig[]> = {};
GAUGE_CONFIGS.forEach(cfg => {
if (!map[cfg.group]) map[cfg.group] = [];
map[cfg.group].push(cfg);
});
return map;
}, []);
const panelBg = isDark ? '#121218' : '#f5f5f5';
const headerBg = isDark ? '#1a1a28' : '#e8eaf6';
const borderColor = isDark ? '#2a2a3a' : '#c5cae9';
return (
<>
{/* Toggle button (always visible) */}
<Tooltip title={open ? '게이지 패널 숨기기' : '지표 게이지 패널 열기'} placement="left">
<IconButton
onClick={onToggle}
size="small"
sx={{
position: 'fixed',
right: open ? PANEL_WIDTH : 0,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1300,
bgcolor: isDark ? '#1e1e3a' : '#3949ab',
color: '#fff',
borderRadius: '6px 0 0 6px',
width: 22,
height: 56,
'&:hover': { bgcolor: isDark ? '#2a2a5a' : '#1a237e' },
transition: 'right 0.3s ease-in-out',
boxShadow: '-2px 0 8px rgba(0,0,0,0.3)',
}}
>
{open ? <ChevronRightIcon fontSize="small" /> : <ChevronLeftIcon fontSize="small" />}
</IconButton>
</Tooltip>
{/* Sliding panel */}
<Box
sx={{
position: 'fixed',
top: 0,
right: open ? 0 : -PANEL_WIDTH,
bottom: 0,
width: PANEL_WIDTH,
zIndex: 1200,
bgcolor: panelBg,
borderLeft: `1px solid ${borderColor}`,
display: 'flex',
flexDirection: 'column',
transition: 'right 0.3s ease-in-out',
boxShadow: open ? '-4px 0 16px rgba(0,0,0,0.25)' : 'none',
}}
>
{/* Panel header */}
<Box
sx={{
px: 1.5, py: 1,
bgcolor: headerBg,
borderBottom: `1px solid ${borderColor}`,
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexShrink: 0,
}}
>
<DashboardIcon sx={{ fontSize: 16, color: '#5c6bc0' }} />
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: isDark ? '#ccc' : '#333', flex: 1 }}>
</Typography>
</Box>
{/* Scrollable content */}
<Box sx={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', px: 1, py: 1 }}>
{/* Signal summary */}
<SignalSummary indicatorData={indicatorData} isDark={isDark} />
{/* Grouped gauges */}
{Object.entries(groups).map(([groupName, configs]) => (
<Box key={groupName} sx={{ mb: 0.5 }}>
{/* Group header */}
<Box
onClick={() => toggleGroup(groupName)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
py: 0.5,
mb: 0.5,
userSelect: 'none',
}}
>
<Box
sx={{
width: 8, height: 8, borderRadius: '50%',
bgcolor: GROUP_COLORS[groupName] || '#666',
flexShrink: 0,
}}
/>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: GROUP_COLORS[groupName] || '#aaa', flex: 1 }}>
{groupName}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: isDark ? '#888' : '#aaa' }}>
{openGroups[groupName] ? '▲' : '▼'}
</Typography>
</Box>
<Divider sx={{ mb: 0.75, borderColor: isDark ? '#333' : '#ddd' }} />
{/* Gauges — 2열 그리드 */}
{openGroups[groupName] && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.65,
alignItems: 'stretch',
}}
>
{configs.map(cfg => (
<GaugeWidget
key={cfg.id}
config={cfg}
value={
indicatorData !== null && indicatorData !== undefined
? readGaugeNumeric(indicatorData, cfg.key)
: undefined
}
isDark={isDark}
/>
))}
</Box>
)}
</Box>
))}
</Box>
{/* Footer */}
<Box
sx={{
px: 1.5, py: 0.75,
bgcolor: headerBg,
borderTop: `1px solid ${borderColor}`,
flexShrink: 0,
}}
>
<Typography sx={{ fontSize: '0.6rem', color: isDark ? '#666' : '#999', textAlign: 'center' }}>
·
</Typography>
</Box>
</Box>
</>
);
};
export default IndicatorGaugePanel;
@@ -0,0 +1,721 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
TextField,
Slider,
Divider,
Paper,
IconButton,
Tooltip,
Switch,
Grid,
Select,
MenuItem,
FormControl,
} from '@mui/material';
import { Close, Refresh, DragIndicator, Add, Delete } from '@mui/icons-material';
import DraggablePaper from './DraggablePaper';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext';
import { useIndicatorSettings, IndicatorSettings, CustomReferenceLine } from '../contexts/IndicatorSettingsContext';
import { useIndicatorVisibility, IndicatorVisibilitySettings, IndicatorOrder } from '../contexts/IndicatorVisibilityContext';
import { useAuth } from '../contexts/AuthContext';
interface IndicatorParamsSettingsDialogProps {
open: boolean;
onClose: () => void;
selectedIndicator?: string; // 특정 보조지표만 표시 (없으면 전체 표시)
}
// 지표별 색상/굵기 설정 키 매핑
interface ColorConfig {
label: string;
colorKey: keyof ChartColorSettings;
widthKey?: keyof ChartColorSettings;
}
const indicatorColorConfigs: Record<string, ColorConfig[]> = {
candleChart: [
{ label: '상승', colorKey: 'candleRising' },
{ label: '하락', colorKey: 'candleFalling' },
],
macdLine: [
{ label: 'MACD', colorKey: 'macdLine', widthKey: 'macdLineWidth' },
{ label: '시그널', colorKey: 'signalLine', widthKey: 'signalLineWidth' },
],
rsi: [
{ label: 'RSI', colorKey: 'rsiColor', widthKey: 'rsiWidth' },
{ label: '신호', colorKey: 'rsiSignalColor', widthKey: 'rsiSignalWidth' },
],
stochasticK: [
{ label: '%K', colorKey: 'stochasticK', widthKey: 'stochasticKWidth' },
{ label: '%D', colorKey: 'stochasticD', widthKey: 'stochasticDWidth' },
],
cci: [
{ label: 'CCI', colorKey: 'cciColor', widthKey: 'cciWidth' },
{ label: '신호', colorKey: 'cciSignalColor', widthKey: 'cciSignalWidth' },
],
adx: [{ label: 'ADX', colorKey: 'adxColor', widthKey: 'adxWidth' }],
plusDi: [
{ label: '+DI', colorKey: 'plusDiColor', widthKey: 'plusDiWidth' },
{ label: '-DI', colorKey: 'minusDiColor', widthKey: 'minusDiWidth' },
],
minusDi: [],
williamsR: [{ label: 'Williams%R', colorKey: 'williamsRColor', widthKey: 'williamsRWidth' }],
trix: [
{ label: 'TRIX', colorKey: 'trixColor', widthKey: 'trixWidth' },
{ label: '시그널', colorKey: 'trixSignalColor', widthKey: 'trixSignalWidth' },
],
newPsychological: [{ label: '신심리도', colorKey: 'newPsychologicalColor', widthKey: 'newPsychologicalWidth' }],
investPsychological: [{ label: '투자심리도', colorKey: 'investPsychologicalColor', widthKey: 'investPsychologicalWidth' }],
bwi: [{ label: 'BWI', colorKey: 'bwiColor', widthKey: 'bwiWidth' }],
obv: [
{ label: 'OBV', colorKey: 'obvColor', widthKey: 'obvWidth' },
{ label: '신호', colorKey: 'obvSignalColor', widthKey: 'obvSignalWidth' },
],
volumeOsc: [{ label: 'VolumeOSC', colorKey: 'volumeOscColor', widthKey: 'volumeOscWidth' }],
vr: [{ label: 'VR', colorKey: 'vrColor', widthKey: 'vrWidth' }],
disparity: [
{ label: '초단기', colorKey: 'disparityUltraShortColor', widthKey: 'disparityUltraShortWidth' },
{ label: '단기', colorKey: 'disparityShortColor', widthKey: 'disparityShortWidth' },
{ label: '중기', colorKey: 'disparityMidColor', widthKey: 'disparityMidWidth' },
{ label: '장기', colorKey: 'disparityLongColor', widthKey: 'disparityLongWidth' },
],
};
// 지표별 파라미터 키 매핑
const indicatorParamKeys: Record<string, { key: string; label: string }[]> = {
macdLine: [{ key: 'macdFastPeriod', label: '단기 EMA' }, { key: 'macdSlowPeriod', label: '장기 EMA' }, { key: 'macdSignalPeriod', label: '시그널' }],
rsi: [{ key: 'rsiPeriod', label: '기간' }, { key: 'rsiSignalPeriod', label: '신호' }],
stochasticK: [{ key: 'stochasticKPeriod', label: '기간' }, { key: 'stochasticSlowing', label: '%K' }, { key: 'stochasticDPeriod', label: '%D' }],
cci: [{ key: 'cciPeriod', label: '기간' }, { key: 'cciSignalPeriod', label: '신호' }],
adx: [{ key: 'adxPeriod', label: '기간' }],
williamsR: [{ key: 'williamsRPeriod', label: '기간' }],
trix: [{ key: 'trixPeriod', label: '기간' }, { key: 'trixSignalPeriod', label: '신호' }],
newPsychological: [{ key: 'newPsychologicalPeriod', label: '기간' }],
investPsychological: [{ key: 'investPsychologicalPeriod', label: '기간' }],
bwi: [{ key: 'bwiPeriod', label: '기간' }],
obv: [{ key: 'obvPeriod', label: '기간' }, { key: 'obvSignalPeriod', label: '신호' }],
volumeOsc: [{ key: 'volumeOscShortPeriod', label: '단기' }, { key: 'volumeOscLongPeriod', label: '장기' }],
vr: [{ key: 'vrPeriod', label: '기간' }],
disparity: [
{ key: 'disparityUltraShortPeriod', label: '초단기' },
{ key: 'disparityShortPeriod', label: '단기' },
{ key: 'disparityMidPeriod', label: '중기' },
{ key: 'disparityLongPeriod', label: '장기' },
],
plusDi: [{ key: 'dmiPeriod', label: '기간' }],
};
// 지표 ID -> 지표 타입 매핑
const indicatorIdToType: Record<string, string> = {
candleChart: 'candle',
volume: 'volume',
macdLine: 'macd',
rsi: 'rsi',
stochasticK: 'stochastic',
cci: 'cci',
adx: 'adx',
plusDi: 'dmi',
williamsR: 'williamsR',
trix: 'trix',
disparity: 'disparity',
newPsychological: 'newPsychological',
investPsychological: 'investPsychological',
bwi: 'bwi',
obv: 'obv',
volumeOsc: 'volumeOsc',
vr: 'vr',
};
// Sortable 아이템 컴포넌트 - 테이블 행 스타일
interface SortableItemProps {
indicator: IndicatorOrder;
checked: boolean;
onToggle: () => void;
colors: ChartColorSettings;
updateColors: (c: Partial<ChartColorSettings>) => void;
indicatorSettings: IndicatorSettings;
updateIndicatorSettings: (s: Partial<IndicatorSettings>) => void;
getCustomReferenceLines: (indicatorType: string) => CustomReferenceLine[];
addCustomReferenceLine: (indicatorType: string, line: Omit<CustomReferenceLine, 'id'>) => void;
removeCustomReferenceLine: (indicatorType: string, lineId: string) => void;
updateCustomReferenceLine: (indicatorType: string, lineId: string, updates: Partial<CustomReferenceLine>) => void;
colorInputRefsRef: React.RefObject<Map<string, HTMLInputElement>>;
}
const SortableItem: React.FC<SortableItemProps> = ({
indicator,
checked,
onToggle,
colors,
updateColors,
indicatorSettings,
updateIndicatorSettings,
getCustomReferenceLines,
addCustomReferenceLine,
removeCustomReferenceLine,
updateCustomReferenceLine,
colorInputRefsRef,
}) => {
const [expanded, setExpanded] = useState(false);
const isDisabled = indicator.settingKey === 'volume';
const isCandleChart = indicator.settingKey === 'candleChart';
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: indicator.id, disabled: isDisabled || isCandleChart });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const canDrag = !isDisabled && !isCandleChart;
const colorConfigs = indicatorColorConfigs[indicator.id] || [];
const paramKeys = indicatorParamKeys[indicator.id] || [];
const hasSettings = indicator.settingKey !== 'volume';
const indicatorType = indicatorIdToType[indicator.id] || indicator.id;
const referenceLines = getCustomReferenceLines(indicatorType);
const primaryColor = colorConfigs[0];
const handleAddReferenceLine = () => {
addCustomReferenceLine(indicatorType, {
name: '기준선',
value: 0,
color: '#ffeb3b',
lineStyle: 'dashed',
});
};
// 파라미터와 색상 매핑 (파라미터 인덱스에 맞는 색상 찾기)
const getColorForParam = (paramIndex: number) => {
if (paramIndex < colorConfigs.length) {
return colorConfigs[paramIndex];
}
return null;
};
return (
<Box ref={setNodeRef} style={style}>
{/* 메인 행 */}
<Paper
sx={{
mb: expanded ? 0 : 0.5,
border: '1px solid',
borderColor: isDragging ? 'primary.main' : checked ? 'primary.light' : 'divider',
borderRadius: expanded ? '4px 4px 0 0' : 1,
bgcolor: isDragging ? 'action.hover' : checked ? 'action.selected' : 'background.paper',
}}
>
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
minHeight: 48,
}}
>
{/* 드래그 핸들 */}
{canDrag ? (
<Box
{...attributes}
{...listeners}
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' }, display: 'flex' }}
>
<DragIndicator color="action" sx={{ fontSize: 18 }} />
</Box>
) : (
<Box sx={{ width: 18 }} />
)}
{/* 지표명 */}
<Typography variant="body2" sx={{ minWidth: 80, fontWeight: checked ? 'bold' : 'normal', fontSize: '13px' }}>
{indicator.label}
</Typography>
{/* 파라미터 + 색상 (이름표시 | 값입력 | 색상버튼) */}
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{hasSettings && paramKeys.map((param, idx) => {
const colorCfg = getColorForParam(idx);
return (
<Box key={param.key} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* 파라미터 이름 */}
<Typography variant="caption" sx={{ fontSize: '11px', color: 'text.secondary', minWidth: 28 }}>
{param.label}
</Typography>
{/* 값 입력 */}
<TextField
type="number"
size="small"
value={(indicatorSettings as any)[param.key] || ''}
onChange={(e) => updateIndicatorSettings({ [param.key]: Number(e.target.value) })}
inputProps={{ min: 1, max: 999, style: { fontSize: 11, padding: '4px 6px', textAlign: 'center' } }}
sx={{ width: 50, '& .MuiOutlinedInput-root': { bgcolor: 'background.default' } }}
/>
{/* 색상 선택 (있는 경우만) */}
{colorCfg && (
<Box sx={{ position: 'relative' }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 0.5,
bgcolor: colors[colorCfg.colorKey] as string,
border: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
}}
/>
<input
type="color"
defaultValue={colors[colorCfg.colorKey] as string || '#666666'}
ref={(el) => {
if (el && colorInputRefsRef.current) {
colorInputRefsRef.current.set(colorCfg.colorKey, el);
}
}}
onInput={(e) => {
// ✅ 색상 선택 중: 미리보기만 업데이트 (리렌더링 방지)
const target = e.target as HTMLInputElement;
const box = target.previousElementSibling as HTMLElement;
if (box) {
box.style.backgroundColor = target.value;
}
}}
style={{ position: 'absolute', top: 0, left: 0, width: 24, height: 24, opacity: 0, cursor: 'pointer' }}
/>
</Box>
)}
</Box>
);
})}
</Box>
{/* 선 굵기 슬라이더 + 미리보기 */}
{hasSettings && primaryColor?.widthKey && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 140 }}>
<Slider
value={colors[primaryColor.widthKey] as number || 1.5}
onChange={(_, value) => updateColors({ [primaryColor.widthKey!]: value as number })}
min={0.5}
max={5}
step={0.5}
size="small"
sx={{ width: 80 }}
/>
<Typography variant="caption" sx={{ minWidth: 28, fontSize: '10px' }}>
{colors[primaryColor.widthKey]}px
</Typography>
{/* 미리보기 */}
<Box
sx={{
width: 30,
height: Math.max((colors[primaryColor.widthKey] as number || 1.5) * 2, 2),
backgroundColor: colors[primaryColor.colorKey] as string,
borderRadius: 0.5,
}}
/>
</Box>
)}
{/* 우측 끝: ON/OFF 스위치 + 펼침 버튼 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto' }}>
<Switch checked={checked} onChange={onToggle} disabled={isDisabled || isCandleChart} size="small" />
{hasSettings && !isCandleChart ? (
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ p: 0.25 }}>
<Typography variant="caption" sx={{ fontSize: '10px', color: 'text.secondary' }}>
{expanded ? '▲' : '▼'}
</Typography>
</IconButton>
) : (
<Box sx={{ width: 24 }} />
)}
</Box>
</Box>
</Paper>
{/* 확장된 기준선 설정 - 테이블 스타일 */}
{expanded && hasSettings && !isCandleChart && (
<Paper
sx={{
mb: 0.5,
p: 1.5,
border: '1px solid',
borderTop: 'none',
borderColor: 'divider',
borderRadius: '0 0 4px 4px',
bgcolor: 'action.hover',
}}
>
{/* 기준선 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
📏
</Typography>
<Button size="small" startIcon={<Add sx={{ fontSize: 14 }} />} onClick={handleAddReferenceLine} sx={{ fontSize: '10px', py: 0 }}>
</Button>
</Box>
{referenceLines.length > 0 ? (
<>
{/* 기준선 테이블 헤더 */}
<Box sx={{ p: 0.5, mb: 0.5, bgcolor: 'primary.main', borderRadius: 0.5, opacity: 0.85 }}>
<Grid container spacing={0.5} alignItems="center">
<Grid item xs={3}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}> </Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
</Grid>
</Box>
{/* 기준선 행 */}
{referenceLines.map((line) => (
<Box key={line.id} sx={{ p: 0.5, mb: 0.5, bgcolor: 'background.paper', borderRadius: 0.5, border: '1px solid', borderColor: 'divider' }}>
<Grid container spacing={0.5} alignItems="center">
<Grid item xs={3}>
<TextField
size="small"
fullWidth
value={line.name}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { name: e.target.value })}
inputProps={{ style: { fontSize: 10, padding: '4px 6px' } }}
/>
</Grid>
<Grid item xs={2}>
<TextField
size="small"
fullWidth
type="number"
value={line.value}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { value: parseFloat(e.target.value) || 0 })}
inputProps={{ style: { fontSize: 10, padding: '4px 6px', textAlign: 'center' } }}
/>
</Grid>
<Grid item xs={2}>
<Box sx={{ position: 'relative' }}>
<Box sx={{ width: 20, height: 20, borderRadius: 0.5, bgcolor: line.color, border: '1px solid', borderColor: 'divider', cursor: 'pointer' }} />
<input
type="color"
value={line.color}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { color: e.target.value })}
style={{ position: 'absolute', top: 0, left: 0, width: 20, height: 20, opacity: 0, cursor: 'pointer' }}
/>
</Box>
</Grid>
<Grid item xs={3}>
<FormControl size="small" fullWidth>
<Select
value={line.lineStyle || 'dashed'}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { lineStyle: e.target.value as 'solid' | 'dashed' | 'dotted' })}
sx={{ fontSize: '10px', '& .MuiSelect-select': { py: 0.5, px: 0.5 } }}
>
<MenuItem value="solid" sx={{ fontSize: '10px' }}></MenuItem>
<MenuItem value="dashed" sx={{ fontSize: '10px' }}></MenuItem>
<MenuItem value="dotted" sx={{ fontSize: '10px' }}></MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={2}>
<IconButton size="small" onClick={() => removeCustomReferenceLine(indicatorType, line.id)} color="error" sx={{ p: 0.25 }}>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
</Grid>
</Grid>
</Box>
))}
</>
) : (
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', textAlign: 'center', py: 0.5 }}>
</Typography>
)}
</Paper>
)}
</Box>
);
};
const IndicatorParamsSettingsDialog: React.FC<IndicatorParamsSettingsDialogProps> = ({ open, onClose, selectedIndicator }) => {
const { colors, updateColors, resetColors } = useChartColors();
const {
indicatorSettings,
updateIndicatorSettings,
resetIndicatorSettings,
getCustomReferenceLines,
addCustomReferenceLine,
removeCustomReferenceLine,
updateCustomReferenceLine,
} = useIndicatorSettings();
const { settings, order, updateSettings, updateOrder, resetSettings, saveSettings } = useIndicatorVisibility();
const { isAuthenticated } = useAuth();
const [tempSettings, setTempSettings] = useState<IndicatorVisibilitySettings>(settings);
const [tempOrder, setTempOrder] = useState<IndicatorOrder[]>(order);
// ✅ 로컬 색상 및 지표 설정 상태 추가 (적용 버튼 클릭 시까지 Context 업데이트 지연)
const [localColors, setLocalColors] = useState<Partial<ChartColorSettings>>({});
const [localIndicatorSettings, setLocalIndicatorSettings] = useState<Partial<IndicatorSettings>>({});
// ✅ 색상 input ref 추적 (리렌더링 방지를 위해)
const colorInputRefsRef = useRef<Map<string, HTMLInputElement>>(new Map());
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => {
if (open) {
setTempSettings(settings);
setTempOrder(order);
// ✅ 다이얼로그 열릴 때 로컬 상태 초기화
setLocalColors({});
setLocalIndicatorSettings({});
}
}, [open, settings, order]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setTempOrder((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleToggle = (key: keyof IndicatorVisibilitySettings) => {
if (key === 'candleChart') return;
if (key === 'macdLine') {
setTempSettings(prev => ({ ...prev, macdLine: !prev.macdLine, macdHistogram: !prev.macdLine }));
} else if (key === 'stochasticK') {
setTempSettings(prev => ({ ...prev, stochasticK: !prev.stochasticK, stochasticD: !prev.stochasticK, stochasticSlow: !prev.stochasticK }));
} else if (key === 'plusDi') {
setTempSettings(prev => ({ ...prev, plusDi: !prev.plusDi, minusDi: !prev.plusDi }));
} else if (key === 'trix') {
setTempSettings(prev => ({ ...prev, trix: !prev.trix, trixSignal: !prev.trix }));
} else {
setTempSettings(prev => ({ ...prev, [key]: !prev[key] }));
}
};
const handleSave = async () => {
console.log('💾 [handleSave] 시작 - localIndicatorSettings:', localIndicatorSettings);
console.log('💾 [handleSave] localColors:', localColors);
// ✅ 모든 색상 input의 현재 값을 읽어서 localColors에 반영
const finalColors: Partial<ChartColorSettings> = { ...localColors };
colorInputRefsRef.current.forEach((input, key) => {
if (input && input.value) {
(finalColors as any)[key] = input.value;
}
});
// ✅ 로컬 색상 및 지표 설정을 Context에 한 번에 반영
if (Object.keys(finalColors).length > 0) {
console.log('💾 [handleSave] updateColors 호출');
updateColors(finalColors);
}
if (Object.keys(localIndicatorSettings).length > 0) {
console.log('💾 [handleSave] updateIndicatorSettings 호출:', localIndicatorSettings);
updateIndicatorSettings(localIndicatorSettings);
} else {
console.warn('⚠️ [handleSave] localIndicatorSettings 비어있음 - 스킵');
}
updateSettings(tempSettings);
updateOrder(tempOrder);
await saveSettings(isAuthenticated);
onClose();
};
const handleReset = () => {
if (window.confirm('모든 보조지표 설정을 초기화하시겠습니까?')) {
resetSettings();
resetColors();
resetIndicatorSettings();
// ✅ 로컬 상태도 초기화
setLocalColors({});
setLocalIndicatorSettings({});
onClose();
}
};
// ✅ 로컬 색상과 설정을 업데이트하는 함수 (적용 버튼 클릭 시까지 Context 업데이트 지연)
const handleLocalColorUpdate = (updates: Partial<ChartColorSettings>) => {
setLocalColors(prev => ({ ...prev, ...updates }));
};
const handleLocalSettingsUpdate = (updates: Partial<IndicatorSettings>) => {
console.log('🔧 [handleLocalSettingsUpdate] 호출:', updates);
setLocalIndicatorSettings(prev => {
const updated = { ...prev, ...updates };
console.log('🔧 [handleLocalSettingsUpdate] 업데이트 후:', updated);
return updated;
});
};
// ✅ 현재 색상 및 설정 (로컬 변경사항 반영)
const currentColors = { ...colors, ...localColors };
const currentIndicatorSettings = { ...indicatorSettings, ...localIndicatorSettings };
let filteredOrder = tempOrder.filter(item =>
item.settingKey !== 'stochasticD' &&
item.settingKey !== 'stochasticSlow' &&
item.settingKey !== 'trixSignal' &&
item.settingKey !== 'minusDi' &&
item.settingKey !== 'macdHistogram'
);
// 특정 보조지표만 표시하는 경우 필터링
if (selectedIndicator) {
filteredOrder = filteredOrder.filter(item => item.id === selectedIndicator);
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
height: '85vh',
maxHeight: 850,
width: 700,
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
},
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
📊 {selectedIndicator
? `${filteredOrder[0]?.label || '보조지표'} 설정`
: '보조지표 설정'}
</Typography>
<Box>
<Tooltip title="초기화">
<IconButton onClick={handleReset} size="small" sx={{ mr: 1, color: '#fff' }}>
<Refresh />
</IconButton>
</Tooltip>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<Close />
</IconButton>
</Box>
</DialogTitle>
<Divider />
<DialogContent sx={{ pt: 2, overflow: 'auto' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
, .
</Typography>
{/* 드래그 가능한 목록 */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={filteredOrder.map(item => item.id)} strategy={verticalListSortingStrategy}>
{filteredOrder.map((indicator) => {
const value = tempSettings[indicator.settingKey];
const isChecked = typeof value === 'boolean' ? value : false;
return (
<SortableItem
key={indicator.id}
indicator={indicator}
checked={isChecked}
onToggle={() => handleToggle(indicator.settingKey)}
colors={currentColors}
updateColors={handleLocalColorUpdate}
indicatorSettings={currentIndicatorSettings}
updateIndicatorSettings={handleLocalSettingsUpdate}
getCustomReferenceLines={getCustomReferenceLines}
addCustomReferenceLine={addCustomReferenceLine}
removeCustomReferenceLine={removeCustomReferenceLine}
updateCustomReferenceLine={updateCustomReferenceLine}
colorInputRefsRef={colorInputRefsRef}
/>
);
})}
</SortableContext>
</DndContext>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} color="inherit">
</Button>
<Button onClick={handleSave} variant="contained" color="primary">
</Button>
</DialogActions>
</Dialog>
);
};
export default IndicatorParamsSettingsDialog;
@@ -0,0 +1,400 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Box,
IconButton,
Typography,
Slider,
useTheme,
Tab,
Tabs,
} from '@mui/material';
import {
Close as CloseIcon,
DragHandle,
Opacity as OpacityIcon,
} from '@mui/icons-material';
import {
ComposedChart,
Line,
XAxis,
YAxis,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Bar,
Customized,
} from 'recharts';
import { useChartColors } from '../contexts/ChartColorContext';
import { useIndicatorSettings } from '../contexts/IndicatorSettingsContext';
import { useIndicatorPopup } from '../contexts/IndicatorPopupContext';
interface IndicatorPopupWindowProps {
open: boolean;
onClose: () => void;
chartData: any[];
chartMargin?: { top: number; right: number; bottom: number; left: number };
}
const IndicatorPopupWindow: React.FC<IndicatorPopupWindowProps> = ({
open,
onClose,
chartData,
chartMargin = { top: 5, right: 55, bottom: 20, left: 55 },
}) => {
const { colors } = useChartColors();
const { indicatorSettings } = useIndicatorSettings();
const { hoveredIndex, activeIndicatorType } = useIndicatorPopup();
const theme = useTheme();
// 팝업 상태
const [popupTransparency, setPopupTransparency] = useState(30); // 기본값: 30% (약간 투명)
const [height, setHeight] = useState(40); // 화면 높이의 40%
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<HTMLDivElement>(null);
// 확대 표시 범위 (마우스 위치 기준 전후 개수)
const ZOOM_RANGE = 10; // 전후 10개씩 총 21개 데이터 표시
// 마우스 위치 기준으로 확대할 데이터 추출
const zoomedData = useMemo(() => {
if (!hoveredIndex || chartData.length === 0) {
const endIndex = chartData.length - 1;
const startIndex = Math.max(0, endIndex - ZOOM_RANGE * 2);
return chartData.slice(startIndex, endIndex + 1);
}
const startIndex = Math.max(0, hoveredIndex - ZOOM_RANGE);
const endIndex = Math.min(chartData.length - 1, hoveredIndex + ZOOM_RANGE);
return chartData.slice(startIndex, endIndex + 1);
}, [chartData, hoveredIndex]);
// 드래그로 높이 조절
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
};
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 1) {
setIsResizing(true);
}
};
useEffect(() => {
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!isResizing) return;
let clientY: number;
if ('touches' in e) {
if (e.touches.length === 0) return;
clientY = e.touches[0].clientY;
e.preventDefault();
} else {
clientY = e.clientY;
}
const windowHeight = window.innerHeight;
const newHeight = Math.max(20, Math.min(80, ((windowHeight - clientY) / windowHeight) * 100));
setHeight(newHeight);
};
const handleEnd = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('touchend', handleEnd);
}
return () => {
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('touchend', handleEnd);
};
}, [isResizing]);
// X축 레이블 포맷
const formatXAxisLabel = (dateStr: string) => {
return dateStr || '';
};
// Y축 숫자 포맷
const formatYAxis = (value: number): string => {
if (value === null || value === undefined) return '';
const absValue = Math.abs(value);
if (absValue >= 1000) return (value / 1000).toFixed(1) + 'K';
if (absValue >= 100) return Math.round(value).toString();
if (absValue >= 1) return value.toFixed(1);
return value.toFixed(2);
};
// 마우스 위치 크로스헤어
const VerticalCrosshair = (props: any) => {
const { xAxisMap, offset } = props;
if (!hoveredIndex || !xAxisMap || !zoomedData) return null;
const startIndex = Math.max(0, hoveredIndex - ZOOM_RANGE);
const indexInZoomedData = hoveredIndex - startIndex;
if (indexInZoomedData < 0 || indexInZoomedData >= zoomedData.length) return null;
const data = zoomedData[indexInZoomedData];
if (!data) return null;
const xAxis = xAxisMap[0];
if (!xAxis) return null;
const x = xAxis.scale(data.date);
if (x === undefined || isNaN(x)) return null;
const y1 = offset?.top || 0;
const y2 = (offset?.top || 0) + (offset?.height || 0);
return (
<line
x={x}
y={y1}
x1={x}
y1={y1}
x2={x}
y2={y2}
stroke="#4caf50"
strokeWidth={2}
opacity={0.8}
pointerEvents="none"
/>
);
};
if (!open || !activeIndicatorType) return null;
// 차트 타이틀과 정보
const getIndicatorTitle = () => {
switch (activeIndicatorType) {
case 'macd':
return `MACD (${indicatorSettings.macdFastPeriod},${indicatorSettings.macdSlowPeriod},${indicatorSettings.macdSignalPeriod})`;
case 'stochastic':
return `STOCHASTIC (${indicatorSettings.stochasticKPeriod},${indicatorSettings.stochasticDPeriod},${indicatorSettings.stochasticSlowing})`;
case 'rsi':
return `RSI (${indicatorSettings.rsiPeriod})`;
case 'volume':
return 'VOLUME';
default:
return '';
}
};
// 차트 높이 계산
const chartHeight = (height * window.innerHeight / 100) - 60;
return (
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: `${height}vh`,
bgcolor: `rgba(${theme.palette.mode === 'dark' ? '18, 18, 26' : '255, 255, 255'}, ${1 - popupTransparency / 100})`,
backdropFilter: popupTransparency < 50 ? 'blur(10px)' : 'none',
zIndex: 1300,
display: 'flex',
flexDirection: 'column',
borderTop: '2px solid',
borderColor: 'primary.main',
transition: isResizing ? 'none' : 'height 0.1s ease',
}}
>
{/* 드래그 핸들 */}
<Box
ref={resizeRef}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
sx={{
height: 12,
bgcolor: 'primary.dark',
cursor: 'ns-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
'&:hover': {
bgcolor: 'primary.main',
},
}}
>
<DragHandle sx={{ fontSize: 16, color: 'text.secondary' }} />
</Box>
{/* 헤더 */}
<Box
sx={{
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: `rgba(${theme.palette.mode === 'dark' ? '30, 30, 46' : '245, 245, 245'}, ${Math.max(1 - popupTransparency / 100, 0.3)})`,
}}
>
<Typography variant="subtitle1" fontWeight="bold" sx={{ color: 'primary.light' }}>
📊 {getIndicatorTitle()}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{/* 투명도 조절 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 200 }}>
<OpacityIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
<Typography variant="caption" sx={{ color: 'text.secondary', minWidth: 45 }}>
</Typography>
<Slider
value={popupTransparency}
onChange={(_, value) => setPopupTransparency(value as number)}
min={0}
max={100}
step={5}
size="small"
sx={{ width: 120 }}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', minWidth: 40 }}>
{popupTransparency}%
</Typography>
</Box>
{/* 닫기 버튼 */}
<IconButton
size="small"
onClick={onClose}
sx={{
bgcolor: 'action.hover',
'&:hover': { bgcolor: 'error.main', color: 'error.contrastText' },
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* 차트 영역 */}
<Box sx={{ flex: 1, p: 2 }}>
{activeIndicatorType === 'macd' && (
<ResponsiveContainer width="100%" height={chartHeight}>
<ComposedChart data={zoomedData} margin={chartMargin}>
<CartesianGrid stroke="#2d3748" strokeWidth={0.5} strokeDasharray="3 3" />
<XAxis
dataKey="date"
type="category"
tick={{ fontSize: 11, fill: '#8b95a5' }}
tickLine={{ stroke: '#3a4557' }}
axisLine={{ stroke: '#3a4557' }}
tickFormatter={formatXAxisLabel}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 12, fill: '#8b95a5' }}
tickLine={{ stroke: '#3a4557' }}
axisLine={{ stroke: '#3a4557' }}
tickFormatter={formatYAxis}
width={60}
/>
<Customized component={VerticalCrosshair} />
<ReferenceLine y={0} stroke="#666" strokeDasharray="3 3" strokeWidth={1} />
{/* 히스토그램 */}
<Bar dataKey="macdHistogram" radius={[2, 2, 0, 0]}>
{zoomedData.map((entry, index) => (
<rect
key={`hist-${index}`}
fill={entry.macdHistogram >= 0 ? colors.histogramPositive : colors.histogramNegative}
opacity={0.8}
/>
))}
</Bar>
{/* MACD 라인 */}
<Line
type="monotone"
dataKey="macdLine"
stroke={colors.macdLine}
strokeWidth={2.5}
dot={{ r: 4, fill: colors.macdLine }}
isAnimationActive={false}
/>
{/* Signal 라인 */}
<Line
type="monotone"
dataKey="signalLine"
stroke={colors.signalLine}
strokeWidth={2.5}
dot={{ r: 4, fill: colors.signalLine }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
)}
{activeIndicatorType === 'stochastic' && (
<ResponsiveContainer width="100%" height={chartHeight}>
<ComposedChart data={zoomedData} margin={chartMargin}>
<CartesianGrid stroke="#2d3748" strokeWidth={0.5} strokeDasharray="3 3" />
<XAxis
dataKey="date"
type="category"
tick={{ fontSize: 11, fill: '#8b95a5' }}
tickLine={{ stroke: '#3a4557' }}
axisLine={{ stroke: '#3a4557' }}
tickFormatter={formatXAxisLabel}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: '#8b95a5' }}
tickLine={{ stroke: '#3a4557' }}
axisLine={{ stroke: '#3a4557' }}
tickFormatter={formatYAxis}
width={60}
/>
<Customized component={VerticalCrosshair} />
<ReferenceLine y={indicatorSettings.stochasticOverbought} stroke={colors.stochasticOverboughtColor} strokeDasharray="3 3" strokeWidth={1} />
<ReferenceLine y={indicatorSettings.stochasticNeutral} stroke={colors.stochasticNeutralColor} strokeDasharray="3 3" strokeWidth={1} />
<ReferenceLine y={indicatorSettings.stochasticOversold} stroke={colors.stochasticOversoldColor} strokeDasharray="3 3" strokeWidth={1} />
<Line
type="monotone"
dataKey="stochasticK"
stroke={colors.stochasticK}
strokeWidth={2.5}
dot={{ r: 4, fill: colors.stochasticK }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="stochasticD"
stroke={colors.stochasticD}
strokeWidth={2.5}
dot={{ r: 4, fill: colors.stochasticD }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
)}
</Box>
</Box>
);
};
export default IndicatorPopupWindow;
@@ -0,0 +1,562 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Switch,
FormControlLabel,
Divider,
useTheme,
useMediaQuery,
Alert,
Paper,
TextField,
Slider,
IconButton,
} from '@mui/material';
import { DragIndicator, Close } from '@mui/icons-material';
import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext';
import { useIndicatorSettings, IndicatorSettings } from '../contexts/IndicatorSettingsContext';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useIndicatorVisibility, IndicatorVisibilitySettings, IndicatorOrder } from '../contexts/IndicatorVisibilityContext';
import { useAuth } from '../contexts/AuthContext';
import DraggablePaper from './DraggablePaper';
interface IndicatorSettingsDialogProps {
open: boolean;
onClose: () => void;
}
// 지표별 색상/굵기 설정 키 매핑 (여러 색상 지원)
interface ColorConfig {
label: string;
colorKey: keyof ChartColorSettings;
widthKey?: keyof ChartColorSettings;
}
const indicatorColorConfigs: Record<string, ColorConfig[]> = {
macdLine: [
{ label: 'MACD', colorKey: 'macdLine', widthKey: 'macdLineWidth' },
{ label: '시그널', colorKey: 'signalLine', widthKey: 'signalLineWidth' },
],
rsi: [
{ label: '', colorKey: 'rsiColor', widthKey: 'rsiWidth' },
],
stochasticK: [
{ label: '%K', colorKey: 'stochasticK', widthKey: 'stochasticKWidth' },
{ label: '%D', colorKey: 'stochasticD', widthKey: 'stochasticDWidth' },
],
cci: [
{ label: '', colorKey: 'cciColor', widthKey: 'cciWidth' },
],
adx: [
{ label: '', colorKey: 'adxColor', widthKey: 'adxWidth' },
],
plusDi: [
{ label: '+DI', colorKey: 'plusDiColor', widthKey: 'plusDiWidth' },
{ label: '-DI', colorKey: 'minusDiColor', widthKey: 'minusDiWidth' },
],
minusDi: [], // plusDi에서 함께 처리
williamsR: [
{ label: '', colorKey: 'williamsRColor', widthKey: 'williamsRWidth' },
],
trix: [
{ label: 'TRIX', colorKey: 'trixColor', widthKey: 'trixWidth' },
{ label: '시그널', colorKey: 'trixSignalColor', widthKey: 'trixSignalWidth' },
],
newPsychological: [
{ label: '', colorKey: 'newPsychologicalColor', widthKey: 'newPsychologicalWidth' },
],
investPsychological: [
{ label: '', colorKey: 'investPsychologicalColor', widthKey: 'investPsychologicalWidth' },
],
bwi: [
{ label: '', colorKey: 'bwiColor', widthKey: 'bwiWidth' },
],
obv: [
{ label: '', colorKey: 'obvColor', widthKey: 'obvWidth' },
],
volumeOsc: [
{ label: '', colorKey: 'volumeOscColor', widthKey: 'volumeOscWidth' },
],
vr: [
{ label: '', colorKey: 'vrColor', widthKey: 'vrWidth' },
],
disparity: [
{ label: '초단기', colorKey: 'disparityUltraShortColor', widthKey: 'disparityUltraShortWidth' },
{ label: '단기', colorKey: 'disparityShortColor', widthKey: 'disparityShortWidth' },
{ label: '중기', colorKey: 'disparityMidColor', widthKey: 'disparityMidWidth' },
{ label: '장기', colorKey: 'disparityLongColor', widthKey: 'disparityLongWidth' },
],
};
// 지표별 파라미터 키 매핑
const indicatorParamKeys: Record<string, { key: string; label: string }[]> = {
macdLine: [{ key: 'macdFastPeriod', label: '단기 EMA' }, { key: 'macdSlowPeriod', label: '장기 EMA' }, { key: 'macdSignalPeriod', label: '시그널' }],
rsi: [{ key: 'rsiPeriod', label: '기간' }],
stochasticK: [{ key: 'stochasticKPeriod', label: '기간' }, { key: 'stochasticSlowing', label: '%K' }, { key: 'stochasticDPeriod', label: '%D' }],
cci: [{ key: 'cciPeriod', label: '기간' }, { key: 'cciSignalPeriod', label: '시그널' }],
adx: [{ key: 'adxPeriod', label: '기간' }],
williamsR: [{ key: 'williamsRPeriod', label: '기간' }],
trix: [{ key: 'trixPeriod', label: '기간' }, { key: 'trixSignalPeriod', label: '시그널' }],
newPsychological: [{ key: 'newPsychologicalPeriod', label: '기간' }],
investPsychological: [{ key: 'investPsychologicalPeriod', label: '기간' }],
bwi: [{ key: 'bwiPeriod', label: '기간' }],
obv: [{ key: 'obvPeriod', label: '기간' }],
volumeOsc: [{ key: 'volumeOscShortPeriod', label: '단기' }, { key: 'volumeOscLongPeriod', label: '장기' }],
vr: [{ key: 'vrPeriod', label: '기간' }],
disparity: [
{ key: 'disparityUltraShortPeriod', label: '초단기' },
{ key: 'disparityShortPeriod', label: '단기' },
{ key: 'disparityMidPeriod', label: '중기' },
{ key: 'disparityLongPeriod', label: '장기' },
],
plusDi: [{ key: 'dmiPeriod', label: '기간' }],
minusDi: [{ key: 'dmiPeriod', label: '기간' }],
};
// Sortable 아이템 컴포넌트 - 이동평균선 설정과 동일한 스타일
interface SortableItemProps {
indicator: IndicatorOrder;
isDisabled: boolean;
checked: boolean;
onToggle: () => void;
colors: ChartColorSettings;
updateColors: (c: Partial<ChartColorSettings>) => void;
indicatorSettings: IndicatorSettings;
updateIndicatorSettings: (s: Partial<IndicatorSettings>) => void;
}
const SortableItem: React.FC<SortableItemProps> = ({
indicator,
isDisabled,
checked,
onToggle,
colors,
updateColors,
indicatorSettings,
updateIndicatorSettings,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: indicator.id, disabled: isDisabled || indicator.settingKey === 'candleChart' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const canDrag = !isDisabled && indicator.settingKey !== 'candleChart';
const colorConfigs = indicatorColorConfigs[indicator.id] || [];
const paramKeys = indicatorParamKeys[indicator.id];
const hasSettings = indicator.settingKey !== 'candleChart' && indicator.settingKey !== 'volume';
// 첫 번째 색상/굵기 설정
const primaryColor = colorConfigs[0];
// 추가 색상 설정 (시그널 등)
const secondaryColors = colorConfigs.slice(1);
return (
<Paper
ref={setNodeRef}
style={style}
sx={{
mb: 1,
border: isDragging ? '2px solid #1976d2' : checked ? '1px solid' : '1px solid',
borderColor: isDragging ? '#1976d2' : checked ? 'primary.main' : 'divider',
borderRadius: 1,
bgcolor: isDragging ? 'action.hover' : checked ? 'action.selected' : 'background.paper',
boxShadow: isDragging ? 4 : 1,
opacity: checked ? 1 : 0.7,
}}
>
<Box
sx={{
px: 1.5,
py: 1.2,
display: 'flex',
alignItems: 'center',
gap: 1.5,
minHeight: 48,
}}
>
{/* 사용 체크박스 */}
<Box sx={{ width: 40, display: 'flex', justifyContent: 'center' }}>
<Switch
checked={checked}
onChange={onToggle}
disabled={indicator.settingKey === 'candleChart'}
size="small"
/>
</Box>
{/* 드래그 핸들 + 지표명 */}
<Box sx={{ width: 100, display: 'flex', alignItems: 'center', gap: 0.5 }}>
{canDrag && (
<Box
{...attributes}
{...listeners}
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' }, display: 'flex' }}
>
<DragIndicator color="action" sx={{ fontSize: 18 }} />
</Box>
)}
<Typography variant="body2" sx={{ fontWeight: checked ? 'bold' : 'normal', fontSize: '13px' }}>
{indicator.label}
</Typography>
</Box>
{/* 파라미터 영역 */}
<Box sx={{ width: 180, display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasSettings && paramKeys && paramKeys.map((param, idx) => (
<TextField
key={param.key}
type="number"
size="small"
placeholder={param.label}
value={(indicatorSettings as any)[param.key] || ''}
onChange={(e) => updateIndicatorSettings({ [param.key]: Number(e.target.value) })}
onClick={(e) => e.stopPropagation()}
disabled={!checked}
inputProps={{ min: 1, max: 999, style: { fontSize: 12, padding: '6px 8px', textAlign: 'center' } }}
sx={{
width: paramKeys.length > 2 ? 50 : 60,
'& .MuiOutlinedInput-root': { bgcolor: 'background.default' },
}}
/>
))}
</Box>
{/* 색상 영역 */}
<Box sx={{ width: 100, display: 'flex', alignItems: 'center', gap: 1 }}>
{hasSettings && primaryColor && (
<Box sx={{ position: 'relative' }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
bgcolor: colors[primaryColor.colorKey] as string,
border: '2px solid',
borderColor: 'divider',
cursor: checked ? 'pointer' : 'default',
opacity: checked ? 1 : 0.5,
}}
/>
<input
type="color"
value={colors[primaryColor.colorKey] as string || '#666666'}
onChange={(e) => updateColors({ [primaryColor.colorKey]: e.target.value })}
onClick={(e) => e.stopPropagation()}
disabled={!checked}
style={{
position: 'absolute', top: 0, left: 0,
width: 32, height: 32, opacity: 0, cursor: 'pointer'
}}
/>
</Box>
)}
{/* 추가 색상 (시그널 등) */}
{hasSettings && secondaryColors.map((cfg) => (
<Box key={cfg.colorKey} sx={{ position: 'relative' }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 0.5,
bgcolor: colors[cfg.colorKey] as string,
border: '1px solid',
borderColor: 'divider',
cursor: checked ? 'pointer' : 'default',
opacity: checked ? 1 : 0.5,
}}
/>
<input
type="color"
value={colors[cfg.colorKey] as string || '#666666'}
onChange={(e) => updateColors({ [cfg.colorKey]: e.target.value })}
onClick={(e) => e.stopPropagation()}
disabled={!checked}
style={{
position: 'absolute', top: 0, left: 0,
width: 24, height: 24, opacity: 0, cursor: 'pointer'
}}
/>
</Box>
))}
</Box>
{/* 선 굵기 영역 */}
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
{hasSettings && primaryColor?.widthKey && (
<>
<Slider
value={colors[primaryColor.widthKey] as number || 1}
onChange={(_, value) => updateColors({ [primaryColor.widthKey!]: value as number })}
onClick={(e) => e.stopPropagation()}
disabled={!checked}
min={0.5}
max={5}
step={0.5}
sx={{ flex: 1, maxWidth: 150 }}
size="small"
/>
<Typography variant="caption" sx={{ minWidth: 30, fontSize: '11px', color: 'text.secondary' }}>
{colors[primaryColor.widthKey]}px
</Typography>
{/* 선 미리보기 */}
<Box
sx={{
width: 40,
height: Math.max((colors[primaryColor.widthKey] as number || 1) * 2, 2),
bgcolor: checked ? colors[primaryColor.colorKey] as string : 'grey.400',
borderRadius: 0.5,
}}
/>
</>
)}
</Box>
</Box>
</Paper>
);
};
const IndicatorSettingsDialog: React.FC<IndicatorSettingsDialogProps> = ({ open, onClose }) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { settings, order, updateSettings, updateOrder, resetSettings, saveSettings } = useIndicatorVisibility();
const { colors, updateColors } = useChartColors();
const { indicatorSettings, updateIndicatorSettings } = useIndicatorSettings();
const { isAuthenticated } = useAuth();
const [tempSettings, setTempSettings] = useState<IndicatorVisibilitySettings>(settings);
const [tempOrder, setTempOrder] = useState<IndicatorOrder[]>(order);
const [activeId, setActiveId] = useState<string | null>(null);
// 드래그 센서 설정 - 즉시 드래그 시작 가능하도록 설정
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px 이동 후 드래그 시작
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100, // 100ms 터치 후 드래그 시작
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 다이얼로그가 열릴 때마다 현재 설정으로 초기화
React.useEffect(() => {
if (open) {
setTempSettings(settings);
setTempOrder(order);
}
}, [open, settings, order]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id) {
setTempOrder((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleToggle = (key: keyof IndicatorVisibilitySettings) => {
// candleChart는 변경 불가
if (key === 'candleChart') return;
setTempSettings(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = async () => {
updateSettings(tempSettings);
updateOrder(tempOrder);
await saveSettings(isAuthenticated);
onClose();
};
const handleReset = () => {
if (window.confirm('모든 보조지표 설정을 기본값으로 초기화하시겠습니까?')) {
resetSettings();
onClose();
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
fullScreen={isMobile}
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
minHeight: isMobile ? '100%' : '60vh',
maxHeight: isMobile ? '100%' : '90vh',
width: isMobile ? '100%' : 750,
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
📊
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{/* 헤더 행 - 이동평균선 설정과 동일한 스타일 */}
<Paper sx={{ p: 1.5, mb: 2, bgcolor: 'action.hover' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 40, textAlign: 'center' }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary"></Typography>
</Box>
<Box sx={{ width: 100 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary"></Typography>
</Box>
<Box sx={{ width: 180 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary"></Typography>
</Box>
<Box sx={{ width: 100 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary"></Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary"> </Typography>
</Box>
</Box>
</Paper>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tempOrder.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
{tempOrder.map((indicator) => {
const value = tempSettings[indicator.settingKey];
const isChecked = typeof value === 'boolean' ? value : false;
return (
<SortableItem
key={indicator.id}
indicator={indicator}
isDisabled={false}
checked={isChecked}
onToggle={() => handleToggle(indicator.settingKey)}
colors={colors}
updateColors={updateColors}
indicatorSettings={indicatorSettings}
updateIndicatorSettings={updateIndicatorSettings}
/>
);
})}
</SortableContext>
<DragOverlay>
{activeId ? (
<Paper
sx={{
p: 1.5,
display: 'flex',
alignItems: 'center',
border: '2px solid #1976d2',
borderRadius: 1,
bgcolor: 'background.paper',
boxShadow: 6,
opacity: 0.9,
}}
>
<DragIndicator color="primary" sx={{ mr: 1.5 }} />
<Typography variant="body1">
{tempOrder.find(item => item.id === activeId)?.label}
</Typography>
</Paper>
) : null}
</DragOverlay>
</DndContext>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={handleReset} color="error" variant="outlined">
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={onClose} variant="outlined">
</Button>
<Button onClick={handleSave} variant="contained" color="primary">
</Button>
</DialogActions>
</Dialog>
);
};
export default IndicatorSettingsDialog;
@@ -0,0 +1,556 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Slider,
Paper,
IconButton,
Tooltip,
Switch,
Button,
Grid,
Select,
MenuItem,
FormControl,
} from '@mui/material';
import { Refresh, DragIndicator, Add, Delete } from '@mui/icons-material';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext';
import { useIndicatorSettings, IndicatorSettings, CustomReferenceLine } from '../contexts/IndicatorSettingsContext';
import { useIndicatorVisibility, IndicatorVisibilitySettings, IndicatorOrder } from '../contexts/IndicatorVisibilityContext';
// 지표별 색상/굵기 설정 키 매핑
interface ColorConfig {
label: string;
colorKey: keyof ChartColorSettings;
widthKey?: keyof ChartColorSettings;
}
const indicatorColorConfigs: Record<string, ColorConfig[]> = {
macdLine: [
{ label: 'MACD', colorKey: 'macdLine', widthKey: 'macdLineWidth' },
{ label: '시그널', colorKey: 'signalLine', widthKey: 'signalLineWidth' },
],
rsi: [
{ label: 'RSI', colorKey: 'rsiColor', widthKey: 'rsiWidth' },
{ label: '신호', colorKey: 'rsiSignalColor', widthKey: 'rsiSignalWidth' },
],
stochasticK: [
{ label: '%K', colorKey: 'stochasticK', widthKey: 'stochasticKWidth' },
{ label: '%D', colorKey: 'stochasticD', widthKey: 'stochasticDWidth' },
],
cci: [
{ label: 'CCI', colorKey: 'cciColor', widthKey: 'cciWidth' },
{ label: '신호', colorKey: 'cciSignalColor', widthKey: 'cciSignalWidth' },
],
adx: [{ label: 'ADX', colorKey: 'adxColor', widthKey: 'adxWidth' }],
plusDi: [
{ label: '+DI', colorKey: 'plusDiColor', widthKey: 'plusDiWidth' },
{ label: '-DI', colorKey: 'minusDiColor', widthKey: 'minusDiWidth' },
],
williamsR: [{ label: 'Williams%R', colorKey: 'williamsRColor', widthKey: 'williamsRWidth' }],
trix: [
{ label: 'TRIX', colorKey: 'trixColor', widthKey: 'trixWidth' },
{ label: '시그널', colorKey: 'trixSignalColor', widthKey: 'trixSignalWidth' },
],
newPsychological: [{ label: '신심리도', colorKey: 'newPsychologicalColor', widthKey: 'newPsychologicalWidth' }],
investPsychological: [{ label: '투자심리도', colorKey: 'investPsychologicalColor', widthKey: 'investPsychologicalWidth' }],
bwi: [{ label: 'BWI', colorKey: 'bwiColor', widthKey: 'bwiWidth' }],
obv: [
{ label: 'OBV', colorKey: 'obvColor', widthKey: 'obvWidth' },
{ label: '신호', colorKey: 'obvSignalColor', widthKey: 'obvSignalWidth' },
],
volumeOsc: [{ label: 'VolumeOSC', colorKey: 'volumeOscColor', widthKey: 'volumeOscWidth' }],
vr: [{ label: 'VR', colorKey: 'vrColor', widthKey: 'vrWidth' }],
disparity: [
{ label: '초단기', colorKey: 'disparityUltraShortColor', widthKey: 'disparityUltraShortWidth' },
{ label: '단기', colorKey: 'disparityShortColor', widthKey: 'disparityShortWidth' },
{ label: '중기', colorKey: 'disparityMidColor', widthKey: 'disparityMidWidth' },
{ label: '장기', colorKey: 'disparityLongColor', widthKey: 'disparityLongWidth' },
],
};
// 지표별 파라미터 키 매핑
const indicatorParamKeys: Record<string, { key: string; label: string }[]> = {
macdLine: [{ key: 'macdFastPeriod', label: '단기 EMA' }, { key: 'macdSlowPeriod', label: '장기 EMA' }, { key: 'macdSignalPeriod', label: '시그널' }],
rsi: [{ key: 'rsiPeriod', label: '기간' }, { key: 'rsiSignalPeriod', label: '신호' }],
stochasticK: [{ key: 'stochasticKPeriod', label: '기간' }, { key: 'stochasticSlowing', label: '%K' }, { key: 'stochasticDPeriod', label: '%D' }],
cci: [{ key: 'cciPeriod', label: '기간' }, { key: 'cciSignalPeriod', label: '신호' }],
adx: [{ key: 'adxPeriod', label: '기간' }],
williamsR: [{ key: 'williamsRPeriod', label: '기간' }],
trix: [{ key: 'trixPeriod', label: '기간' }, { key: 'trixSignalPeriod', label: '시그널' }],
newPsychological: [{ key: 'newPsychologicalPeriod', label: '기간' }],
investPsychological: [{ key: 'investPsychologicalPeriod', label: '기간' }],
bwi: [{ key: 'bwiPeriod', label: '기간' }],
obv: [{ key: 'obvPeriod', label: '기간' }, { key: 'obvSignalPeriod', label: '신호' }],
volumeOsc: [{ key: 'volumeOscShortPeriod', label: '단기' }, { key: 'volumeOscLongPeriod', label: '장기' }],
vr: [{ key: 'vrPeriod', label: '기간' }],
disparity: [
{ key: 'disparityUltraShortPeriod', label: '초단기' },
{ key: 'disparityShortPeriod', label: '단기' },
{ key: 'disparityMidPeriod', label: '중기' },
{ key: 'disparityLongPeriod', label: '장기' },
],
plusDi: [{ key: 'dmiPeriod', label: '기간' }],
};
// 지표 ID -> 지표 타입 매핑
const indicatorIdToType: Record<string, string> = {
volume: 'volume',
macdLine: 'macd',
rsi: 'rsi',
stochasticK: 'stochastic',
cci: 'cci',
adx: 'adx',
plusDi: 'dmi',
williamsR: 'williamsR',
trix: 'trix',
disparity: 'disparity',
newPsychological: 'newPsychological',
investPsychological: 'investPsychological',
bwi: 'bwi',
obv: 'obv',
volumeOsc: 'volumeOsc',
vr: 'vr',
};
// Sortable 아이템 컴포넌트 - 테이블 행 스타일
interface SortableItemProps {
indicator: IndicatorOrder;
checked: boolean;
onToggle: () => void;
colors: ChartColorSettings;
updateColors: (c: Partial<ChartColorSettings>) => void;
indicatorSettings: IndicatorSettings;
updateIndicatorSettings: (s: Partial<IndicatorSettings>) => void;
getCustomReferenceLines: (indicatorType: string) => CustomReferenceLine[];
addCustomReferenceLine: (indicatorType: string, line: Omit<CustomReferenceLine, 'id'>) => void;
removeCustomReferenceLine: (indicatorType: string, lineId: string) => void;
updateCustomReferenceLine: (indicatorType: string, lineId: string, updates: Partial<CustomReferenceLine>) => void;
}
const SortableItem: React.FC<SortableItemProps> = ({
indicator,
checked,
onToggle,
colors,
updateColors,
indicatorSettings,
updateIndicatorSettings,
getCustomReferenceLines,
addCustomReferenceLine,
removeCustomReferenceLine,
updateCustomReferenceLine,
}) => {
const [expanded, setExpanded] = useState(false);
const isDisabled = indicator.settingKey === 'volume';
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: indicator.id, disabled: isDisabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const canDrag = !isDisabled;
const colorConfigs = indicatorColorConfigs[indicator.id] || [];
const paramKeys = indicatorParamKeys[indicator.id] || [];
const hasSettings = indicator.settingKey !== 'volume';
const indicatorType = indicatorIdToType[indicator.id] || indicator.id;
const referenceLines = getCustomReferenceLines(indicatorType);
const primaryColor = colorConfigs[0];
const handleAddReferenceLine = () => {
addCustomReferenceLine(indicatorType, {
name: '기준선',
value: 0,
color: '#ffeb3b',
lineStyle: 'dashed',
});
};
// 파라미터와 색상 매핑
const getColorForParam = (paramIndex: number) => {
if (paramIndex < colorConfigs.length) {
return colorConfigs[paramIndex];
}
return null;
};
return (
<Box ref={setNodeRef} style={style}>
{/* 메인 행 */}
<Paper
sx={{
mb: expanded ? 0 : 0.5,
border: '1px solid',
borderColor: isDragging ? 'primary.main' : checked ? 'primary.light' : 'divider',
borderRadius: expanded ? '4px 4px 0 0' : 1,
bgcolor: isDragging ? 'action.hover' : checked ? 'action.selected' : 'background.paper',
}}
>
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
minHeight: 48,
}}
>
{/* 드래그 핸들 */}
{canDrag ? (
<Box
{...attributes}
{...listeners}
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' }, display: 'flex' }}
>
<DragIndicator color="action" sx={{ fontSize: 18 }} />
</Box>
) : (
<Box sx={{ width: 18 }} />
)}
{/* 지표명 */}
<Typography variant="body2" sx={{ minWidth: 80, fontWeight: checked ? 'bold' : 'normal', fontSize: '13px' }}>
{indicator.label}
</Typography>
{/* 파라미터 + 색상 (이름표시 | 값입력 | 색상버튼) */}
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{hasSettings && paramKeys.map((param, idx) => {
const colorCfg = getColorForParam(idx);
return (
<Box key={param.key} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* 파라미터 이름 */}
<Typography variant="caption" sx={{ fontSize: '11px', color: 'text.secondary', minWidth: 28 }}>
{param.label}
</Typography>
{/* 값 입력 */}
<TextField
type="number"
size="small"
value={(indicatorSettings as any)[param.key] || ''}
onChange={(e) => updateIndicatorSettings({ [param.key]: Number(e.target.value) })}
inputProps={{ min: 1, max: 999, style: { fontSize: 11, padding: '4px 6px', textAlign: 'center' } }}
sx={{ width: 50, '& .MuiOutlinedInput-root': { bgcolor: 'background.default' } }}
/>
{/* 색상 선택 (있는 경우만) */}
{colorCfg && (
<Box sx={{ position: 'relative' }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 0.5,
bgcolor: colors[colorCfg.colorKey] as string,
border: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
}}
/>
<input
type="color"
value={colors[colorCfg.colorKey] as string || '#666666'}
onChange={(e) => updateColors({ [colorCfg.colorKey]: e.target.value })}
style={{ position: 'absolute', top: 0, left: 0, width: 24, height: 24, opacity: 0, cursor: 'pointer' }}
/>
</Box>
)}
</Box>
);
})}
</Box>
{/* 선 굵기 슬라이더 + 미리보기 */}
{hasSettings && primaryColor?.widthKey && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 140 }}>
<Slider
value={colors[primaryColor.widthKey] as number || 1.5}
onChange={(_, value) => updateColors({ [primaryColor.widthKey!]: value as number })}
min={0.5}
max={5}
step={0.5}
size="small"
sx={{ width: 80 }}
/>
<Typography variant="caption" sx={{ minWidth: 28, fontSize: '10px' }}>
{colors[primaryColor.widthKey]}px
</Typography>
{/* 미리보기 */}
<Box
sx={{
width: 30,
height: Math.max((colors[primaryColor.widthKey] as number || 1.5) * 2, 2),
backgroundColor: colors[primaryColor.colorKey] as string,
borderRadius: 0.5,
}}
/>
</Box>
)}
{/* 우측 끝: ON/OFF 스위치 + 펼침 버튼 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto' }}>
<Switch checked={checked} onChange={onToggle} disabled={isDisabled} size="small" />
{hasSettings ? (
<IconButton size="small" onClick={() => setExpanded(!expanded)} sx={{ p: 0.25 }}>
<Typography variant="caption" sx={{ fontSize: '10px', color: 'text.secondary' }}>
{expanded ? '▲' : '▼'}
</Typography>
</IconButton>
) : (
<Box sx={{ width: 24 }} />
)}
</Box>
</Box>
</Paper>
{/* 확장된 기준선 설정 */}
{expanded && hasSettings && (
<Paper
sx={{
mb: 0.5,
p: 1.5,
border: '1px solid',
borderTop: 'none',
borderColor: 'divider',
borderRadius: '0 0 4px 4px',
bgcolor: 'action.hover',
}}
>
{/* 기준선 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
📏
</Typography>
<Button size="small" startIcon={<Add sx={{ fontSize: 14 }} />} onClick={handleAddReferenceLine} sx={{ fontSize: '10px', py: 0 }}>
</Button>
</Box>
{referenceLines.length > 0 ? (
<>
{/* 기준선 테이블 헤더 */}
<Box sx={{ p: 0.5, mb: 0.5, bgcolor: 'primary.main', borderRadius: 0.5, opacity: 0.85 }}>
<Grid container spacing={0.5} alignItems="center">
<Grid item xs={3}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}> </Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white" sx={{ fontSize: '10px' }}></Typography>
</Grid>
</Grid>
</Box>
{/* 기준선 행 */}
{referenceLines.map((line) => (
<Box key={line.id} sx={{ p: 0.5, mb: 0.5, bgcolor: 'background.paper', borderRadius: 0.5, border: '1px solid', borderColor: 'divider' }}>
<Grid container spacing={0.5} alignItems="center">
<Grid item xs={3}>
<TextField
size="small"
fullWidth
value={line.name}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { name: e.target.value })}
inputProps={{ style: { fontSize: 10, padding: '4px 6px' } }}
/>
</Grid>
<Grid item xs={2}>
<TextField
size="small"
fullWidth
type="number"
value={line.value}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { value: parseFloat(e.target.value) || 0 })}
inputProps={{ style: { fontSize: 10, padding: '4px 6px', textAlign: 'center' } }}
/>
</Grid>
<Grid item xs={2}>
<Box sx={{ position: 'relative' }}>
<Box sx={{ width: 20, height: 20, borderRadius: 0.5, bgcolor: line.color, border: '1px solid', borderColor: 'divider', cursor: 'pointer' }} />
<input
type="color"
value={line.color}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { color: e.target.value })}
style={{ position: 'absolute', top: 0, left: 0, width: 20, height: 20, opacity: 0, cursor: 'pointer' }}
/>
</Box>
</Grid>
<Grid item xs={3}>
<FormControl size="small" fullWidth>
<Select
value={line.lineStyle || 'dashed'}
onChange={(e) => updateCustomReferenceLine(indicatorType, line.id, { lineStyle: e.target.value as 'solid' | 'dashed' | 'dotted' })}
sx={{ fontSize: '10px', '& .MuiSelect-select': { py: 0.5, px: 0.5 } }}
>
<MenuItem value="solid" sx={{ fontSize: '10px' }}></MenuItem>
<MenuItem value="dashed" sx={{ fontSize: '10px' }}></MenuItem>
<MenuItem value="dotted" sx={{ fontSize: '10px' }}></MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={2}>
<IconButton size="small" onClick={() => removeCustomReferenceLine(indicatorType, line.id)} color="error" sx={{ p: 0.25 }}>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
</Grid>
</Grid>
</Box>
))}
</>
) : (
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', textAlign: 'center', py: 0.5 }}>
</Typography>
)}
</Paper>
)}
</Box>
);
};
const IndicatorSettingsPanel: React.FC = () => {
const { colors, updateColors, resetColors } = useChartColors();
const {
indicatorSettings,
updateIndicatorSettings,
resetIndicatorSettings,
getCustomReferenceLines,
addCustomReferenceLine,
removeCustomReferenceLine,
updateCustomReferenceLine,
} = useIndicatorSettings();
const { settings, order, updateSettings, updateOrder, resetSettings } = useIndicatorVisibility();
const [localOrder, setLocalOrder] = useState<IndicatorOrder[]>(order);
useEffect(() => {
setLocalOrder(order);
}, [order]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = localOrder.findIndex((item) => item.id === active.id);
const newIndex = localOrder.findIndex((item) => item.id === over.id);
const newOrder = arrayMove(localOrder, oldIndex, newIndex);
setLocalOrder(newOrder);
updateOrder(newOrder);
}
};
const handleToggle = (key: keyof IndicatorVisibilitySettings) => {
if (key === 'candleChart') return;
if (key === 'macdLine') {
updateSettings({ macdLine: !settings.macdLine, macdHistogram: !settings.macdLine });
} else if (key === 'stochasticK') {
updateSettings({ stochasticK: !settings.stochasticK, stochasticD: !settings.stochasticK, stochasticSlow: !settings.stochasticK });
} else if (key === 'plusDi') {
updateSettings({ plusDi: !settings.plusDi, minusDi: !settings.plusDi });
} else if (key === 'trix') {
updateSettings({ trix: !settings.trix, trixSignal: !settings.trix });
} else {
updateSettings({ [key]: !settings[key] });
}
};
const handleReset = () => {
resetSettings();
resetColors();
resetIndicatorSettings();
};
// 필터링된 지표 목록
const filteredOrder = localOrder.filter(item =>
item.settingKey !== 'candleChart' &&
item.settingKey !== 'stochasticD' &&
item.settingKey !== 'stochasticSlow' &&
item.settingKey !== 'trixSignal' &&
item.settingKey !== 'minusDi' &&
item.settingKey !== 'macdHistogram'
);
return (
<Box>
{/* 헤더 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle1" fontWeight="bold">
📈
</Typography>
<Tooltip title="모두 초기화">
<Button startIcon={<Refresh />} onClick={handleReset} size="small" color="inherit">
</Button>
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
, .
</Typography>
{/* 지표 목록 */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={filteredOrder.map(i => i.id)} strategy={verticalListSortingStrategy}>
{filteredOrder.map((indicator) => (
<SortableItem
key={indicator.id}
indicator={indicator}
checked={Boolean(settings[indicator.settingKey])}
onToggle={() => handleToggle(indicator.settingKey)}
colors={colors}
updateColors={updateColors}
indicatorSettings={indicatorSettings}
updateIndicatorSettings={updateIndicatorSettings}
getCustomReferenceLines={getCustomReferenceLines}
addCustomReferenceLine={addCustomReferenceLine}
removeCustomReferenceLine={removeCustomReferenceLine}
updateCustomReferenceLine={updateCustomReferenceLine}
/>
))}
</SortableContext>
</DndContext>
</Box>
);
};
export default IndicatorSettingsPanel;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
Typography,
Alert,
Link,
IconButton,
InputAdornment,
Divider,
} from '@mui/material';
import { Visibility, VisibilityOff, Close, AccountCircle } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import DraggablePaper from './DraggablePaper';
interface LoginDialogProps {
open: boolean;
onClose: () => void;
onSwitchToSignUp: () => void;
}
const LoginDialog: React.FC<LoginDialogProps> = ({ open, onClose, onSwitchToSignUp }) => {
const { login } = useAuth();
const [usernameOrEmail, setUsernameOrEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!usernameOrEmail || !password) {
setError('모든 필드를 입력해주세요.');
return;
}
setIsLoading(true);
try {
await login(usernameOrEmail, password);
handleClose();
} catch (err: any) {
setError(err.response?.data?.error || '로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setUsernameOrEmail('');
setPassword('');
setError('');
setShowPassword(false);
onClose();
};
// 테스트 계정으로 자동 로그인
const handleTestLogin = async (username: string, password: string) => {
setError('');
setIsLoading(true);
try {
await login(username, password);
handleClose();
} catch (err: any) {
setError(err.response?.data?.error || '로그인에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="xs"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
🔐
</Typography>
<IconButton size="small" onClick={handleClose} sx={{ color: '#fff' }}>
<Close />
</IconButton>
</DialogTitle>
<form onSubmit={handleSubmit}>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="사용자명 또는 이메일"
value={usernameOrEmail}
onChange={(e) => setUsernameOrEmail(e.target.value)}
margin="normal"
autoFocus
disabled={isLoading}
/>
<TextField
fullWidth
label="비밀번호"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
disabled={isLoading}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
?{' '}
<Link
component="button"
type="button"
onClick={() => {
handleClose();
onSwitchToSignUp();
}}
sx={{ cursor: 'pointer' }}
>
</Link>
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
<Button
onClick={handleClose}
disabled={isLoading}
variant="outlined"
fullWidth
>
</Button>
<Button
type="submit"
variant="contained"
disabled={isLoading}
fullWidth
>
{isLoading ? '로그인 중...' : '로그인'}
</Button>
</Box>
<Divider sx={{ width: '100%' }}>
<Typography variant="caption" color="text.secondary">
</Typography>
</Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%' }}>
<Button
variant="outlined"
color="secondary"
startIcon={<AccountCircle />}
onClick={() => handleTestLogin('test1', 'test1')}
disabled={isLoading}
fullWidth
>
Test 1
</Button>
<Button
variant="outlined"
color="secondary"
startIcon={<AccountCircle />}
onClick={() => handleTestLogin('test2', 'test2')}
disabled={isLoading}
fullWidth
>
Test 2
</Button>
<Button
variant="outlined"
color="secondary"
startIcon={<AccountCircle />}
onClick={() => handleTestLogin('test3', 'test3')}
disabled={isLoading}
fullWidth
>
Test 3
</Button>
</Box>
</DialogActions>
</form>
</Dialog>
);
};
export default LoginDialog;
@@ -0,0 +1,375 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Button,
Box,
Typography,
TextField,
Select,
MenuItem,
Switch,
IconButton,
Slider,
Divider,
Tooltip,
} from '@mui/material';
import { Close, Refresh } from '@mui/icons-material';
import { MALineConfig, defaultMALines, useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext';
import DraggablePaper from './DraggablePaper';
interface MASettingsDialogProps {
open: boolean;
onClose: () => void;
/** 멀티차트: 스토어 MA만 편집(적용 시까지 투자분석 컨텍스트 미반영) */
multiChartToolbar?: {
maLines: MALineConfig[];
setMaLines: (value: MALineConfig[] | ((prev: MALineConfig[]) => MALineConfig[])) => void;
};
}
const MASettingsDialog: React.FC<MASettingsDialogProps> = ({ open, onClose, multiChartToolbar }) => {
const { settings, updateMALines } = useIndicatorVisibility();
const [tempMALines, setTempMALines] = useState<MALineConfig[]>([]);
useEffect(() => {
if (!open || !multiChartToolbar) return;
setTempMALines(multiChartToolbar.maLines.map((l) => ({ ...l })));
}, [open, multiChartToolbar]);
useEffect(() => {
if (!open || multiChartToolbar) return;
setTempMALines(settings.maLines ? [...settings.maLines] : [...defaultMALines]);
}, [open, multiChartToolbar, settings.maLines]);
const handleToggle = (index: number) => {
const newLines = [...tempMALines];
newLines[index] = { ...newLines[index], enabled: !newLines[index].enabled };
setTempMALines(newLines);
if (!multiChartToolbar) {
updateMALines(newLines);
}
};
const handlePeriodChange = (index: number, value: number) => {
const newLines = [...tempMALines];
newLines[index] = { ...newLines[index], period: value };
setTempMALines(newLines);
};
const handleColorChange = (index: number, color: string) => {
const newLines = [...tempMALines];
newLines[index] = { ...newLines[index], color };
setTempMALines(newLines);
};
const handleWidthChange = (index: number, width: number) => {
const newLines = [...tempMALines];
newLines[index] = { ...newLines[index], width };
setTempMALines(newLines);
};
// ❌ EMA 사용하지 않음 - SMA만 사용
// const handleTypeChange = (index: number, type: 'SMA' | 'EMA') => {
// const newLines = [...tempMALines];
// newLines[index] = { ...newLines[index], type };
// setTempMALines(newLines);
// };
const handleSave = () => {
if (multiChartToolbar) {
multiChartToolbar.setMaLines(tempMALines.map((l) => ({ ...l })));
} else {
console.log('💾 [MASettingsDialog] 저장 버튼 클릭 - Context 업데이트 중...');
updateMALines(tempMALines);
console.log('✅ [MASettingsDialog] Context 업데이트 완료');
}
onClose();
};
const handleReset = () => {
setTempMALines([...defaultMALines]);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
borderRadius: 2,
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
📊
</Typography>
<Tooltip title="초기화">
<IconButton onClick={handleReset} size="small" sx={{ color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.1)' } }}>
<Refresh />
</IconButton>
</Tooltip>
</Box>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
sx={{
color: '#fff',
borderColor: '#fff',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.1)',
borderColor: '#fff'
},
minWidth: '70px',
fontSize: '13px',
fontWeight: 600,
}}
variant="outlined"
size="small"
>
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleSave();
}}
sx={{
bgcolor: '#fff',
color: '#1976d2',
'&:hover': {
bgcolor: 'rgba(255,255,255,0.9)'
},
minWidth: '70px',
fontSize: '13px',
fontWeight: 600,
}}
variant="contained"
size="small"
>
</Button>
</Box>
</DialogTitle>
<Divider />
<DialogContent sx={{ pt: 2 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
p: 1.5,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(33, 150, 243, 0.15)' : '#e3f2fd',
borderRadius: 1,
border: '1px solid',
borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(33, 150, 243, 0.3)' : '#90caf9'
}}>
<Typography variant="body2" sx={{ fontSize: '12px' }}>
💡 <strong>, , </strong>
</Typography>
</Box>
{/* 헤더 행 */}
<Box sx={{
display: 'grid',
gridTemplateColumns: '60px 120px 120px 90px 150px 1fr 100px',
gap: 1,
p: 1.5,
mb: 1,
bgcolor: (theme) => theme.palette.mode === 'dark' ? '#616161' : '#9e9e9e',
borderRadius: 1,
}}>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}>ON/OFF</Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}> </Typography>
<Typography variant="caption" fontWeight="bold" sx={{ textAlign: 'center', color: 'white' }}></Typography>
</Box>
{/* 각 MA 설정 행 */}
{tempMALines.map((ma, index) => (
<Box
key={ma.id}
sx={{
display: 'grid',
gridTemplateColumns: '60px 120px 120px 90px 150px 1fr 100px',
gap: 1,
p: 1.5,
mb: 1,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
borderRadius: 1,
alignItems: 'center',
transition: 'all 0.2s',
}}
>
{/* 토글 스위치 */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Switch
checked={ma.enabled}
onChange={() => handleToggle(index)}
size="small"
/>
</Box>
{/* 이름 */}
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Typography
variant="body2"
fontWeight={ma.enabled ? 'bold' : 'normal'}
color={ma.enabled ? 'text.primary' : 'text.secondary'}
>
{ma.id}
</Typography>
</Box>
{/* 기간 */}
<TextField
type="number"
value={ma.period}
onChange={(e) => handlePeriodChange(index, parseInt(e.target.value) || 1)}
disabled={!ma.enabled}
size="small"
inputProps={{ min: 1, max: 1000 }}
sx={{
'& .MuiInputBase-input': {
textAlign: 'center',
fontSize: '0.875rem',
padding: '6px 8px',
}
}}
/>
{/* 색상 */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
bgcolor: ma.color,
border: '2px solid',
borderColor: ma.enabled ? '#1976d2' : '#ccc',
cursor: ma.enabled ? 'pointer' : 'not-allowed',
opacity: ma.enabled ? 1 : 0.5,
position: 'relative',
'&:hover': ma.enabled ? {
boxShadow: '0 0 8px rgba(25, 118, 210, 0.6)',
transform: 'scale(1.05)',
} : {},
transition: 'all 0.2s',
}}
>
{ma.enabled && (
<input
type="color"
value={ma.color}
onChange={(e) => handleColorChange(index, e.target.value)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: 'pointer',
border: 'none',
}}
/>
)}
</Box>
</Box>
{/* 선 굵기 슬라이더 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1 }}>
<Slider
value={ma.width}
onChange={(_, value) => handleWidthChange(index, value as number)}
disabled={!ma.enabled}
min={0.5}
max={5}
step={0.5}
sx={{ flex: 1 }}
/>
<Typography variant="caption" sx={{ minWidth: 35, textAlign: 'right', fontSize: '11px' }}>
{ma.width.toFixed(1)}px
</Typography>
</Box>
{/* 선 유형 (고정) */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Select
value="실선"
size="small"
disabled
sx={{
width: '100%',
'& .MuiSelect-select': {
py: 0.5,
fontSize: '0.875rem',
}
}}
>
<MenuItem value="실선"></MenuItem>
</Select>
</Box>
{/* 미리보기 */}
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Box
sx={{
width: 60,
height: ma.width * 2 + 2,
maxHeight: 12,
backgroundColor: ma.enabled ? ma.color : '#ccc',
borderRadius: 0.5,
}}
/>
</Box>
</Box>
))}
</DialogContent>
</Dialog>
);
};
export default MASettingsDialog;
@@ -0,0 +1,264 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
TextField,
Select,
MenuItem,
FormControl,
Checkbox,
IconButton,
Slider,
Paper,
Grid,
Tooltip,
Button,
} from '@mui/material';
import { Refresh } from '@mui/icons-material';
import { MALineConfig, defaultMALines, useIndicatorVisibility } from '../contexts/IndicatorVisibilityContext';
const MASettingsPanel: React.FC = () => {
const { settings, updateMALines } = useIndicatorVisibility();
const [maLines, setMALines] = useState<MALineConfig[]>([]);
// 초기화
useEffect(() => {
setMALines(settings.maLines ? [...settings.maLines] : [...defaultMALines]);
}, [settings.maLines]);
// 변경사항 자동 저장 (debounce)
const saveChanges = useCallback((lines: MALineConfig[]) => {
updateMALines(lines);
}, [updateMALines]);
const handleToggle = (index: number) => {
const newLines = [...maLines];
newLines[index] = { ...newLines[index], enabled: !newLines[index].enabled };
setMALines(newLines);
saveChanges(newLines);
};
const handlePeriodChange = (index: number, value: number) => {
const newLines = [...maLines];
newLines[index] = { ...newLines[index], period: value };
setMALines(newLines);
saveChanges(newLines);
};
const handleColorChange = (index: number, color: string) => {
const newLines = [...maLines];
newLines[index] = { ...newLines[index], color };
setMALines(newLines);
saveChanges(newLines);
};
const handleWidthChange = (index: number, width: number) => {
const newLines = [...maLines];
newLines[index] = { ...newLines[index], width };
setMALines(newLines);
saveChanges(newLines);
};
const handleTypeChange = (index: number, type: 'SMA' | 'EMA') => {
const newLines = [...maLines];
newLines[index] = { ...newLines[index], type };
setMALines(newLines);
saveChanges(newLines);
};
const handleReset = () => {
const newLines = [...defaultMALines];
setMALines(newLines);
saveChanges(newLines);
};
return (
<Box>
{/* 헤더 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
📊
</Typography>
<Tooltip title="초기화">
<Button startIcon={<Refresh />} onClick={handleReset} size="small" color="inherit">
</Button>
</Tooltip>
</Box>
{/* 헤더 행 */}
<Paper sx={{ p: 1.5, mb: 2, bgcolor: 'primary.main', opacity: 0.9 }}>
<Grid container spacing={1} alignItems="center">
<Grid item xs={1}>
<Typography variant="caption" fontWeight="bold" color="white">
</Typography>
</Grid>
<Grid item xs={1.5}>
<Typography variant="caption" fontWeight="bold" color="white">
</Typography>
</Grid>
<Grid item xs={1.5}>
<Typography variant="caption" fontWeight="bold" color="white">
</Typography>
</Grid>
<Grid item xs={2}>
<Typography variant="caption" fontWeight="bold" color="white">
</Typography>
</Grid>
<Grid item xs={1.5}>
<Typography variant="caption" fontWeight="bold" color="white">
</Typography>
</Grid>
<Grid item xs={4.5}>
<Typography variant="caption" fontWeight="bold" color="white">
</Typography>
</Grid>
</Grid>
</Paper>
{/* 각 MA 설정 행 */}
{maLines.map((ma, index) => (
<Paper
key={ma.id}
sx={{
p: 1.5,
mb: 1.5,
border: '1px solid',
borderColor: ma.enabled ? 'primary.main' : 'divider',
bgcolor: ma.enabled ? 'action.selected' : 'background.paper',
transition: 'all 0.2s',
}}
>
<Grid container spacing={1} alignItems="center">
{/* 활성화 체크박스 */}
<Grid item xs={1}>
<Checkbox
checked={ma.enabled}
onChange={() => handleToggle(index)}
sx={{ p: 0.5 }}
/>
</Grid>
{/* 이름 */}
<Grid item xs={1.5}>
<Typography
variant="body2"
fontWeight={ma.enabled ? 'bold' : 'normal'}
color={ma.enabled ? 'text.primary' : 'text.secondary'}
>
{ma.id}
</Typography>
</Grid>
{/* 종류 (SMA/EMA) */}
<Grid item xs={1.5}>
<FormControl size="small" fullWidth>
<Select
value={ma.type}
onChange={(e) => handleTypeChange(index, e.target.value as 'SMA' | 'EMA')}
sx={{ fontSize: '0.875rem' }}
>
<MenuItem value="SMA"></MenuItem>
<MenuItem value="EMA"></MenuItem>
</Select>
</FormControl>
</Grid>
{/* 기간 */}
<Grid item xs={2}>
<TextField
type="number"
value={ma.period}
onChange={(e) => handlePeriodChange(index, parseInt(e.target.value) || 1)}
size="small"
inputProps={{ min: 1, max: 1000 }}
sx={{
'& .MuiInputBase-input': {
textAlign: 'center',
fontSize: '0.875rem',
}
}}
fullWidth
/>
</Grid>
{/* 색상 */}
<Grid item xs={1.5}>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
bgcolor: ma.color,
border: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
'&:hover': { boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.5)' },
}}
/>
<input
type="color"
value={ma.color}
onChange={(e) => handleColorChange(index, e.target.value)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: 32,
height: 32,
opacity: 0,
cursor: 'pointer'
}}
/>
</Box>
</Grid>
{/* 선 굵기 */}
<Grid item xs={4.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Slider
value={ma.width}
onChange={(_, value) => handleWidthChange(index, value as number)}
min={0.5}
max={5}
step={0.5}
sx={{ flex: 1 }}
/>
<Typography variant="caption" sx={{ minWidth: 30, textAlign: 'right' }}>
{ma.width}px
</Typography>
{/* 선 미리보기 */}
<Box
sx={{
width: 40,
height: Math.max(ma.width * 2, 2),
backgroundColor: ma.enabled ? ma.color : '#ccc',
borderRadius: 0.5,
}}
/>
</Box>
</Grid>
</Grid>
</Paper>
))}
{/* 범례/안내 */}
<Box sx={{ mt: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, opacity: 0.8 }}>
<Typography variant="caption" color="info.contrastText">
💡 <strong>(SMA)</strong>: - <br />
💡 <strong>(EMA)</strong>: -
</Typography>
</Box>
</Box>
);
};
export default MASettingsPanel;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,839 @@
import React, { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
Paper,
Typography,
List,
ListItem,
ListItemButton,
Autocomplete,
TextField,
Chip,
keyframes,
Tabs,
Tab,
IconButton,
TableSortLabel,
Divider,
} from '@mui/material';
import {
Circle as CircleIcon,
Star as StarIcon,
StarBorder as StarBorderIcon,
AddCircle as AddCircleIcon,
AddCircleOutline as AddCircleOutlineIcon,
RemoveCircleOutline as RemoveCircleOutlineIcon,
} from '@mui/icons-material';
import { MarketTicker } from '../types/ticker';
import { WebSocketStatus } from '../types/orderbook';
import { toggleFavorite, getAllFavorites } from '../services/cryptoFavoriteApi';
import { toggleHolding, getAllHoldings, CryptoHoldingDto } from '../services/cryptoHoldingApi';
/**
* 가격 변동 플래시 애니메이션
*/
const flashRise = keyframes`
0% { background-color: rgba(239, 68, 68, 0.3); }
100% { background-color: transparent; }
`;
const flashFall = keyframes`
0% { background-color: rgba(59, 130, 246, 0.3); }
100% { background-color: transparent; }
`;
interface MarketListProps {
tickers: Map<string, MarketTicker>;
selectedMarket: string;
onSelectMarket: (market: string) => void;
wsStatus: WebSocketStatus;
}
/**
* 정렬 타입
*/
type SortField = 'koreanName' | 'tradePrice' | 'changeRate' | 'accTradePrice24h';
type SortOrder = 'asc' | 'desc';
/**
* 탭 타입
*/
type MarketTab = 'krw' | 'btc' | 'holdings' | 'favorites';
/**
* 가격 포맷 (소수점 처리)
*/
const formatPrice = (price: number): string => {
if (price >= 1000) {
return price.toLocaleString('ko-KR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
} else if (price >= 100) {
return price.toLocaleString('ko-KR', { minimumFractionDigits: 0, maximumFractionDigits: 1 });
} else if (price >= 1) {
return price.toLocaleString('ko-KR', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
} else {
return price.toLocaleString('ko-KR', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
};
/**
* 거래대금 포맷 (억 단위)
*/
const formatTradePrice = (price: number): string => {
const billion = price / 100000000; // 억 단위
if (billion >= 100) {
return `${Math.floor(billion).toLocaleString()}`;
} else if (billion >= 1) {
return `${billion.toFixed(0)}`;
} else {
return `${(price / 1000000).toFixed(0)}백만`;
}
};
/**
* 등락률 색상
*/
const getChangeColor = (change: 'RISE' | 'EVEN' | 'FALL'): string => {
switch (change) {
case 'RISE':
return '#ef4444'; // 빨간색
case 'FALL':
return '#3b82f6'; // 파란색
case 'EVEN':
default:
return '#6b7280'; // 회색
}
};
/**
* 연결 상태 색상
*/
const getStatusColor = (status: WebSocketStatus): string => {
switch (status) {
case 'connected':
return '#22c55e';
case 'connecting':
return '#eab308';
case 'disconnected':
case 'error':
return '#ef4444';
default:
return '#9ca3af';
}
};
/**
* 종목 목록 컴포넌트
*/
/**
* 가격 변동 추적 컴포넌트 (업비트 스타일)
*/
/** code(KRW-BTC) → "BTC/KRW" 형태 심볼 변환 */
const codeToSymbolLabel = (code: string): string => {
const parts = code.split('-');
if (parts.length === 2) return `${parts[1]}/${parts[0]}`;
return code;
};
/** 변동 지시자 바 (이미지 스타일) */
const ChangeBar = memo<{ change: 'RISE' | 'EVEN' | 'FALL' }>(({ change }) => {
const color = getChangeColor(change);
if (change === 'EVEN') {
return (
<Box sx={{ width: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Box sx={{ width: 8, height: 2, bgcolor: color, borderRadius: 1 }} />
</Box>
);
}
return (
<Box sx={{ width: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Box sx={{
width: 0, height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
...(change === 'RISE'
? { borderBottom: `7px solid ${color}` }
: { borderTop: `7px solid ${color}` }),
}} />
</Box>
);
});
ChangeBar.displayName = 'ChangeBar';
/** USD 가격 포맷 */
const formatUsdPrice = (krwPrice: number, usdtKrwRate: number): string => {
if (!usdtKrwRate || usdtKrwRate === 0) return '-';
const usd = krwPrice / usdtKrwRate;
if (usd >= 10000) return `$${usd.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
if (usd >= 1) return `$${usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
return `$${usd.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`;
};
const TickerItem = memo<{
ticker: MarketTicker;
isSelected: boolean;
isFavorite: boolean;
onSelect: () => void;
onToggleFavorite: (e: React.MouseEvent) => void;
priceUnit?: 'krw' | 'usd';
usdtKrwRate?: number;
/** 'favorite': 별 아이콘 | 'holding': +/- 아이콘 */
iconMode?: 'favorite' | 'holding';
isHolding?: boolean;
onToggleHolding?: (e: React.MouseEvent) => void;
}>(({
ticker, isSelected, isFavorite, onSelect, onToggleFavorite,
priceUnit = 'krw', usdtKrwRate = 1,
iconMode = 'favorite', isHolding = false, onToggleHolding,
}) => {
const [priceFlash, setPriceFlash] = useState<'rise' | 'fall' | null>(null);
const prevPriceRef = useRef(ticker.tradePrice);
useEffect(() => {
if (prevPriceRef.current !== ticker.tradePrice) {
const flash = ticker.tradePrice > prevPriceRef.current ? 'rise' : 'fall';
setPriceFlash(flash);
prevPriceRef.current = ticker.tradePrice;
const timer = setTimeout(() => setPriceFlash(null), 600);
return () => clearTimeout(timer);
}
}, [ticker.tradePrice]);
const changeColor = getChangeColor(ticker.change);
const symbolLabel = codeToSymbolLabel(ticker.code);
const displayPrice = priceUnit === 'usd'
? formatUsdPrice(ticker.tradePrice, usdtKrwRate)
: formatPrice(ticker.tradePrice);
return (
<ListItem
disablePadding
sx={{
borderBottom: 1,
borderColor: 'divider',
'&:hover .favorite-icon': { opacity: 1 },
}}
>
<ListItemButton
selected={isSelected}
onClick={onSelect}
sx={{
py: 0.75,
px: 1,
width: '100%',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
gap: 0.5,
overflow: 'hidden',
'&.Mui-selected': { backgroundColor: 'action.selected' },
'&:hover': { backgroundColor: 'action.hover' },
animation: priceFlash
? `${priceFlash === 'rise' ? flashRise : flashFall} 0.6s ease-out`
: 'none',
}}
>
{/* 아이콘: 즐겨찾기 별 또는 보유 +/- */}
{iconMode === 'holding' ? (
<IconButton
size="small"
onClick={onToggleHolding}
className="favorite-icon"
sx={{
opacity: isHolding ? 1 : 0.3,
transition: 'opacity 0.2s, color 0.2s',
color: isHolding ? '#26a69a' : 'text.secondary',
p: 0.25,
flexShrink: 0,
'&:hover': { opacity: 1, color: isHolding ? '#ef5350' : '#26a69a' },
}}
>
{isHolding
? <RemoveCircleOutlineIcon sx={{ fontSize: 15 }} />
: <AddCircleOutlineIcon sx={{ fontSize: 15 }} />}
</IconButton>
) : (
<IconButton
size="small"
onClick={onToggleFavorite}
className="favorite-icon"
sx={{
opacity: isFavorite ? 1 : 0.25,
transition: 'opacity 0.2s',
color: isFavorite ? 'warning.main' : 'text.secondary',
p: 0.25,
flexShrink: 0,
}}
>
{isFavorite ? <StarIcon sx={{ fontSize: 14 }} /> : <StarBorderIcon sx={{ fontSize: 14 }} />}
</IconButton>
)}
{/* 변동 지시자 */}
<ChangeBar change={ticker.change} />
{/* 한글명 + 영문심볼 */}
<Box sx={{ flex: '1 1 0', minWidth: 0, overflow: 'hidden' }}>
<Typography
variant="body2"
fontWeight={700}
sx={{
fontSize: '0.82rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.3,
}}
>
{ticker.koreanName}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.68rem', lineHeight: 1.2, display: 'block' }}
>
{symbolLabel}
</Typography>
</Box>
{/* 현재가 */}
<Box sx={{ width: 90, flexShrink: 0, textAlign: 'right', overflow: 'hidden' }}>
<Typography
variant="body2"
fontWeight={700}
sx={{
color: changeColor,
fontFamily: 'monospace',
fontSize: '0.82rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{displayPrice}
</Typography>
</Box>
{/* 전일대비 */}
<Box sx={{ width: 68, flexShrink: 0, textAlign: 'right', overflow: 'hidden' }}>
<Typography
variant="caption"
sx={{
color: changeColor,
fontWeight: 700,
display: 'block',
fontSize: '0.75rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
}}
>
{ticker.changeRate > 0 ? '+' : ''}{ticker.changeRate.toFixed(2)}%
</Typography>
</Box>
</ListItemButton>
</ListItem>
);
});
TickerItem.displayName = 'TickerItem';
export const MarketList = memo<MarketListProps>(({ tickers, selectedMarket, onSelectMarket, wsStatus }) => {
const [selectedTicker, setSelectedTicker] = useState<MarketTicker | null>(null);
const [currentTab, setCurrentTab] = useState<MarketTab>('krw');
const [sortField, setSortField] = useState<SortField>('accTradePrice24h');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [holdings, setHoldings] = useState<Set<string>>(new Set());
// 즐겨찾기 로드
useEffect(() => {
loadFavorites();
}, []);
const loadFavorites = async () => {
try {
const favoriteList = await getAllFavorites();
const favoriteSet = new Set(favoriteList.map(f => f.symbol));
setFavorites(favoriteSet);
} catch (error) {
console.error('즐겨찾기 로드 실패:', error);
}
};
// 보유 종목 로드
useEffect(() => {
loadHoldings();
}, []);
const loadHoldings = async () => {
try {
const list = await getAllHoldings();
setHoldings(new Set(list.map(h => h.symbol)));
} catch (error) {
console.error('보유 종목 로드 실패:', error);
}
};
// 보유 토글
const handleToggleHolding = async (e: React.MouseEvent, ticker: MarketTicker) => {
e.stopPropagation();
try {
const isAdded = await toggleHolding({
symbol: ticker.code,
koreanName: ticker.koreanName,
englishName: ticker.englishName,
});
setHoldings(prev => {
const next = new Set(prev);
if (isAdded) next.add(ticker.code);
else next.delete(ticker.code);
return next;
});
} catch (error) {
console.error('보유 종목 토글 실패:', error);
}
};
// USDT/KRW 환율 (BTC 탭 USD 가격 표시용) — 30초마다만 재계산
const usdtKrwRateRef = useRef<number>(1350);
const usdtKrwRate = useMemo(() => {
const rate = tickers.get('KRW-USDT')?.tradePrice || 1350;
usdtKrwRateRef.current = rate;
return rate;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Math.floor(Date.now() / 30000)]); // 30초 버킷
// 즐겨찾기 토글
const handleToggleFavorite = async (e: React.MouseEvent, ticker: MarketTicker) => {
e.stopPropagation();
try {
const isFavorite = await toggleFavorite({
symbol: ticker.code,
koreanName: ticker.koreanName,
englishName: ticker.englishName,
});
setFavorites(prev => {
const newSet = new Set(prev);
if (isFavorite) newSet.add(ticker.code);
else newSet.delete(ticker.code);
return newSet;
});
} catch (error) {
console.error('즐겨찾기 토글 실패:', error);
}
};
// 정렬 변경 핸들러
const handleSortChange = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
};
// 항상 최신 tickers/favorites/holdings를 ref로 유지 (interval 클로저용)
const tickersRef = useRef(tickers);
tickersRef.current = tickers;
const favoritesRef = useRef(favorites);
favoritesRef.current = favorites;
const holdingsRef = useRef(holdings);
holdingsRef.current = holdings;
/** 현재 상태 기준으로 정렬된 코드 배열 계산 */
const computeSortedKeys = useCallback((
_tickers: Map<string, MarketTicker>,
_tab: MarketTab,
_sortField: SortField,
_sortOrder: SortOrder,
_favorites: Set<string>,
_holdings: Set<string>,
): string[] => {
let arr = Array.from(_tickers.values());
switch (_tab) {
case 'krw':
case 'btc':
arr = arr.filter(t => t.code.startsWith('KRW-'));
break;
case 'favorites':
arr = arr.filter(t => t.code.startsWith('KRW-') && _favorites.has(t.code));
break;
case 'holdings':
// 보유 탭: 전체 KRW 목록, 보유 종목은 상단 정렬
arr = arr.filter(t => t.code.startsWith('KRW-'));
break;
}
arr.sort((a, b) => {
// 보유 탭: 보유 종목 항상 상단
if (_tab === 'holdings') {
const aH = _holdings.has(a.code) ? 0 : 1;
const bH = _holdings.has(b.code) ? 0 : 1;
if (aH !== bH) return aH - bH;
}
let cmp = 0;
switch (_sortField) {
case 'koreanName':
cmp = a.koreanName.localeCompare(b.koreanName, 'ko');
break;
case 'tradePrice':
cmp = a.tradePrice - b.tradePrice;
break;
case 'changeRate':
cmp = a.changeRate - b.changeRate;
break;
case 'accTradePrice24h':
cmp = a.accTradePrice24h - b.accTradePrice24h;
break;
}
return _sortOrder === 'asc' ? cmp : -cmp;
});
return arr.map(t => t.code);
}, []);
// 정렬된 코드 배열 state — 이 순서로 목록을 렌더
const [sortedKeys, setSortedKeys] = useState<string[]>([]);
// 정렬 기준(탭/필드/방향/즐겨찾기/보유) 변경 시 즉시 재정렬
useEffect(() => {
setSortedKeys(
computeSortedKeys(tickersRef.current, currentTab, sortField, sortOrder, favoritesRef.current, holdingsRef.current)
);
}, [currentTab, sortField, sortOrder, favorites, holdings, computeSortedKeys]);
// 가격·등락률 기준 정렬 시 2초마다 순서 갱신 (한글명·거래대금은 즉시만)
useEffect(() => {
if (sortField !== 'tradePrice' && sortField !== 'changeRate') return;
const interval = setInterval(() => {
setSortedKeys(
computeSortedKeys(tickersRef.current, currentTab, sortField, sortOrder, favoritesRef.current, holdingsRef.current)
);
}, 2000);
return () => clearInterval(interval);
}, [currentTab, sortField, sortOrder, computeSortedKeys]);
// tickers 첫 수신 시 초기 정렬 (sortedKeys가 비어 있을 때)
useEffect(() => {
if (sortedKeys.length === 0 && tickers.size > 0) {
setSortedKeys(
computeSortedKeys(tickers, currentTab, sortField, sortOrder, favoritesRef.current, holdingsRef.current)
);
}
}, [tickers.size]); // eslint-disable-line react-hooks/exhaustive-deps
// sortedKeys 순서로 현재 tickers 값 조회
const sortedTickers = useMemo(() => {
return sortedKeys
.map(code => tickers.get(code))
.filter((t): t is MarketTicker => t !== undefined);
}, [sortedKeys, tickers]);
// 보유 탭용 전체 KRW 목록 (검색 드롭다운에서 사용)
const allKrwTickers = useMemo(() => {
return Array.from(tickers.values())
.filter(t => t.code.startsWith('KRW-'))
.sort((a, b) => a.koreanName.localeCompare(b.koreanName, 'ko'));
}, [tickers]);
// 검색용 옵션 리스트 — 보유/관심 탭은 전체 KRW, 나머지는 현재 탭 필터 기반
const searchOptions = useMemo(() => {
const base = (currentTab === 'holdings' || currentTab === 'favorites') ? allKrwTickers : sortedTickers;
return base.map(ticker => ({
label: `${ticker.koreanName} (${ticker.symbol})`,
value: ticker.code,
ticker: ticker,
}));
}, [currentTab, allKrwTickers, sortedTickers]);
return (
<Paper
elevation={0}
sx={{
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
height: '100%',
display: 'flex',
flexDirection: 'column',
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
{/* 탭 네비게이션 */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', flexShrink: 0, overflow: 'hidden' }}>
<Tabs
value={currentTab}
onChange={(_, newValue) => setCurrentTab(newValue)}
variant="fullWidth"
sx={{
minHeight: 42,
'& .MuiTab-root': {
minHeight: 42,
fontSize: '0.875rem',
fontWeight: 600,
minWidth: 0,
},
}}
>
<Tab label="원화" value="krw" />
<Tab label="BTC" value="btc" />
<Tab label="보유" value="holdings" />
<Tab label="관심" value="favorites" />
</Tabs>
</Box>
{/* 검색 (Autocomplete) */}
<Box sx={{ p: 1.5, borderBottom: 1, borderColor: 'divider' }}>
<Autocomplete
size="small"
options={searchOptions}
value={(currentTab === 'holdings' || currentTab === 'favorites') ? null : (selectedTicker ? { label: `${selectedTicker.koreanName} (${selectedTicker.symbol})`, value: selectedTicker.code, ticker: selectedTicker } : null)}
onChange={(_, newValue) => {
if (newValue && currentTab !== 'holdings' && currentTab !== 'favorites') {
setSelectedTicker(newValue.ticker);
onSelectMarket(newValue.value);
}
}}
getOptionLabel={(option) => option.label}
clearOnBlur={currentTab === 'holdings' || currentTab === 'favorites'}
blurOnSelect={currentTab === 'holdings' || currentTab === 'favorites'}
renderInput={(params) => (
<TextField
{...params}
placeholder={
currentTab === 'holdings'
? '종목 검색 후 + 버튼으로 추가'
: currentTab === 'favorites'
? '종목 검색 후 ★ 버튼으로 즐겨찾기 추가'
: '코인명/심볼검색'
}
/>
)}
renderOption={(props, option) => {
const changeColor = getChangeColor(option.ticker.change);
const alreadyHolding = holdings.has(option.value);
const alreadyFavorite = favorites.has(option.value);
return (
<li {...props} key={option.value} style={{ paddingRight: (currentTab === 'holdings' || currentTab === 'favorites') ? 4 : undefined }}>
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" fontWeight={600} sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{option.ticker.koreanName}
</Typography>
<Typography variant="caption" sx={{ color: changeColor, fontWeight: 600, flexShrink: 0, ml: 0.5 }}>
{option.ticker.changeRate > 0 ? '+' : ''}
{option.ticker.changeRate.toFixed(2)}%
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{option.ticker.symbol}
</Typography>
<Typography variant="caption" fontWeight={700} sx={{ color: changeColor, fontFamily: 'monospace' }}>
{formatPrice(option.ticker.tradePrice)}
</Typography>
</Box>
</Box>
{/* 보유탭: 드롭다운 내 추가/제거 버튼 */}
{currentTab === 'holdings' && (
<IconButton
size="small"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleHolding(e, option.ticker);
}}
sx={{
flexShrink: 0,
color: alreadyHolding ? '#ef5350' : '#26a69a',
p: 0.5,
'&:hover': { bgcolor: 'action.hover' },
}}
>
{alreadyHolding
? <RemoveCircleOutlineIcon sx={{ fontSize: 18 }} />
: <AddCircleIcon sx={{ fontSize: 18 }} />}
</IconButton>
)}
{/* 관심탭: 드롭다운 내 즐겨찾기 토글 버튼 */}
{currentTab === 'favorites' && (
<IconButton
size="small"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleFavorite(e, option.ticker);
}}
sx={{
flexShrink: 0,
color: alreadyFavorite ? 'warning.main' : 'text.secondary',
opacity: alreadyFavorite ? 1 : 0.4,
p: 0.5,
transition: 'opacity 0.2s, color 0.2s',
'&:hover': { bgcolor: 'action.hover', opacity: 1 },
}}
>
{alreadyFavorite
? <StarIcon sx={{ fontSize: 18 }} />
: <StarBorderIcon sx={{ fontSize: 18 }} />}
</IconButton>
)}
</Box>
</li>
);
}}
noOptionsText="검색 결과가 없습니다"
/>
</Box>
{/* 정렬 헤더 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1,
py: 0.75,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'action.hover',
gap: 0.5,
}}
>
{/* 별 + 변동 아이콘 공간 */}
<Box sx={{ width: 34, flexShrink: 0 }} />
{/* 한글명 */}
<Box sx={{ flex: '1 1 0' }}>
<TableSortLabel
active={sortField === 'koreanName'}
direction={sortField === 'koreanName' ? sortOrder : 'asc'}
onClick={() => handleSortChange('koreanName')}
sx={{ '& .MuiTableSortLabel-icon': { fontSize: '0.9rem' } }}
>
<Typography variant="caption" fontWeight={700} color="text.secondary" sx={{ fontSize: '0.72rem' }}>
</Typography>
</TableSortLabel>
</Box>
{/* 현재가 */}
<Box sx={{ width: 90, flexShrink: 0, textAlign: 'right', overflow: 'hidden' }}>
<TableSortLabel
active={sortField === 'tradePrice'}
direction={sortField === 'tradePrice' ? sortOrder : 'desc'}
onClick={() => handleSortChange('tradePrice')}
sx={{ '& .MuiTableSortLabel-icon': { fontSize: '0.9rem' }, justifyContent: 'flex-end', maxWidth: '100%' }}
>
<Typography variant="caption" fontWeight={700} color="text.secondary"
sx={{ fontSize: '0.72rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{currentTab === 'btc' ? '현재가(USD)' : '현재가'}
</Typography>
</TableSortLabel>
</Box>
{/* 전일대비 */}
<Box sx={{ width: 68, textAlign: 'right', flexShrink: 0 }}>
<TableSortLabel
active={sortField === 'changeRate'}
direction={sortField === 'changeRate' ? sortOrder : 'desc'}
onClick={() => handleSortChange('changeRate')}
sx={{ '& .MuiTableSortLabel-icon': { fontSize: '0.9rem' }, justifyContent: 'flex-end' }}
>
<Typography variant="caption" fontWeight={700} color="text.secondary" sx={{ fontSize: '0.72rem' }}>
</Typography>
</TableSortLabel>
</Box>
</Box>
{/* 종목 목록 — overflowY:scroll로 스크롤바 공간을 항상 점유 */}
<List
sx={{
flex: 1,
overflowY: 'scroll',
overflowX: 'hidden',
p: 0,
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'background.default',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'divider',
borderRadius: '4px',
},
}}
>
{currentTab === 'holdings' ? (
// 보유 탭: DB 보유 종목만 표시
holdings.size === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.8 }}>
.{'\n'}
<Box component="span" sx={{ fontSize: '0.75rem', opacity: 0.7 }}>
+ .
</Box>
</Typography>
</Box>
) : (
sortedTickers
.filter(t => holdings.has(t.code))
.map(ticker => (
<TickerItem
key={ticker.code}
ticker={ticker}
isSelected={ticker.code === selectedMarket}
isFavorite={favorites.has(ticker.code)}
onSelect={() => { setSelectedTicker(ticker); onSelectMarket(ticker.code); }}
onToggleFavorite={(e) => handleToggleFavorite(e, ticker)}
iconMode="holding"
isHolding={true}
onToggleHolding={(e) => handleToggleHolding(e, ticker)}
/>
))
)
) : sortedTickers.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{currentTab === 'favorites'
? '즐겨찾기한 종목이 없습니다.\n별 아이콘을 눌러 추가하세요.'
: '데이터 로딩 중...'}
</Typography>
</Box>
) : (
sortedTickers.map(ticker => (
<TickerItem
key={ticker.code}
ticker={ticker}
isSelected={ticker.code === selectedMarket}
isFavorite={favorites.has(ticker.code)}
onSelect={() => {
setSelectedTicker(ticker);
onSelectMarket(ticker.code);
}}
onToggleFavorite={(e) => handleToggleFavorite(e, ticker)}
priceUnit={currentTab === 'btc' ? 'usd' : 'krw'}
usdtKrwRate={usdtKrwRate}
/>
))
)}
</List>
</Paper>
);
});
MarketList.displayName = 'MarketList';
export default MarketList;
@@ -0,0 +1,400 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Checkbox,
IconButton,
Typography,
Divider,
Select,
MenuItem,
FormControl,
FormControlLabel,
Tabs,
Tab,
} from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material';
import DraggablePaper from './DraggablePaper';
export interface MeasureSettings {
lineColor: string;
lineStyle: 'solid' | 'dashed' | 'dotted';
lineWidth: number;
backgroundColor: string;
backgroundEnabled: boolean;
backgroundOpacity: number;
extendTop: boolean;
extendBottom: boolean;
labelColor: string;
labelSize: number;
labelBackgroundEnabled: boolean;
}
interface MeasureSettingsDialogProps {
open: boolean;
onClose: () => void;
settings: MeasureSettings;
onSave: (settings: MeasureSettings) => void;
onDelete?: () => void;
isEditingIndividual?: boolean;
}
export const defaultMeasureSettings: MeasureSettings = {
lineColor: '#2962ff',
lineStyle: 'solid',
lineWidth: 2,
backgroundColor: '#2962ff',
backgroundEnabled: true,
backgroundOpacity: 0.15,
extendTop: false,
extendBottom: false,
labelColor: '#2962ff',
labelSize: 12,
labelBackgroundEnabled: true,
};
const MeasureSettingsDialog: React.FC<MeasureSettingsDialogProps> = ({
open,
onClose,
settings,
onSave,
onDelete,
isEditingIndividual = false,
}) => {
const [localSettings, setLocalSettings] = useState<MeasureSettings>(settings);
const [activeTab, setActiveTab] = useState(0);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
const handleChange = (field: keyof MeasureSettings, value: any) => {
setLocalSettings(prev => ({ ...prev, [field]: value }));
};
const handleSave = () => {
onSave(localSettings);
onClose();
};
const handleReset = () => {
setLocalSettings(defaultMeasureSettings);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
borderRadius: 2,
bgcolor: 'background.paper',
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}
>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
{isEditingIndividual ? '편집' : '설정'}
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 0 }}>
{/* 탭 */}
<Tabs
value={activeTab}
onChange={(e, newValue) => setActiveTab(newValue)}
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="모습" />
<Tab label="작표" />
<Tab label="보임" />
</Tabs>
{/* 탭 내용 */}
<Box sx={{ p: 3 }}>
{/* 모습 탭 */}
{activeTab === 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* 라인 */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight={600}>
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<input
type="color"
value={localSettings.lineColor}
onChange={(e) => handleChange('lineColor', e.target.value)}
style={{
width: 50,
height: 35,
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
}}
/>
<Box
sx={{
width: 50,
height: 25,
bgcolor: localSettings.lineColor,
borderRadius: 0.5,
border: '1px solid rgba(0,0,0,0.2)',
}}
/>
</Box>
<FormControl size="small" sx={{ minWidth: 100 }}>
<Select
value={localSettings.lineStyle}
onChange={(e) => handleChange('lineStyle', e.target.value)}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed">- - -</MenuItem>
<MenuItem value="dotted">· · ·</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 80 }}>
<Select
value={localSettings.lineWidth}
onChange={(e) => handleChange('lineWidth', Number(e.target.value))}
>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
<MenuItem value={3}>3</MenuItem>
<MenuItem value={4}>4</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* 배경 */}
<Box>
<FormControlLabel
control={
<Checkbox
checked={localSettings.backgroundEnabled}
onChange={(e) => handleChange('backgroundEnabled', e.target.checked)}
/>
}
label={
<Typography variant="subtitle2" fontWeight={600}>
</Typography>
}
/>
{localSettings.backgroundEnabled && (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 1, ml: 4 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<input
type="color"
value={localSettings.backgroundColor}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
style={{
width: 50,
height: 35,
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
}}
/>
<Box
sx={{
width: 50,
height: 25,
bgcolor: localSettings.backgroundColor,
borderRadius: 0.5,
border: '1px solid rgba(0,0,0,0.2)',
opacity: localSettings.backgroundOpacity,
}}
/>
</Box>
<FormControl size="small" sx={{ minWidth: 100 }}>
<Select
value={Math.round(localSettings.backgroundOpacity * 100)}
onChange={(e) => handleChange('backgroundOpacity', Number(e.target.value) / 100)}
>
<MenuItem value={5}>5%</MenuItem>
<MenuItem value={10}>10%</MenuItem>
<MenuItem value={15}>15%</MenuItem>
<MenuItem value={20}>20%</MenuItem>
<MenuItem value={30}>30%</MenuItem>
<MenuItem value={50}>50%</MenuItem>
</Select>
</FormControl>
</Box>
)}
</Box>
{/* 익스텐드 탭 */}
<Box>
<FormControlLabel
control={
<Checkbox
checked={localSettings.extendTop}
onChange={(e) => handleChange('extendTop', e.target.checked)}
/>
}
label="익스텐드 탭"
/>
</Box>
{/* 익스텐드 바텀 */}
<Box>
<FormControlLabel
control={
<Checkbox
checked={localSettings.extendBottom}
onChange={(e) => handleChange('extendBottom', e.target.checked)}
/>
}
label="익스텐드 바텀"
/>
</Box>
</Box>
)}
{/* 작표 탭 */}
{activeTab === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* 라벨 */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight={600}>
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<input
type="color"
value={localSettings.labelColor}
onChange={(e) => handleChange('labelColor', e.target.value)}
style={{
width: 50,
height: 35,
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
}}
/>
<Box
sx={{
width: 50,
height: 25,
bgcolor: localSettings.labelColor,
borderRadius: 0.5,
border: '1px solid rgba(0,0,0,0.2)',
}}
/>
</Box>
<FormControl size="small" sx={{ minWidth: 100 }}>
<Select
value={localSettings.labelSize}
onChange={(e) => handleChange('labelSize', Number(e.target.value))}
>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={11}>11</MenuItem>
<MenuItem value={12}>12</MenuItem>
<MenuItem value={13}>13</MenuItem>
<MenuItem value={14}>14</MenuItem>
<MenuItem value={16}>16</MenuItem>
<MenuItem value={18}>18</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* 라벨 배그라운드 */}
<Box>
<FormControlLabel
control={
<Checkbox
checked={localSettings.labelBackgroundEnabled}
onChange={(e) => handleChange('labelBackgroundEnabled', e.target.checked)}
/>
}
label="라벨 백그라운드"
/>
</Box>
</Box>
)}
{/* 보임 탭 (비어있음) */}
{activeTab === 2 && (
<Box sx={{ p: 2, textAlign: 'center', color: 'text.secondary' }}>
<Typography> .</Typography>
</Box>
)}
</Box>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2, gap: 1 }}>
{isEditingIndividual && onDelete && (
<Button
onClick={() => {
if (window.confirm('이 기간 측정을 삭제하시겠습니까?')) {
onDelete();
onClose();
}
}}
variant="outlined"
color="error"
>
</Button>
)}
<Button onClick={handleReset} variant="outlined" color="secondary">
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={onClose} variant="outlined">
</Button>
<Button onClick={handleSave} variant="contained" color="primary">
</Button>
</DialogActions>
</Dialog>
);
};
export default MeasureSettingsDialog;
@@ -0,0 +1,441 @@
/**
* 모바일 앱 UI 셸
* - 햄버거 메뉴 + 드로어 네비게이션
* - 하단 탭 바 (주요 화면)
* - 터치 친화적 레이아웃
*/
import React, { useState } from 'react';
import {
Box,
AppBar,
Toolbar,
Typography,
IconButton,
Tooltip,
Drawer,
List,
ListItem,
ListItemIcon,
ListItemText,
useTheme,
BottomNavigation,
BottomNavigationAction,
Divider,
Chip,
Badge,
Button,
Menu,
MenuItem,
ListItemIcon as MenuItemIcon,
ListItemText as MenuItemText,
} from '@mui/material';
import {
Menu as MenuIcon,
Analytics,
Dashboard as DashboardIcon,
GridView as MultiChartIcon,
TrendingUp as TrendingUpIcon,
ShowChart,
Schedule,
Settings as SettingsIcon,
Notifications as NotificationsIcon,
LightMode,
DarkMode,
Login as LoginIcon,
Logout as LogoutIcon,
AccountCircle,
Person,
Rule as RuleIcon,
AccountBalance as SimulationIcon,
CloudDownload,
ListAlt as OrderbookIcon,
AutoAwesome as AutoTradingIcon,
FormatListBulleted as AlertListIcon,
ClearAll as ClearAllIcon,
} from '@mui/icons-material';
import { useAppTheme } from '../contexts/ThemeContext';
import { useAuth } from '../contexts/AuthContext';
import { useAlertNotification } from '../contexts/AlertNotificationContext';
import { useNavigation } from '../contexts/NavigationContext';
import DashboardPage from '../pages/DashboardPage';
import PatternSearchPage from '../pages/PatternSearchPage';
import RisingStocksPage from '../pages/RisingStocksPage';
import StrategyTreeBuilderPage from '../pages/StrategyTreeBuilderPage';
import SimulationPage from '../pages/SimulationPage';
import DataCollectionPage from '../pages/DataCollectionPage';
import SchedulerPage from '../pages/SchedulerPage';
import SettingsPage from '../pages/SettingsPage';
import CandlestickTestPage from '../pages/CandlestickTestPage';
import AlertPage from '../pages/AlertPage';
import NotificationListPage from '../pages/NotificationListPage';
import OrderbookPage from '../pages/OrderbookPage';
import AutoTradingPage from '../pages/AutoTradingPage';
import MultiChartPage from '../pages/MultiChartPage';
import MultiChartPage2 from '../pages/MultiChartPage2';
import AlertListPage from '../pages/AlertListPage';
import MobileChartPage from '../pages/MobileChartPage';
import LoginDialog from './LoginDialog';
import SignUpDialog from './SignUpDialog';
import IndicatorConfigPanel from './IndicatorConfigPanel';
import IndicatorPopupWindow from './IndicatorPopupWindow';
import AlertSnackbar from './alert/AlertSnackbar';
import { useIndicatorPopup } from '../contexts/IndicatorPopupContext';
interface NavItem {
page: string;
label: string;
icon: React.ReactNode;
}
const DRAWER_ITEMS: NavItem[] = [
{ page: 'dashboard', label: '대시보드', icon: <DashboardIcon /> },
{ page: 'multi-chart', label: '멀티차트', icon: <MultiChartIcon /> },
{ page: 'multi-chart-2', label: '멀티차트2', icon: <MultiChartIcon /> },
{ page: 'realtime-chart', label: '실시간 차트', icon: <TrendingUpIcon /> },
{ page: 'rising-stocks', label: '상승종목 추세', icon: <TrendingUpIcon /> },
{ page: 'pattern-search', label: '패턴검색', icon: <ShowChart /> },
{ page: 'simulation', label: '모의투자', icon: <SimulationIcon /> },
{ page: 'strategy-tree', label: '전략설정', icon: <RuleIcon /> },
{ page: 'candlestick-test', label: '투자분석', icon: <Analytics /> },
{ page: 'alerts', label: '알림 설정', icon: <NotificationsIcon /> },
{ page: 'alert-list', label: '알림목록', icon: <AlertListIcon /> },
{ page: 'orderbook', label: '실시간 호가', icon: <OrderbookIcon /> },
{ page: 'auto-trading', label: '자동매매', icon: <AutoTradingIcon /> },
{ page: 'data-collection', label: '데이터 수집', icon: <CloudDownload /> },
{ page: 'scheduler', label: '스케쥴러', icon: <Schedule /> },
{ page: 'settings', label: '설정', icon: <SettingsIcon /> },
];
const BOTTOM_NAV_ITEMS: NavItem[] = [
{ page: 'dashboard', label: '대시', icon: <DashboardIcon /> },
{ page: 'multi-chart', label: '멀티', icon: <MultiChartIcon /> },
{ page: 'candlestick-test', label: '분석', icon: <Analytics /> },
{ page: 'alerts', label: '알림', icon: <NotificationsIcon /> },
{ page: 'settings', label: '설정', icon: <SettingsIcon /> },
];
export const MobileAppShell: React.FC = () => {
const theme = useTheme();
const { currentPage, navigateToPage } = useNavigation();
const { isPopupOpen, closePopup, chartData, chartMargin } = useIndicatorPopup();
const { themeMode, setThemeMode } = useAppTheme();
const { isAuthenticated, user, logout } = useAuth();
const { unreadCount, notifications, dismissAllNotifications } = useAlertNotification();
const [drawerOpen, setDrawerOpen] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signUpDialogOpen, setSignUpDialogOpen] = useState(false);
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const handleThemeToggle = () => setThemeMode(themeMode === 'dark' ? 'light' : 'dark');
const handleDrawerToggle = () => setDrawerOpen(!drawerOpen);
const handleUserMenuOpen = (e: React.MouseEvent<HTMLElement>) => setUserMenuAnchor(e.currentTarget);
const handleUserMenuClose = () => setUserMenuAnchor(null);
const handleLogout = () => {
logout();
handleUserMenuClose();
};
const renderPage = () => {
if (currentPage === 'candlestick-test' || currentPage === 'realtime-chart') {
return <MobileChartPage embedded />;
}
switch (currentPage) {
case 'dashboard':
return <DashboardPage />;
case 'multi-chart':
return <MultiChartPage />;
case 'multi-chart-2':
return <MultiChartPage2 />;
case 'rising-stocks':
return <RisingStocksPage />;
case 'pattern-search':
return <PatternSearchPage />;
case 'simulation':
return <SimulationPage />;
case 'strategy-tree':
return <StrategyTreeBuilderPage />;
case 'data-collection':
return <DataCollectionPage />;
case 'scheduler':
return <SchedulerPage />;
case 'alerts':
return <AlertPage />;
case 'alert-list':
return <AlertListPage />;
case 'notifications':
return <NotificationListPage />;
case 'orderbook':
return <OrderbookPage />;
case 'auto-trading':
return <AutoTradingPage />;
case 'settings':
return <SettingsPage />;
default:
return <DashboardPage />;
}
};
const bottomNavValue = BOTTOM_NAV_ITEMS.findIndex((i) => i.page === currentPage);
const safeBottomNavValue = bottomNavValue >= 0 ? bottomNavValue : 0;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100dvh',
bgcolor: 'background.default',
pb: 'calc(56px + env(safe-area-inset-bottom, 0px))',
}}
>
{/* 상단 앱바 (모바일 컴팩트) */}
<AppBar
position="fixed"
elevation={0}
sx={{
bgcolor: themeMode === 'dark' ? 'background.paper' : 'primary.main',
borderBottom: '1px solid',
borderColor: 'divider',
height: 56,
}}
>
<Toolbar sx={{ minHeight: 56, px: 1, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
edge="start"
color="inherit"
onClick={handleDrawerToggle}
sx={{ mr: 0.5 }}
aria-label="메뉴"
>
<MenuIcon />
</IconButton>
<Analytics sx={{ fontSize: 24, color: themeMode === 'dark' ? 'primary.main' : '#fff' }} />
<Typography
variant="h6"
component="div"
sx={{
fontSize: '1rem',
fontWeight: 700,
color: themeMode === 'dark' ? 'text.primary' : '#fff',
}}
>
Golden
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" onClick={handleThemeToggle} color="inherit">
{themeMode === 'dark' ? <LightMode fontSize="small" /> : <DarkMode fontSize="small" />}
</IconButton>
<Tooltip title="알림팝업 전체 닫기">
<span>
<IconButton
size="small"
onClick={dismissAllNotifications}
disabled={notifications.length === 0}
color="inherit"
aria-label="알림팝업 전체 닫기"
>
<ClearAllIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
{isAuthenticated ? (
<IconButton size="small" onClick={handleUserMenuOpen} color="inherit">
<AccountCircle />
</IconButton>
) : (
<Button
size="small"
variant="outlined"
startIcon={<LoginIcon />}
onClick={() => setLoginDialogOpen(true)}
sx={{
borderColor: themeMode === 'dark' ? 'primary.main' : '#fff',
color: themeMode === 'dark' ? 'primary.main' : '#fff',
fontSize: '0.75rem',
py: 0.25,
}}
>
</Button>
)}
</Box>
</Toolbar>
</AppBar>
{/* 네비게이션 드로어 */}
<Drawer
variant="temporary"
open={drawerOpen}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
'& .MuiDrawer-paper': {
width: 280,
boxSizing: 'border-box',
mt: 0,
pt: 1,
},
}}
>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
Golden Analysis
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
<Divider />
<List sx={{ pt: 0 }}>
{DRAWER_ITEMS.map((item) => (
<ListItem
key={item.page}
button
onClick={() => {
navigateToPage(item.page);
setDrawerOpen(false);
}}
selected={currentPage === item.page}
sx={{
py: 1.25,
'&.Mui-selected': {
bgcolor: 'action.selected',
},
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ fontSize: '0.9rem' }}
/>
{item.page === 'alerts' && unreadCount > 0 && (
<Chip
label={unreadCount}
size="small"
color="error"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</ListItem>
))}
</List>
</Drawer>
{/* 메인 콘텐츠 (모바일 터치 최적화) */}
<Box
component="main"
sx={{
flex: 1,
mt: '56px',
minHeight: 'calc(100dvh - 56px - 56px)',
overflow: (currentPage === 'candlestick-test' || currentPage === 'realtime-chart') ? 'hidden' : 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y',
px: (currentPage === 'candlestick-test' || currentPage === 'realtime-chart') ? 0 : 1.5,
py: (currentPage === 'candlestick-test' || currentPage === 'realtime-chart') ? 0 : 1.5,
maxWidth: '100%',
display: 'flex',
flexDirection: 'column',
height: (currentPage === 'candlestick-test' || currentPage === 'realtime-chart')
? 'calc(100dvh - 56px - 56px)'
: undefined,
}}
>
{renderPage()}
</Box>
{/* 하단 네비게이션 (safe-area 지원) */}
<BottomNavigation
value={safeBottomNavValue}
onChange={(_, newValue) => {
const item = BOTTOM_NAV_ITEMS[newValue];
if (item) navigateToPage(item.page);
}}
showLabels
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'calc(56px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
'& .MuiBottomNavigationAction-label': {
fontSize: '0.7rem',
},
}}
>
{BOTTOM_NAV_ITEMS.map((item, idx) => (
<BottomNavigationAction
key={item.page}
label={item.label}
icon={
item.page === 'alerts' && unreadCount > 0 ? (
<Badge badgeContent={unreadCount} color="error">
{item.icon}
</Badge>
) : (
item.icon
)
}
/>
))}
</BottomNavigation>
{/* 사용자 메뉴 */}
<Menu
anchorEl={userMenuAnchor}
open={Boolean(userMenuAnchor)}
onClose={handleUserMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem disabled>
<MenuItemIcon>
<Person fontSize="small" />
</MenuItemIcon>
<MenuItemText primary={user?.username} secondary={user?.email} />
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<MenuItemIcon>
<LogoutIcon fontSize="small" />
</MenuItemIcon>
<MenuItemText primary="로그아웃" />
</MenuItem>
</Menu>
<LoginDialog
open={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onSwitchToSignUp={() => {
setLoginDialogOpen(false);
setSignUpDialogOpen(true);
}}
/>
<SignUpDialog
open={signUpDialogOpen}
onClose={() => setSignUpDialogOpen(false)}
onSwitchToLogin={() => {
setSignUpDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
<IndicatorConfigPanel />
<IndicatorPopupWindow
open={isPopupOpen}
onClose={closePopup}
chartData={chartData}
chartMargin={chartMargin}
/>
<AlertSnackbar />
</Box>
);
};
@@ -0,0 +1,330 @@
import React, { memo, useState } from 'react';
import { Box, Paper, Typography, Chip, Divider, Tabs, Tab } from '@mui/material';
import {
TrendingUp,
TrendingDown,
Circle as CircleIcon,
} from '@mui/icons-material';
import { OrderbookDisplayUnit, WebSocketStatus } from '../types/orderbook';
interface OrderbookProps {
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WebSocketStatus;
market: string;
bestAsk: number;
bestBid: number;
spread: number;
spreadPercentage: number;
/** 패널 임베드 시 외부 border 제거 */
disableBorder?: boolean;
}
/**
* 가격 포맷 (천 단위 구분)
*/
const formatPrice = (price: number): string => {
return price.toLocaleString('ko-KR', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
};
/**
* 잔량 포맷 (소수점 4자리)
*/
const formatSize = (size: number): string => {
return size.toLocaleString('ko-KR', {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
});
};
/**
* 연결 상태 아이콘 색상
*/
const getStatusColor = (status: WebSocketStatus): string => {
switch (status) {
case 'connected':
return '#22c55e';
case 'connecting':
return '#eab308';
case 'disconnected':
case 'error':
return '#ef4444';
default:
return '#9ca3af';
}
};
/**
* 호가 행 컴포넌트 (업비트 스타일: 잔량 | 가격 | 전일대비)
*/
const OrderbookRow = memo<{
unit: OrderbookDisplayUnit;
type: 'ask' | 'bid';
prevPrice?: number;
}>(({ unit, type, prevPrice = 0 }) => {
const isAsk = type === 'ask';
const bgColor = isAsk ? 'rgba(239, 68, 68, 0.05)' : 'rgba(59, 130, 246, 0.05)';
const barColor = isAsk ? '#ef4444' : '#3b82f6';
const textColor = isAsk ? '#ef4444' : '#3b82f6';
// 전일대비 계산
const changeRate = prevPrice > 0 ? ((unit.price - prevPrice) / prevPrice) * 100 : 0;
const changeSign = changeRate > 0 ? '+' : '';
return (
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '32px',
px: 2,
py: 0.5,
'&:hover': {
backgroundColor: bgColor,
},
transition: 'background-color 0.2s ease',
}}
>
{/* 배경 바 (좌측에서 시작) */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${unit.percentage}%`,
backgroundColor: barColor,
opacity: 0.1,
transition: 'width 0.3s ease',
}}
/>
{/* 잔량 (좌측) */}
<Typography
variant="body2"
sx={{
position: 'relative',
zIndex: 1,
color: 'text.primary',
fontFamily: 'monospace',
fontSize: '13px',
minWidth: '80px',
textAlign: 'left',
}}
>
{formatSize(unit.size)}
</Typography>
{/* 가격 (중앙) */}
<Typography
variant="body2"
sx={{
position: 'relative',
zIndex: 1,
fontWeight: 700,
color: textColor,
fontFamily: 'monospace',
fontSize: '14px',
flex: 1,
textAlign: 'center',
}}
>
{formatPrice(unit.price)}
</Typography>
{/* 전일대비 (우측) */}
<Typography
variant="caption"
sx={{
position: 'relative',
zIndex: 1,
color: textColor,
fontFamily: 'monospace',
fontSize: '12px',
minWidth: '70px',
textAlign: 'right',
fontWeight: 600,
}}
>
{changeSign}{Math.abs(changeRate).toFixed(2)}%
</Typography>
</Box>
);
});
OrderbookRow.displayName = 'OrderbookRow';
/**
* 호가창 메인 컴포넌트
*/
export const Orderbook = memo<OrderbookProps>(({
asks,
bids,
totalAskSize,
totalBidSize,
wsStatus,
market,
bestAsk,
bestBid,
spread,
spreadPercentage,
disableBorder = false,
}) => {
const [tabValue, setTabValue] = useState(0);
return (
<Paper
elevation={0}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: disableBorder ? 'none' : '1px solid',
borderColor: 'divider',
overflow: 'hidden',
borderRadius: disableBorder ? 0 : undefined,
}}
>
{/* 헤더 */}
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
}}
>
<Box
sx={{
px: 2,
pt: 2,
pb: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h6" fontWeight={700}>
</Typography>
<Chip
label={market}
size="small"
sx={{
fontWeight: 600,
backgroundColor: 'primary.main',
color: 'white',
}}
/>
</Box>
{/* 연결 상태 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircleIcon
sx={{
fontSize: 10,
color: getStatusColor(wsStatus),
}}
/>
<Typography variant="caption" color="text.secondary">
{wsStatus === 'connected' && '연결됨'}
{wsStatus === 'connecting' && '연결 중...'}
{wsStatus === 'disconnected' && '연결 끊김'}
{wsStatus === 'error' && '오류'}
</Typography>
</Box>
</Box>
{/* 탭 */}
<Tabs
value={tabValue}
onChange={(_, newValue) => setTabValue(newValue)}
variant="fullWidth"
sx={{
minHeight: 40,
'& .MuiTab-root': {
minHeight: 40,
py: 1,
fontSize: '0.875rem',
},
}}
>
<Tab label="일반호가" />
<Tab label="누적호가" />
<Tab label="호가주문" />
<Tab label="모아보기" />
</Tabs>
</Box>
{/* 호가 컨텐츠 */}
<Box sx={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* 컬럼 헤더 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1,
backgroundColor: 'background.default',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: '80px', textAlign: 'left' }}>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, textAlign: 'center' }}>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: '70px', textAlign: 'right' }}>
</Typography>
</Box>
{/* 매도 호가 (15호가 → 1호가) */}
<Box sx={{ flex: 1 }}>
{asks.length > 0 ? (
asks.map((ask, index) => (
<OrderbookRow key={`ask-${ask.price}-${index}`} unit={ask} type="ask" />
))
) : (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
...
</Typography>
</Box>
)}
</Box>
{/* 스프레드 구분선 */}
<Divider sx={{ borderColor: 'divider', borderWidth: 1 }} />
{/* 매수 호가 (1호가 → 15호가) */}
<Box sx={{ flex: 1 }}>
{bids.length > 0 ? (
bids.map((bid, index) => (
<OrderbookRow key={`bid-${bid.price}-${index}`} unit={bid} type="bid" />
))
) : (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
...
</Typography>
</Box>
)}
</Box>
</Box>
</Paper>
);
});
Orderbook.displayName = 'Orderbook';
export default Orderbook;
@@ -0,0 +1,623 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { Box, IconButton, Tooltip, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
import {
Create as DrawIcon,
Timeline as LineIcon,
Gesture as FreeDrawIcon,
ShowChart as TrendIcon,
Delete as ClearIcon,
Undo as UndoIcon,
Redo as RedoIcon,
GridOn as GridIcon,
} from '@mui/icons-material';
interface Point {
x: number;
y: number;
}
interface PatternCanvasProps {
width?: number;
height?: number;
isDark?: boolean;
onPatternChange?: (patternData: number[]) => void;
templatePattern?: number[];
showGrid?: boolean;
}
type DrawingTool = 'select' | 'freedraw' | 'line' | 'trend';
const PatternCanvas: React.FC<PatternCanvasProps> = ({
width = 800,
height = 400,
isDark = true,
onPatternChange,
templatePattern,
showGrid: initialShowGrid = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [currentTool, setCurrentTool] = useState<DrawingTool>('freedraw');
const [paths, setPaths] = useState<Point[][]>([]);
const [currentPath, setCurrentPath] = useState<Point[]>([]);
const [undoStack, setUndoStack] = useState<Point[][][]>([]);
const [redoStack, setRedoStack] = useState<Point[][][]>([]);
const [showGrid, setShowGrid] = useState(initialShowGrid);
// 직선/추세선 클릭 기반 그리기 상태
const [linePoints, setLinePoints] = useState<Point[]>([]); // 확정된 점들
const [previewPoint, setPreviewPoint] = useState<Point | null>(null); // 마우스 위치 (미리보기용)
const [isLineDrawing, setIsLineDrawing] = useState(false); // 직선 그리기 모드 활성화 여부
// 색상 설정
const colors = {
background: isDark ? '#18181b' : '#f5f5f5',
grid: isDark ? '#27272a' : '#e0e0e0',
line: isDark ? '#22c55e' : '#16a34a',
templateLine: isDark ? '#3b82f6' : '#2563eb',
previewLine: isDark ? '#f59e0b' : '#d97706',
axis: isDark ? '#52525b' : '#9ca3af',
};
// 캔버스 크기 조정
const [canvasSize, setCanvasSize] = useState({ width, height });
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({
width: rect.width || width,
height: rect.height || height,
});
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, [width, height]);
// 캔버스 그리기
const drawCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 캔버스 클리어
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 그리드 그리기
if (showGrid) {
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
const gridSize = 40;
for (let x = 0; x <= canvas.width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = 0; y <= canvas.height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
}
// 템플릿 패턴 그리기
if (templatePattern && templatePattern.length > 1) {
ctx.strokeStyle = colors.templateLine;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([8, 4]);
ctx.beginPath();
const padding = 40;
const drawWidth = canvas.width - padding * 2;
const drawHeight = canvas.height - padding * 2;
templatePattern.forEach((value, index) => {
const x = padding + (index / (templatePattern.length - 1)) * drawWidth;
const y = padding + (1 - value) * drawHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
ctx.setLineDash([]);
}
// 저장된 경로 그리기
ctx.strokeStyle = colors.line;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
paths.forEach(path => {
if (path.length < 2) return;
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x, path[i].y);
}
ctx.stroke();
});
// 현재 그리고 있는 자유 곡선
if (currentPath.length > 1 && currentTool === 'freedraw') {
ctx.strokeStyle = colors.line;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(currentPath[0].x, currentPath[0].y);
for (let i = 1; i < currentPath.length; i++) {
ctx.lineTo(currentPath[i].x, currentPath[i].y);
}
ctx.stroke();
}
// 직선/추세선 모드: 확정된 점들 연결
if (linePoints.length > 1) {
ctx.strokeStyle = colors.line;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(linePoints[0].x, linePoints[0].y);
for (let i = 1; i < linePoints.length; i++) {
ctx.lineTo(linePoints[i].x, linePoints[i].y);
}
ctx.stroke();
}
// 직선/추세선 모드: 미리보기 선 (마지막 확정 점 → 마우스 위치)
if (isLineDrawing && linePoints.length > 0 && previewPoint) {
const lastPoint = linePoints[linePoints.length - 1];
ctx.strokeStyle = colors.previewLine;
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(lastPoint.x, lastPoint.y);
ctx.lineTo(previewPoint.x, previewPoint.y);
ctx.stroke();
ctx.setLineDash([]);
// 마우스 위치에 점 표시
ctx.fillStyle = colors.previewLine;
ctx.beginPath();
ctx.arc(previewPoint.x, previewPoint.y, 4, 0, Math.PI * 2);
ctx.fill();
}
// 확정된 점들에 마커 표시
if (linePoints.length > 0) {
ctx.fillStyle = colors.line;
linePoints.forEach((point, index) => {
ctx.beginPath();
ctx.arc(point.x, point.y, index === 0 ? 6 : 4, 0, Math.PI * 2);
ctx.fill();
});
}
}, [paths, currentPath, templatePattern, showGrid, colors, linePoints, previewPoint, isLineDrawing, currentTool]);
useEffect(() => {
drawCanvas();
}, [drawCanvas, canvasSize]);
// 마우스 이벤트 핸들러
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
// 직선/추세선 그리기 완료 (경로 저장)
const finishLineDrawing = useCallback(() => {
if (linePoints.length > 1) {
// 현재 상태를 undo 스택에 저장
setUndoStack(prev => [...prev, paths]);
setRedoStack([]);
// 새 경로 추가
const newPaths = [...paths, linePoints];
setPaths(newPaths);
// 패턴 데이터 추출 및 콜백
extractPatternData(newPaths);
}
setLinePoints([]);
setPreviewPoint(null);
setIsLineDrawing(false);
}, [linePoints, paths]);
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (currentTool === 'select') return;
// 우클릭: 직선/추세선 그리기 종료
if (e.button === 2) {
e.preventDefault();
if ((currentTool === 'line' || currentTool === 'trend') && isLineDrawing) {
finishLineDrawing();
}
return;
}
const pos = getMousePos(e);
if (currentTool === 'line' || currentTool === 'trend') {
// 직선/추세선: 클릭으로 점 추가
if (!isLineDrawing) {
// 첫 번째 클릭: 그리기 시작
setIsLineDrawing(true);
setLinePoints([pos]);
} else {
// 이후 클릭: 점 추가 (이전 점과 연결)
setLinePoints(prev => [...prev, pos]);
}
} else {
// 자유 곡선: 드래그 방식
setIsDrawing(true);
setCurrentPath([pos]);
}
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getMousePos(e);
if (currentTool === 'line' || currentTool === 'trend') {
// 직선/추세선: 미리보기 업데이트
if (isLineDrawing) {
setPreviewPoint(pos);
}
} else {
// 자유 곡선: 드래그 중 점 추가
if (isDrawing) {
setCurrentPath(prev => [...prev, pos]);
}
}
};
const handleMouseUp = () => {
// 자유 곡선만 마우스업에서 처리
if (currentTool === 'freedraw' && isDrawing) {
setIsDrawing(false);
if (currentPath.length > 1) {
// 현재 상태를 undo 스택에 저장
setUndoStack(prev => [...prev, paths]);
setRedoStack([]);
// 새 경로 추가
const newPaths = [...paths, currentPath];
setPaths(newPaths);
// 패턴 데이터 추출 및 콜백
extractPatternData(newPaths);
}
setCurrentPath([]);
}
};
const handleMouseLeave = () => {
if (currentTool === 'freedraw' && isDrawing) {
handleMouseUp();
}
// 직선/추세선 모드에서는 마우스가 나가도 계속 유지
};
// 우클릭 메뉴 방지
const handleContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
// 직선/추세선 모드에서 우클릭으로 그리기 종료
if ((currentTool === 'line' || currentTool === 'trend') && isLineDrawing) {
finishLineDrawing();
}
};
// 패턴 데이터 추출 (Y 좌표를 정규화된 값으로 변환)
const extractPatternData = (allPaths: Point[][]) => {
if (!onPatternChange) return;
const canvas = canvasRef.current;
if (!canvas) return;
// 모든 경로의 점들을 하나로 합치고 X 좌표로 정렬
const allPoints: Point[] = allPaths.flat().sort((a, b) => a.x - b.x);
if (allPoints.length < 2) {
onPatternChange([]);
return;
}
// 일정 간격으로 샘플링
const sampleCount = 20;
const minX = allPoints[0].x;
const maxX = allPoints[allPoints.length - 1].x;
const step = (maxX - minX) / (sampleCount - 1);
const sampledData: number[] = [];
for (let i = 0; i < sampleCount; i++) {
const targetX = minX + step * i;
// 가장 가까운 점 찾기
let closestPoint = allPoints[0];
let minDist = Math.abs(allPoints[0].x - targetX);
for (const point of allPoints) {
const dist = Math.abs(point.x - targetX);
if (dist < minDist) {
minDist = dist;
closestPoint = point;
}
}
// Y 좌표를 0-1로 정규화 (위가 1, 아래가 0)
const normalizedY = 1 - (closestPoint.y / canvas.height);
sampledData.push(normalizedY);
}
onPatternChange(sampledData);
};
// 실행 취소
const handleUndo = () => {
if (undoStack.length === 0) return;
// 직선 그리기 중이면 먼저 취소
if (isLineDrawing) {
setLinePoints([]);
setPreviewPoint(null);
setIsLineDrawing(false);
return;
}
const previousState = undoStack[undoStack.length - 1];
setRedoStack(prev => [...prev, paths]);
setPaths(previousState);
setUndoStack(prev => prev.slice(0, -1));
extractPatternData(previousState);
};
// 다시 실행
const handleRedo = () => {
if (redoStack.length === 0) return;
const nextState = redoStack[redoStack.length - 1];
setUndoStack(prev => [...prev, paths]);
setPaths(nextState);
setRedoStack(prev => prev.slice(0, -1));
extractPatternData(nextState);
};
// 전체 지우기
const handleClear = () => {
// 진행 중인 직선 그리기 취소
if (isLineDrawing) {
setLinePoints([]);
setPreviewPoint(null);
setIsLineDrawing(false);
}
if (paths.length === 0) return;
setUndoStack(prev => [...prev, paths]);
setRedoStack([]);
setPaths([]);
setCurrentPath([]);
onPatternChange?.([]);
};
// 도구 변경
const handleToolChange = (_: React.MouseEvent<HTMLElement>, newTool: DrawingTool | null) => {
if (newTool) {
// 도구 변경 시 진행 중인 그리기 완료
if (isLineDrawing) {
finishLineDrawing();
}
setCurrentTool(newTool);
}
};
// 키보드 이벤트 (ESC로 취소)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isLineDrawing) {
setLinePoints([]);
setPreviewPoint(null);
setIsLineDrawing(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isLineDrawing]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 도구 모음 */}
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1,
borderBottom: `1px solid ${isDark ? '#27272a' : '#e0e0e0'}`,
bgcolor: isDark ? '#1f1f26' : '#fafafa',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ToggleButtonGroup
value={currentTool}
exclusive
onChange={handleToolChange}
size="small"
sx={{
'& .MuiToggleButton-root': {
color: isDark ? '#a1a1aa' : '#666',
borderColor: isDark ? '#3f3f46' : '#d0d0d0',
'&.Mui-selected': {
color: '#fff',
bgcolor: '#3b82f6',
'&:hover': { bgcolor: '#2563eb' },
},
},
}}
>
<ToggleButton value="freedraw">
<Tooltip title="자유 곡선 (드래그)">
<FreeDrawIcon fontSize="small" />
</Tooltip>
</ToggleButton>
<ToggleButton value="line">
<Tooltip title="직선 (클릭으로 점 연결, 우클릭으로 완료)">
<LineIcon fontSize="small" />
</Tooltip>
</ToggleButton>
<ToggleButton value="trend">
<Tooltip title="추세선 (클릭으로 점 연결, 우클릭으로 완료)">
<TrendIcon fontSize="small" />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ width: 1, height: 24, bgcolor: isDark ? '#3f3f46' : '#d0d0d0', mx: 1 }} />
<Tooltip title="실행 취소 (ESC: 취소)">
<IconButton
size="small"
onClick={handleUndo}
disabled={undoStack.length === 0 && !isLineDrawing}
sx={{ color: isDark ? '#a1a1aa' : '#666' }}
>
<UndoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="다시 실행">
<IconButton
size="small"
onClick={handleRedo}
disabled={redoStack.length === 0}
sx={{ color: isDark ? '#a1a1aa' : '#666' }}
>
<RedoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="전체 지우기">
<IconButton
size="small"
onClick={handleClear}
disabled={paths.length === 0 && !isLineDrawing}
sx={{ color: isDark ? '#a1a1aa' : '#666' }}
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isLineDrawing && (
<Typography sx={{
fontSize: '11px',
color: colors.previewLine,
fontWeight: 600,
}}>
... ( )
</Typography>
)}
<Tooltip title={showGrid ? '그리드 숨기기' : '그리드 표시'}>
<IconButton
size="small"
onClick={() => setShowGrid(!showGrid)}
sx={{
color: showGrid ? '#3b82f6' : (isDark ? '#a1a1aa' : '#666'),
}}
>
<GridIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#888' }}>
{paths.length > 0 || linePoints.length > 0
? `${paths.reduce((sum, p) => sum + p.length, 0) + linePoints.length}개 포인트`
: '그리기 시작'}
</Typography>
</Box>
</Box>
{/* 캔버스 영역 */}
<Box
ref={containerRef}
sx={{
flex: 1,
position: 'relative',
cursor: currentTool === 'select' ? 'default' : 'crosshair',
}}
>
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onContextMenu={handleContextMenu}
style={{
display: 'block',
width: '100%',
height: '100%',
}}
/>
{/* 빈 캔버스 힌트 */}
{paths.length === 0 && !isDrawing && !isLineDrawing && (
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
pointerEvents: 'none',
}}>
<DrawIcon sx={{ fontSize: 48, color: isDark ? '#3f3f46' : '#d0d0d0', mb: 1 }} />
<Typography sx={{ fontSize: '14px', color: isDark ? '#52525b' : '#9ca3af' }}>
</Typography>
<Typography sx={{ fontSize: '12px', color: isDark ? '#3f3f46' : '#d0d0d0', mt: 0.5 }}>
자유곡선: 드래그 | /추세선: 클릭
</Typography>
</Box>
)}
</Box>
</Box>
);
};
export default PatternCanvas;
@@ -0,0 +1,526 @@
import React, { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import {
Box,
Paper,
Typography,
IconButton,
Divider,
Chip,
CircularProgress,
Alert,
Slider,
useTheme,
} from '@mui/material';
import {
Close as CloseIcon,
TrendingUp,
TrendingDown,
RemoveCircleOutline,
Refresh,
AutoAwesome,
Opacity as OpacityIcon,
} from '@mui/icons-material';
export interface RealTimeAnalysisPanelRef {
updateLastAnalysis: (content: string, recommendation: 'strong_buy' | 'buy' | 'hold' | 'sell' | 'strong_sell' | null) => void;
appendToLastAnalysis: (chunk: string) => void;
startNewAnalysis: () => void;
}
interface RealTimeAnalysisPanelProps {
open: boolean;
onClose: () => void;
symbol: string;
currentPrice: number | null;
onAnalyze: () => Promise<void>;
autoAnalyze: boolean;
nextUpdateTime?: Date | null;
timeInterval?: string;
}
interface AnalysisResult {
timestamp: string;
symbol: string;
price: number;
recommendation: 'strong_buy' | 'buy' | 'hold' | 'sell' | 'strong_sell' | null;
content: string;
loading: boolean;
}
const RealTimeAnalysisPanel = forwardRef<RealTimeAnalysisPanelRef, RealTimeAnalysisPanelProps>(({
open,
onClose,
symbol,
currentPrice,
onAnalyze,
autoAnalyze,
nextUpdateTime,
timeInterval,
}, ref) => {
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
const [analyses, setAnalyses] = useState<AnalysisResult[]>([]);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [opacity, setOpacity] = useState(5); // 투명도 (0=불투명, 100=완전투명)
const [panelWidth, setPanelWidth] = useState(500); // 패널 너비
const [isResizing, setIsResizing] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const panelRef = useRef<HTMLDivElement>(null);
const scrollBottomRef = useRef<HTMLDivElement>(null);
const resizeStartXRef = useRef<number>(0);
const resizeStartWidthRef = useRef<number>(0);
// 현재 시간 1초마다 업데이트 (다음 갱신 시간 카운트다운용)
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
// 다음 갱신까지 남은 시간 계산 (useMemo로 최적화)
const timeRemaining = React.useMemo(() => {
if (!nextUpdateTime) return null;
const diff = Math.max(0, Math.floor((nextUpdateTime.getTime() - currentTime.getTime()) / 1000));
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
return {
label: minutes > 0 ? `${minutes}${seconds}` : `${seconds}`,
color: (diff < 30 ? 'warning' : 'info') as 'warning' | 'info',
seconds: diff,
};
}, [nextUpdateTime, currentTime]);
// 부모 컴포넌트에서 호출할 수 있는 메서드 노출
useImperativeHandle(ref, () => ({
startNewAnalysis: () => {
setIsAnalyzing(true);
const newAnalysis: AnalysisResult = {
timestamp: new Date().toLocaleTimeString('ko-KR'),
symbol,
price: currentPrice || 0,
recommendation: null,
content: '',
loading: true,
};
// 누적 목록 형태로 추가하되, 10개 초과시 오래된 항목 제거
setAnalyses(prev => {
const updated = [...prev, newAnalysis];
// 10개 초과하면 오래된 것부터 제거
if (updated.length > 10) {
return updated.slice(updated.length - 10);
}
return updated;
});
},
updateLastAnalysis: (content: string, recommendation: 'strong_buy' | 'buy' | 'hold' | 'sell' | 'strong_sell' | null) => {
setIsAnalyzing(false);
// 면책 문구 자동 추가
const disclaimerText = '\n\n⚠️ AI 분석은 투자 참고용이며, 최종 결정은 본인 책임입니다.';
const finalContent = content + disclaimerText;
setAnalyses(prev => {
if (prev.length === 0) return prev;
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
loading: false,
content: finalContent,
recommendation,
};
return updated;
});
},
appendToLastAnalysis: (chunk: string) => {
setAnalyses(prev => {
if (prev.length === 0) return prev;
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: updated[updated.length - 1].content + chunk,
};
return updated;
});
},
}));
// 자동 스크롤
useEffect(() => {
if (scrollBottomRef.current) {
scrollBottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [analyses]);
// 분석 실행
const executeAnalysis = async () => {
if (isAnalyzing) return;
await onAnalyze();
};
// 리사이징 핸들러
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
resizeStartXRef.current = e.clientX;
resizeStartWidthRef.current = panelWidth;
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaX = resizeStartXRef.current - e.clientX;
const newWidth = Math.max(350, Math.min(800, resizeStartWidthRef.current + deltaX));
setPanelWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, panelWidth]);
// 패널 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
// 패널 외부 클릭 시 닫지 않음 (사용자가 명시적으로 닫기 버튼을 눌러야 함)
}
};
if (open) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open]);
// 추천 칩 렌더링
const renderRecommendationChip = (recommendation: string | null) => {
if (!recommendation) return null;
const config = {
strong_buy: { label: '🚀 강력 매수', color: '#10b981' as const, bg: 'rgba(16, 185, 129, 0.1)' },
buy: { label: '📈 매수', color: '#22c55e' as const, bg: 'rgba(34, 197, 94, 0.1)' },
hold: { label: '⏸️ 관망', color: '#6366f1' as const, bg: 'rgba(99, 102, 241, 0.1)' },
sell: { label: '📉 매도', color: '#ef4444' as const, bg: 'rgba(239, 68, 68, 0.1)' },
strong_sell: { label: '🔻 강력 매도', color: '#dc2626' as const, bg: 'rgba(220, 38, 38, 0.1)' },
};
const cfg = config[recommendation as keyof typeof config];
if (!cfg) return null;
return (
<Chip
label={cfg.label}
size="small"
sx={{
fontWeight: 600,
color: cfg.color,
backgroundColor: cfg.bg,
border: `1px solid ${cfg.color}`,
}}
/>
);
};
if (!open) return null;
return (
<>
{/* 리사이즈 핸들 */}
<Box
onMouseDown={handleResizeStart}
sx={{
position: 'fixed',
right: panelWidth,
top: 0,
bottom: 0,
width: 4,
zIndex: 1301,
cursor: 'ew-resize',
bgcolor: isResizing ? 'primary.main' : 'transparent',
'&:hover': {
bgcolor: 'primary.light',
},
transition: 'background-color 0.2s',
}}
/>
<Paper
ref={panelRef}
elevation={8}
sx={{
position: 'fixed',
right: 0,
top: 0,
bottom: 0,
width: { xs: '100%', sm: panelWidth },
maxWidth: panelWidth,
zIndex: 1300,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: 0,
borderLeft: '1px solid',
borderColor: 'divider',
bgcolor: isDark
? `rgba(18, 18, 18, ${(100 - opacity) / 100})`
: `rgba(255, 255, 255, ${(100 - opacity) / 100})`,
backdropFilter: opacity > 5 ? 'blur(10px)' : 'none',
}}
>
{/* 헤더 */}
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: isDark
? `rgba(25, 118, 210, ${(100 - opacity) / 100})`
: `rgba(25, 118, 210, ${(100 - opacity) / 100})`,
color: 'primary.contrastText',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesome />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
AI
</Typography>
{autoAnalyze && (
<Chip
label="자동"
size="small"
sx={{
bgcolor: 'rgba(255,255,255,0.2)',
color: 'white',
fontSize: '0.7rem',
height: 20,
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton
size="small"
onClick={executeAnalysis}
disabled={isAnalyzing}
sx={{ color: 'white' }}
>
{isAnalyzing ? <CircularProgress size={20} color="inherit" /> : <Refresh />}
</IconButton>
<IconButton size="small" onClick={onClose} sx={{ color: 'white' }}>
<CloseIcon />
</IconButton>
</Box>
</Box>
{/* 투명도 조절 */}
<Box sx={{
p: 2,
bgcolor: isDark
? `rgba(30, 30, 30, ${(100 - opacity) / 100})`
: `rgba(245, 245, 245, ${(100 - opacity) / 100})`,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<OpacityIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 60 }}>
{opacity}%
</Typography>
<Slider
value={opacity}
onChange={(_, value) => setOpacity(value as number)}
min={0}
max={100}
step={5}
size="small"
sx={{
flex: 1,
'& .MuiSlider-thumb': {
width: 16,
height: 16,
},
}}
/>
</Box>
</Box>
<Divider />
{/* 현재 종목 정보 */}
<Box sx={{
p: 1.5,
bgcolor: isDark
? `rgba(30, 30, 30, ${Math.min(1, (100 - opacity) / 100 + 0.3)})`
: `rgba(240, 240, 240, ${Math.min(1, (100 - opacity) / 100 + 0.3)})`,
}}>
{/* 1줄: 분석 종목 | 현재가 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{symbol}
</Typography>
</Box>
{currentPrice && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'success.main' }}>
{currentPrice.toLocaleString()}
</Typography>
</Box>
)}
</Box>
{/* 2줄: 다음 갱신 | 갱신 간격 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{autoAnalyze && nextUpdateTime && timeRemaining ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Chip
label={timeRemaining.label}
size="small"
color={timeRemaining.color}
sx={{
fontWeight: 600,
fontSize: '0.65rem',
height: 20,
}}
/>
</Box>
) : (
<Box />
)}
{timeInterval && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{timeInterval}
</Typography>
</Box>
)}
</Box>
</Box>
<Divider />
{/* 분석 결과 목록 */}
<Box
sx={{
flex: 1,
overflowY: 'auto',
p: 2,
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
},
}}
>
{analyses.length === 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: 2,
}}
>
<AutoAwesome sx={{ fontSize: 60, color: 'text.disabled' }} />
<Typography variant="body2" color="text.secondary" align="center">
{autoAnalyze
? '1분마다 자동으로 AI 분석이 실행됩니다.'
: '새로고침 버튼을 눌러 AI 분석을 시작하세요.'}
</Typography>
</Box>
) : (
analyses.map((analysis, index) => (
<Box key={index} sx={{ mb: index < analyses.length - 1 ? 3 : 0 }}>
{/* 분석 헤더 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="caption" color="text.secondary">
{analysis.timestamp} | {analysis.symbol}
</Typography>
{renderRecommendationChip(analysis.recommendation)}
</Box>
{/* 분석 내용 */}
<Paper
elevation={1}
sx={{
p: 2,
bgcolor: isDark
? `rgba(40, 40, 40, ${Math.min(1, (100 - opacity) / 100 + 0.2)})`
: `rgba(250, 250, 250, ${Math.min(1, (100 - opacity) / 100 + 0.2)})`,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
}}
>
{analysis.loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
AI ...
</Typography>
</Box>
) : (
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
lineHeight: 1.6,
}}
>
{analysis.content || '분석 대기 중...'}
</Typography>
)}
</Paper>
{index < analyses.length - 1 && <Divider sx={{ my: 2 }} />}
</Box>
))
)}
<div ref={scrollBottomRef} />
</Box>
</Paper>
</>
);
});
RealTimeAnalysisPanel.displayName = 'RealTimeAnalysisPanel';
export default RealTimeAnalysisPanel;
@@ -0,0 +1,804 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Draggable from 'react-draggable';
import { Box, IconButton, Paper, Typography } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import type { CandleData, IndicatorData } from '../services/backtestApi';
import type { MALineConfig, IndicatorVisibilitySettings } from '../contexts/IndicatorVisibilityContext';
/** CandlestickChart와 동일한 SMA 계산 */
function calculateSMA(
data: { time: number; close: number }[],
period: number
): { time: number; value: number | null }[] {
const result: { time: number; value: number | null }[] = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
result.push({ time: data[i].time, value: null });
} else {
let sum = 0;
for (let j = 0; j < period; j++) sum += data[i - j].close;
result.push({ time: data[i].time, value: sum / period });
}
}
return result;
}
function candleTimeToSec(t: string | number | undefined): number {
if (t == null) return NaN;
if (typeof t === 'number') return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t);
return Math.floor(new Date(t).getTime() / 1000);
}
/**
* 형광 플래시 비교 전용 — 화면 문자열과 분리
* - time: ISO 문자열 ↔ epoch(ms/초) 혼용 시에도 동일 봉이면 같은 키
* - 숫자: `formatNum`의 `toLocaleString(undefined)`는 렌더·환경에 따라 미세히 달라질 수 있어 오탐 방지
*/
function flashCompareTimeKey(t: string | number | undefined): string {
const s = candleTimeToSec(t);
return Number.isFinite(s) ? `u:${s}` : `raw:${String(t ?? '')}`;
}
function flashCompareNumKey(v: unknown): string {
if (v === null || v === undefined || v === '') return '—';
const n = Number(v);
if (!Number.isFinite(n)) return '—';
return n.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 12,
minimumFractionDigits: 0,
});
}
function formatNum(v: unknown): string {
if (v === null || v === undefined || v === '') return '—';
const n = Number(v);
if (!Number.isFinite(n)) return '—';
const abs = Math.abs(n);
if (abs >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
if (abs >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: 4 });
if (abs >= 0.0001) return n.toLocaleString(undefined, { maximumFractionDigits: 6 });
return n.toExponential(3);
}
function buildSortedDataForMA(
allIndicatorData: IndicatorData[],
maCalculationData: CandleData[],
candleData: CandleData[]
): { time: number; close: number }[] {
const indicatorForCalculation =
allIndicatorData?.length > 0 ? allIndicatorData : maCalculationData?.length > 0 ? maCalculationData : candleData;
const dataForCalculation = maCalculationData?.length > 0 ? maCalculationData : candleData;
if (!indicatorForCalculation?.length || !dataForCalculation?.length) return [];
return (indicatorForCalculation as any[])
.map((d: any, i: number) => {
if (d == null || typeof d !== 'object') return null;
const fallbackCandle = dataForCalculation[i];
const close =
d.close !== undefined && d.close !== null && !Number.isNaN(Number(d.close))
? Number(d.close)
: fallbackCandle != null &&
fallbackCandle.close != null &&
!Number.isNaN(Number(fallbackCandle.close))
? Number(fallbackCandle.close)
: null;
if (close === null || !Number.isFinite(close)) return null;
const timeValue = d.timestamp || d.time || d.date || fallbackCandle?.time;
if (timeValue == null && fallbackCandle?.time == null) return null;
const tv = timeValue ?? fallbackCandle?.time;
const tSec = Math.floor(typeof tv === 'string' ? new Date(tv).getTime() / 1000 : Number(tv));
if (!Number.isFinite(tSec)) return null;
return { time: tSec, close };
})
.filter((x): x is { time: number; close: number } => x != null);
}
function pickDisplayIndicatorRow(
candleData: CandleData[],
indicatorData: IndicatorData[]
): IndicatorData | null {
if (!candleData.length) return null;
const last = candleData[candleData.length - 1];
const t = last.time;
const byTime = indicatorData.find((r) => r.time === t || r.timestamp === t);
if (byTime) return byTime;
if (indicatorData.length >= candleData.length) return indicatorData[candleData.length - 1];
return indicatorData.length ? indicatorData[indicatorData.length - 1] : null;
}
export interface IchimokuFlags {
tenkan: boolean;
kijun: boolean;
chikou: boolean;
senkouA: boolean;
senkouB: boolean;
}
export interface RealtimeChartValuesPopupProps {
/** null이면 닫힘. 열릴 때 버튼 위치 기준으로 패널 초기 좌표를 잡음 */
anchorEl: HTMLElement | null;
onClose: () => void;
isDark: boolean;
symbol: string;
timeInterval: string;
candleData: CandleData[];
maCalculationData: CandleData[];
allIndicatorData: IndicatorData[];
indicatorData: IndicatorData[];
maLines: MALineConfig[];
indicatorVisibility: IndicatorVisibilitySettings;
ichimokuSettings: IchimokuFlags;
subPanelsOrder: string[];
realtimePrice: number | null;
/** false면 해당 종목 실시간 스트림이 아님 — 형광 연두 플래시를 쓰지 않고 즉시 원래 색으로 표시 */
realtimeReceiving?: boolean;
}
/** 형광 연두 (실시간 갱신 플래시) */
const FLASH_LIME = '#CCFF00';
function Row({
label,
value,
flash,
}: {
label: string;
value: string;
flash?: boolean;
}) {
const labelSx = flash
? {
color: FLASH_LIME,
fontWeight: 700,
textShadow: '0 0 8px rgba(204,255,0,0.55)',
flexShrink: 0,
}
: { flexShrink: 0 };
const valueSx = flash
? {
color: FLASH_LIME,
fontWeight: 700,
fontFamily: 'monospace',
textAlign: 'right' as const,
wordBreak: 'break-all' as const,
textShadow: '0 0 8px rgba(204,255,0,0.45)',
}
: {
fontFamily: 'monospace',
textAlign: 'right' as const,
wordBreak: 'break-all' as const,
};
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, py: 0.25, fontSize: '0.75rem' }}>
<Typography component="span" color={flash ? undefined : 'text.secondary'} sx={labelSx}>
{label}
</Typography>
<Typography component="span" sx={valueSx}>
{value}
</Typography>
</Box>
);
}
function sectionTitle(text: string) {
return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700, display: 'block', mt: 1, mb: 0.5 }}>
{text}
</Typography>
);
}
function subPanelRows(type: string, r: IndicatorData): { label: string; value: string }[] {
switch (type) {
case 'volume':
return [{ label: '거래량', value: formatNum(r.volume) }];
case 'macd':
return [
{ label: 'MACD', value: formatNum(r.macdLine ?? r.macd) },
{ label: 'Signal', value: formatNum(r.signalLine ?? r.signal ?? r.macdSignal) },
{ label: 'Histogram', value: formatNum(r.macdHistogram ?? r.histogram) },
];
case 'rsi':
return [{ label: 'RSI', value: formatNum(r.rsi) }];
case 'stochastic':
return [
{ label: '%K', value: formatNum(r.stochK) },
{ label: '%D', value: formatNum(r.stochD) },
];
case 'cci':
return [{ label: 'CCI', value: formatNum(r.cci) }];
case 'adx':
return [
{ label: 'ADX', value: formatNum(r.adx) },
{ label: '+DI', value: formatNum(r.plusDI) },
{ label: '-DI', value: formatNum(r.minusDI) },
];
case 'obv':
return [
{ label: 'OBV', value: formatNum(r.obv) },
{ label: 'OBV Signal', value: formatNum(r.obvSignal) },
];
case 'newPsychological':
return [{ label: '신심리도', value: formatNum(r.newPsychological ?? r.newPsy) }];
case 'investPsychological':
return [{ label: '투자심리도', value: formatNum(r.investPsychological ?? r.investPsy) }];
case 'bwi':
return [{ label: 'BWI', value: formatNum(r.bwi) }];
case 'dmi':
return [
{ label: '+DI', value: formatNum(r.plusDI) },
{ label: '-DI', value: formatNum(r.minusDI) },
];
case 'williamsR':
return [{ label: 'Williams %R', value: formatNum(r.williamsR) }];
case 'trix':
return [
{ label: 'TRIX', value: formatNum(r.trix) },
{ label: 'TRIX Signal', value: formatNum(r.trixSignal) },
];
case 'volumeOsc':
return [{ label: 'Volume OSC', value: formatNum(r.volumeOsc) }];
case 'vr':
return [
{ label: 'VR', value: formatNum(r.vr) },
{ label: 'VR2', value: formatNum(r.vr2) },
];
case 'disparity':
return [{ label: '이격도', value: formatNum(r.disparity) }];
default:
return [];
}
}
function panelEnabled(type: string, v: IndicatorVisibilitySettings): boolean {
switch (type) {
case 'volume':
return v.volume;
case 'macd':
return v.macdLine;
case 'rsi':
return v.rsi;
case 'stochastic':
return v.stochasticK || v.stochasticD || v.stochasticSlow;
case 'cci':
return v.cci;
case 'adx':
return v.adx;
case 'obv':
return v.obv;
case 'newPsychological':
return v.newPsychological;
case 'investPsychological':
return v.investPsychological;
case 'bwi':
return v.bwi;
case 'dmi':
return v.plusDi || v.minusDi;
case 'williamsR':
return v.williamsR;
case 'trix':
return v.trix;
case 'volumeOsc':
return v.volumeOsc;
case 'vr':
return v.vr;
case 'disparity':
return v.disparity;
default:
return false;
}
}
type ValuesSnapshot = {
lastCandle: CandleData;
row: IndicatorData | null;
maValues: { id: string; period: number; value: string }[];
} | null;
/** 팝업 행과 동일한 표시 문자열 맵 (값 변경 플래시 비교용) */
function buildFlatValuesForFlash(
snapshot: ValuesSnapshot,
realtimePrice: number | null,
indicatorVisibility: IndicatorVisibilitySettings,
ichimokuSettings: IchimokuFlags | undefined,
subPanelsOrder: string[],
): Record<string, string> {
const flat: Record<string, string> = {};
if (!snapshot?.lastCandle) return flat;
const { lastCandle: last, row, maValues } = snapshot;
flat['candle:time'] = flashCompareTimeKey(last.time);
flat['candle:open'] = flashCompareNumKey(last.open);
flat['candle:high'] = flashCompareNumKey(last.high);
flat['candle:low'] = flashCompareNumKey(last.low);
flat['candle:close'] = flashCompareNumKey(last.close);
flat['candle:volume'] = flashCompareNumKey(last.volume);
if (realtimePrice != null && Number.isFinite(realtimePrice)) {
flat['candle:rt'] = flashCompareNumKey(realtimePrice);
}
maValues.forEach((m) => {
flat[`ma:${m.id}`] = m.value;
});
if (row && ichimokuSettings) {
if (ichimokuSettings.tenkan) flat['ichimoku:tenkan'] = formatNum(row.ichimokuTenkan);
if (ichimokuSettings.kijun) flat['ichimoku:kijun'] = formatNum(row.ichimokuKijun);
if (ichimokuSettings.chikou) flat['ichimoku:chikou'] = formatNum(row.ichimokuChikou);
if (ichimokuSettings.senkouA) flat['ichimoku:senkouA'] = formatNum(row.ichimokuSenkouA);
if (ichimokuSettings.senkouB) flat['ichimoku:senkouB'] = formatNum(row.ichimokuSenkouB);
}
if (row) {
if (indicatorVisibility.bollingerUpper) flat['bb:upper'] = formatNum(row.bollingerUpper);
if (indicatorVisibility.bollingerMiddle) flat['bb:middle'] = formatNum(row.bollingerMiddle);
if (indicatorVisibility.bollingerLower) flat['bb:lower'] = formatNum(row.bollingerLower);
}
if (row) {
subPanelsOrder.forEach((type) => {
if (!panelEnabled(type, indicatorVisibility)) return;
subPanelRows(type, row).forEach(({ label, value }) => {
flat[`sub:${type}:${label}`] = value;
});
});
}
return flat;
}
const FLASH_HIGHLIGHT_MS = 650;
const DEFAULT_PANEL_WIDTH = 360;
const DEFAULT_PANEL_HEIGHT = 480;
const MIN_PANEL_WIDTH = 280;
const MIN_PANEL_HEIGHT = 240;
const MAXIMIZE_INSET = 8;
function clampPanelPosition(x: number, y: number, panelWidth: number, panelHeight: number): { x: number; y: number } {
if (typeof window === 'undefined') return { x, y };
const margin = 8;
const maxX = Math.max(margin, window.innerWidth - panelWidth - margin);
const maxY = Math.max(margin, window.innerHeight - panelHeight - margin);
return {
x: Math.min(Math.max(margin, x), maxX),
y: Math.min(Math.max(margin, y), maxY),
};
}
function positionNearAnchor(anchor: HTMLElement, panelWidth: number, panelHeight: number): { x: number; y: number } {
const r = anchor.getBoundingClientRect();
const margin = 8;
const x = r.right - panelWidth;
const y = r.bottom + margin;
return clampPanelPosition(x, y, panelWidth, panelHeight);
}
const RealtimeChartValuesPopup: React.FC<RealtimeChartValuesPopupProps> = ({
anchorEl,
onClose,
isDark,
symbol,
timeInterval,
candleData,
maCalculationData,
allIndicatorData,
indicatorData,
maLines,
indicatorVisibility,
ichimokuSettings,
subPanelsOrder,
realtimePrice,
realtimeReceiving,
}) => {
const nodeRef = useRef<HTMLDivElement>(null);
const [dragPos, setDragPos] = useState({ x: 24, y: 80 });
const [panelW, setPanelW] = useState(DEFAULT_PANEL_WIDTH);
const [panelH, setPanelH] = useState(DEFAULT_PANEL_HEIGHT);
const [isMaximized, setIsMaximized] = useState(false);
const preMaxRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
const resizeListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
useLayoutEffect(() => {
if (!anchorEl) return;
setDragPos(positionNearAnchor(anchorEl, panelW, panelH));
// 열릴 때(앵커만 변경) 위치만 잡음 — 패널 리사이즈로는 위치 고정
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchorEl]);
const onDragPanel = useCallback(
(_: unknown, data: { x: number; y: number }) => {
setDragPos(clampPanelPosition(data.x, data.y, panelW, panelH));
},
[panelW, panelH],
);
const toggleMaximize = useCallback(() => {
if (typeof window === 'undefined') return;
if (!isMaximized) {
preMaxRef.current = { x: dragPos.x, y: dragPos.y, w: panelW, h: panelH };
setIsMaximized(true);
return;
}
const saved = preMaxRef.current;
setIsMaximized(false);
if (saved) {
setPanelW(saved.w);
setPanelH(saved.h);
setDragPos(clampPanelPosition(saved.x, saved.y, saved.w, saved.h));
}
preMaxRef.current = null;
}, [isMaximized, dragPos.x, dragPos.y, panelW, panelH]);
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = panelW;
const startH = panelH;
const onMove = (ev: MouseEvent) => {
if (typeof window === 'undefined') return;
const dw = ev.clientX - startX;
const dh = ev.clientY - startY;
const maxW = window.innerWidth - MAXIMIZE_INSET * 2;
const maxH = window.innerHeight - MAXIMIZE_INSET * 2;
const nextW = Math.min(maxW, Math.max(MIN_PANEL_WIDTH, startW + dw));
const nextH = Math.min(maxH, Math.max(MIN_PANEL_HEIGHT, startH + dh));
setPanelW(nextW);
setPanelH(nextH);
setDragPos((p) => clampPanelPosition(p.x, p.y, nextW, nextH));
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
resizeListenersRef.current = null;
};
resizeListenersRef.current = { move: onMove, up: onUp };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
[isMaximized, panelW, panelH],
);
const snapshot = useMemo(() => {
if (!candleData.length) return null;
const lastCandle = candleData[candleData.length - 1];
const row = pickDisplayIndicatorRow(candleData, indicatorData);
const candleTimesUnix = new Set(
candleData.map((c) => candleTimeToSec(c.time)).filter((x) => Number.isFinite(x))
);
const lastT = candleTimeToSec(lastCandle.time);
const sorted = buildSortedDataForMA(allIndicatorData, maCalculationData, candleData);
const maValues: { id: string; period: number; value: string }[] = [];
if (sorted.length && maLines?.length) {
maLines.forEach((ma) => {
if (!ma.enabled) return;
if (sorted.length < ma.period) {
maValues.push({ id: ma.id, period: ma.period, value: '데이터 부족' });
return;
}
const series = calculateSMA(sorted, ma.period).filter(
(d) => d.value != null && candleTimesUnix.has(d.time)
);
const hit = [...series].reverse().find((d) => d.time <= lastT) ?? series[series.length - 1];
maValues.push({
id: ma.id,
period: ma.period,
value: hit?.value != null ? formatNum(hit.value) : '—',
});
});
}
return { lastCandle, row, maValues };
}, [candleData, indicatorData, allIndicatorData, maCalculationData, maLines]);
const last = snapshot?.lastCandle;
const row = snapshot?.row;
const flatValues = useMemo(
() =>
buildFlatValuesForFlash(
snapshot,
realtimePrice,
indicatorVisibility,
ichimokuSettings,
subPanelsOrder,
),
[snapshot, realtimePrice, indicatorVisibility, ichimokuSettings, subPanelsOrder],
);
const prevFlatRef = useRef<Record<string, string>>({});
const [flashKeys, setFlashKeys] = useState<Set<string>>(() => new Set());
const liveReceive = realtimeReceiving !== false;
const isFlashing = useCallback((key: string) => liveReceive && flashKeys.has(key), [liveReceive, flashKeys]);
useEffect(() => {
if (!anchorEl) {
prevFlatRef.current = {};
setFlashKeys(new Set());
return;
}
if (!liveReceive) {
prevFlatRef.current = { ...flatValues };
setFlashKeys(new Set());
return;
}
const cur = flatValues;
const prev = prevFlatRef.current;
const changed = new Set<string>();
for (const key of Object.keys(cur)) {
if (prev[key] !== undefined && prev[key] !== cur[key]) {
changed.add(key);
}
}
prevFlatRef.current = { ...cur };
if (changed.size === 0) return;
setFlashKeys((s) => {
const n = new Set<string>();
s.forEach((k) => n.add(k));
changed.forEach((k) => n.add(k));
return n;
});
const tid = window.setTimeout(() => {
setFlashKeys((s) => {
const n = new Set<string>();
s.forEach((k) => n.add(k));
changed.forEach((k) => n.delete(k));
return n;
});
}, FLASH_HIGHLIGHT_MS);
return () => window.clearTimeout(tid);
}, [anchorEl, flatValues, liveReceive]);
useEffect(() => {
if (!anchorEl) {
const h = resizeListenersRef.current;
if (h) {
window.removeEventListener('mousemove', h.move);
window.removeEventListener('mouseup', h.up);
resizeListenersRef.current = null;
}
setIsMaximized(false);
}
}, [anchorEl]);
if (!anchorEl || typeof document === 'undefined' || !document.body) {
return null;
}
const shellBox = (
<Box
ref={nodeRef}
sx={{
position: 'fixed',
left: isMaximized ? MAXIMIZE_INSET : 0,
top: isMaximized ? MAXIMIZE_INSET : 0,
zIndex: (theme) => theme.zIndex.modal + 25,
width: isMaximized ? `calc(100vw - ${MAXIMIZE_INSET * 2}px)` : panelW,
height: isMaximized ? `calc(100vh - ${MAXIMIZE_INSET * 2}px)` : panelH,
pointerEvents: 'auto',
boxSizing: 'border-box',
}}
>
<Paper
elevation={8}
sx={{
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: isDark ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.12)',
bgcolor: 'background.paper',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.75,
flexShrink: 0,
borderBottom: 1,
borderColor: 'divider',
bgcolor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
}}
>
<Box
className="chart-values-drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
flex: 1,
minWidth: 0,
cursor: isMaximized ? 'default' : 'grab',
userSelect: 'none',
'&:active': { cursor: isMaximized ? 'default' : 'grabbing' },
}}
>
<DragIndicatorIcon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0, opacity: 0.85 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis' }}>
</Typography>
</Box>
<IconButton
className="chart-values-no-drag"
size="small"
onClick={toggleMaximize}
aria-label={isMaximized ? '이전 크기로' : '최대화'}
sx={{ flexShrink: 0 }}
>
{isMaximized ? <FullscreenExitIcon fontSize="small" /> : <FullscreenIcon fontSize="small" />}
</IconButton>
<IconButton
className="chart-values-no-drag"
size="small"
onClick={onClose}
aria-label="닫기"
sx={{ flexShrink: 0 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ overflow: 'auto', px: 1.25, pb: 1.5, flex: 1, minHeight: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{symbol} · {timeInterval}
</Typography>
{!last ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
.
</Typography>
) : (
<>
{sectionTitle('캔들 (마지막 봉)')}
<Row label="시간" value={String(last.time)} flash={isFlashing('candle:time')} />
<Row label="시가(O)" value={formatNum(last.open)} flash={isFlashing('candle:open')} />
<Row label="고가(H)" value={formatNum(last.high)} flash={isFlashing('candle:high')} />
<Row label="저가(L)" value={formatNum(last.low)} flash={isFlashing('candle:low')} />
<Row label="종가(C)" value={formatNum(last.close)} flash={isFlashing('candle:close')} />
<Row label="거래량(V)" value={formatNum(last.volume)} flash={isFlashing('candle:volume')} />
{realtimePrice != null && Number.isFinite(realtimePrice) && (
<Row label="실시간 체결" value={formatNum(realtimePrice)} flash={isFlashing('candle:rt')} />
)}
{snapshot?.maValues && snapshot.maValues.length > 0 && (
<>
{sectionTitle('이동평균선 (SMA)')}
{snapshot.maValues.map((m) => (
<Row
key={m.id}
label={`${m.id} (${m.period})`}
value={m.value}
flash={isFlashing(`ma:${m.id}`)}
/>
))}
</>
)}
{row &&
ichimokuSettings &&
(ichimokuSettings.tenkan ||
ichimokuSettings.kijun ||
ichimokuSettings.chikou ||
ichimokuSettings.senkouA ||
ichimokuSettings.senkouB) && (
<>
{sectionTitle('일목균형표')}
{ichimokuSettings.tenkan && (
<Row label="전환선" value={formatNum(row.ichimokuTenkan)} flash={isFlashing('ichimoku:tenkan')} />
)}
{ichimokuSettings.kijun && (
<Row label="기준선" value={formatNum(row.ichimokuKijun)} flash={isFlashing('ichimoku:kijun')} />
)}
{ichimokuSettings.chikou && (
<Row label="후행스팬" value={formatNum(row.ichimokuChikou)} flash={isFlashing('ichimoku:chikou')} />
)}
{ichimokuSettings.senkouA && (
<Row label="선행스팬 A" value={formatNum(row.ichimokuSenkouA)} flash={isFlashing('ichimoku:senkouA')} />
)}
{ichimokuSettings.senkouB && (
<Row label="선행스팬 B" value={formatNum(row.ichimokuSenkouB)} flash={isFlashing('ichimoku:senkouB')} />
)}
</>
)}
{row &&
(indicatorVisibility.bollingerUpper ||
indicatorVisibility.bollingerMiddle ||
indicatorVisibility.bollingerLower) && (
<>
{sectionTitle(
`볼린저 밴드 (${indicatorVisibility.bollingerPeriod}/${indicatorVisibility.bollingerStdDev})`
)}
{indicatorVisibility.bollingerUpper && (
<Row label="상한" value={formatNum(row.bollingerUpper)} flash={isFlashing('bb:upper')} />
)}
{indicatorVisibility.bollingerMiddle && (
<Row label="중심" value={formatNum(row.bollingerMiddle)} flash={isFlashing('bb:middle')} />
)}
{indicatorVisibility.bollingerLower && (
<Row label="하한" value={formatNum(row.bollingerLower)} flash={isFlashing('bb:lower')} />
)}
</>
)}
{row && subPanelsOrder.some((t) => panelEnabled(t, indicatorVisibility)) && (
<>
{sectionTitle('보조지표 패널 (마지막 봉)')}
{subPanelsOrder.map((type) => {
if (!panelEnabled(type, indicatorVisibility)) return null;
const rows = subPanelRows(type, row);
if (!rows.length) return null;
return (
<Box key={type} sx={{ mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'primary.main' }}>
{type.toUpperCase()}
</Typography>
{rows.map((x) => (
<Row
key={`${type}-${x.label}`}
label={x.label}
value={x.value}
flash={isFlashing(`sub:${type}:${x.label}`)}
/>
))}
</Box>
);
})}
</>
)}
</>
)}
</Box>
{!isMaximized && (
<Box
className="chart-values-no-drag"
role="presentation"
aria-hidden
onMouseDown={onResizeStart}
sx={{
position: 'absolute',
right: 0,
bottom: 0,
width: 22,
height: 22,
cursor: 'nwse-resize',
zIndex: 2,
background: isDark
? 'linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.12) 50%)'
: 'linear-gradient(135deg, transparent 50%, rgba(0,0,0,0.08) 50%)',
}}
/>
)}
</Paper>
</Box>
);
const panel = isMaximized ? (
shellBox
) : (
<Draggable
nodeRef={nodeRef}
handle=".chart-values-drag-handle"
position={dragPos}
onDrag={onDragPanel}
bounds="body"
cancel=".chart-values-no-drag"
>
{shellBox}
</Draggable>
);
return createPortal(panel, document.body);
};
export default RealtimeChartValuesPopup;
@@ -0,0 +1,245 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
Typography,
Alert,
Link,
IconButton,
InputAdornment,
} from '@mui/material';
import { Visibility, VisibilityOff, Close } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import DraggablePaper from './DraggablePaper';
interface SignUpDialogProps {
open: boolean;
onClose: () => void;
onSwitchToLogin: () => void;
}
const SignUpDialog: React.FC<SignUpDialogProps> = ({ open, onClose, onSwitchToLogin }) => {
const { signUp } = useAuth();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [name, setName] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 유효성 검사
if (!username || !email || !password || !confirmPassword) {
setError('모든 필수 필드를 입력해주세요.');
return;
}
if (username.length < 3) {
setError('사용자명은 3자 이상이어야 합니다.');
return;
}
if (password.length < 6) {
setError('비밀번호는 6자 이상이어야 합니다.');
return;
}
if (password !== confirmPassword) {
setError('비밀번호가 일치하지 않습니다.');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setError('유효한 이메일 주소를 입력해주세요.');
return;
}
setIsLoading(true);
try {
await signUp(username, email, password, name);
handleClose();
} catch (err: any) {
setError(err.response?.data?.error || '회원가입에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setUsername('');
setEmail('');
setPassword('');
setConfirmPassword('');
setName('');
setError('');
setShowPassword(false);
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<IconButton size="small" onClick={handleClose} sx={{ color: '#fff' }}>
<Close />
</IconButton>
</DialogTitle>
<form onSubmit={handleSubmit}>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
fullWidth
label="사용자명 *"
value={username}
onChange={(e) => setUsername(e.target.value)}
margin="normal"
autoFocus
disabled={isLoading}
helperText="3자 이상"
/>
<TextField
fullWidth
label="이메일 *"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
disabled={isLoading}
/>
<TextField
fullWidth
label="비밀번호 *"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
disabled={isLoading}
helperText="6자 이상"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="비밀번호 확인 *"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
margin="normal"
disabled={isLoading}
error={confirmPassword.length > 0 && password !== confirmPassword}
helperText={
confirmPassword.length > 0 && password !== confirmPassword
? '비밀번호가 일치하지 않습니다'
: ''
}
/>
<TextField
fullWidth
label="이름 (선택)"
value={name}
onChange={(e) => setName(e.target.value)}
margin="normal"
disabled={isLoading}
/>
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
?{' '}
<Link
component="button"
type="button"
onClick={() => {
handleClose();
onSwitchToLogin();
}}
sx={{ cursor: 'pointer' }}
>
</Link>
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
<Button onClick={handleClose} disabled={isLoading}>
</Button>
<Button
type="submit"
variant="contained"
disabled={isLoading}
fullWidth
sx={{ ml: 1 }}
>
{isLoading ? '가입 중...' : '회원가입'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default SignUpDialog;
@@ -0,0 +1,464 @@
import React, { useState, useMemo } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Typography,
Box,
TablePagination,
FormControl,
InputLabel,
Select,
MenuItem,
Tooltip,
} from '@mui/material';
import { MacdResult } from '../types/stock';
import dayjs from 'dayjs';
interface SignalTableProps {
data: MacdResult[];
title?: string;
interval?: string; // 시간 간격
initialCapital?: number; // 초기 투자금 (기본 1000만원)
commissionRate?: number; // 수수료율 (기본 0.05%)
onRowClick?: (row: MacdResult, simulation?: TradeSimulation) => void; // 행 클릭 핸들러
}
// 거래 시뮬레이션 결과 타입 (export)
export interface TradeSimulation {
date: string;
timestamp?: string;
signal: string;
price: number; // 체결가
quantity: number; // 수량
tradeAmount: number; // 거래금액
commission: number; // 수수료
netAmount: number; // 실수령액/실지불액
holdingQty: number; // 보유수량
avgBuyPrice: number; // 평균매수가
profitLoss: number; // 손익금액
profitLossRate: number; // 수익률
totalAsset: number; // 총자산
macdData: MacdResult; // 원본 데이터
}
const SignalTable: React.FC<SignalTableProps> = ({
data,
title = '매매 신호',
interval = '1d',
initialCapital = 10000000,
commissionRate = 0.0005,
onRowClick,
}) => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [signalFilter, setSignalFilter] = useState<string>('TRADES'); // 기본값을 거래만으로 변경
// 거래 시뮬레이션 계산
const tradeSimulations = useMemo((): TradeSimulation[] => {
const simulations: TradeSimulation[] = [];
let cash = initialCapital;
let holdingQty = 0;
let avgBuyPrice = 0;
let totalBuyCost = 0;
// 시간순으로 정렬
const sortedData = [...data].sort((a, b) => {
const dateA = a.timestamp || a.date;
const dateB = b.timestamp || b.date;
return new Date(dateA).getTime() - new Date(dateB).getTime();
});
for (const item of sortedData) {
if (item.signal !== 'BUY' && item.signal !== 'SELL') continue;
const price = item.close;
if (item.signal === 'BUY' && cash > 0) {
// 매수: 가용 현금의 95%로 매수 (여유분 확보)
const availableCash = cash * 0.95;
const rawQuantity = availableCash / price;
const quantity = Math.floor(rawQuantity * 100000000) / 100000000; // 암호화폐는 소수점 8자리까지
if (quantity > 0) {
const tradeAmount = quantity * price;
const commission = tradeAmount * commissionRate;
const netAmount = tradeAmount + commission;
// 보유 정보 업데이트
totalBuyCost += netAmount;
holdingQty += quantity;
avgBuyPrice = holdingQty > 0 ? totalBuyCost / holdingQty : 0;
cash -= netAmount;
simulations.push({
date: item.date,
timestamp: item.timestamp,
signal: 'BUY',
price,
quantity,
tradeAmount,
commission,
netAmount,
holdingQty,
avgBuyPrice,
profitLoss: 0,
profitLossRate: 0,
totalAsset: cash + (holdingQty * price),
macdData: item,
});
}
} else if (item.signal === 'SELL' && holdingQty > 0) {
// 매도: 전량 매도
const quantity = holdingQty;
const tradeAmount = quantity * price;
const commission = tradeAmount * commissionRate;
const netAmount = tradeAmount - commission;
// 손익 계산
const profitLoss = netAmount - totalBuyCost;
const profitLossRate = totalBuyCost > 0 ? (profitLoss / totalBuyCost) * 100 : 0;
cash += netAmount;
simulations.push({
date: item.date,
timestamp: item.timestamp,
signal: 'SELL',
price,
quantity,
tradeAmount,
commission,
netAmount,
holdingQty: 0,
avgBuyPrice,
profitLoss,
profitLossRate,
totalAsset: cash,
macdData: item,
});
// 매도 후 초기화
holdingQty = 0;
avgBuyPrice = 0;
totalBuyCost = 0;
}
}
return simulations;
}, [data, initialCapital, commissionRate]);
// 신호별 필터링
const filteredData = data.filter(item => {
if (signalFilter === 'ALL') return true;
if (signalFilter === 'TRADES') return item.signal === 'BUY' || item.signal === 'SELL';
return item.signal === signalFilter;
});
// 매매 신호만 추출 (BUY, SELL)
const tradingSignals = data.filter(item => item.signal === 'BUY' || item.signal === 'SELL');
// 시뮬레이션 결과에서 해당 날짜의 거래 정보 찾기
const findSimulation = (row: MacdResult): TradeSimulation | undefined => {
const dateStr = row.timestamp || row.date;
return tradeSimulations.find(sim => {
const simDateStr = sim.timestamp || sim.date;
return simDateStr === dateStr && sim.signal === row.signal;
});
};
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const getSignalColor = (signal: string) => {
switch (signal) {
case 'BUY':
return 'success';
case 'SELL':
return 'error';
default:
return 'default';
}
};
const getSignalText = (signal: string) => {
switch (signal) {
case 'BUY':
return '매수';
case 'SELL':
return '매도';
default:
return '보유';
}
};
// interval에 따라 날짜 포맷 결정
const formatDate = (row: MacdResult) => {
try {
const dateStr = row.timestamp || row.date;
if (!dateStr) return '-';
const dateObj = dayjs(dateStr);
if (row.timestamp) {
// 분봉/시간봉 (timestamp 있음)
if (interval.endsWith('m') || interval.endsWith('h')) {
return dateObj.format('YYYY-MM-DD HH:mm');
}
return dateObj.format('YYYY-MM-DD HH:mm');
} else {
// 일봉 이상 (timestamp 없음)
if (interval === '1M') {
return dateObj.format('YYYY-MM');
}
return dateObj.format('YYYY-MM-DD');
}
} catch {
return row.date || '-';
}
};
// 페이지네이션 적용 (최신 데이터가 위에 오도록 역순)
const reversedData = [...filteredData].reverse();
const paginatedData = reversedData.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
return (
<Paper sx={{ width: '100%', mb: 2 }}>
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h6">{title}</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
: {tradeSimulations.length}
</Typography>
{tradeSimulations.length > 0 && (
<>
<Chip
label={`초기: ${(initialCapital / 10000).toLocaleString()}만원`}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
<Chip
label={`최종: ${(tradeSimulations[tradeSimulations.length - 1]?.totalAsset / 10000).toLocaleString(undefined, { maximumFractionDigits: 0 })}만원`}
size="small"
sx={{
fontSize: '0.75rem',
bgcolor: tradeSimulations[tradeSimulations.length - 1]?.totalAsset >= initialCapital
? 'rgba(34, 197, 94, 0.2)'
: 'rgba(239, 68, 68, 0.2)',
color: tradeSimulations[tradeSimulations.length - 1]?.totalAsset >= initialCapital
? '#22c55e'
: '#ef4444',
}}
/>
<Chip
label={`수익률: ${((tradeSimulations[tradeSimulations.length - 1]?.totalAsset / initialCapital - 1) * 100).toFixed(1)}%`}
size="small"
sx={{
fontSize: '0.75rem',
fontWeight: 600,
bgcolor: tradeSimulations[tradeSimulations.length - 1]?.totalAsset >= initialCapital
? 'rgba(34, 197, 94, 0.3)'
: 'rgba(239, 68, 68, 0.3)',
color: tradeSimulations[tradeSimulations.length - 1]?.totalAsset >= initialCapital
? '#22c55e'
: '#ef4444',
}}
/>
</>
)}
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel> </InputLabel>
<Select
value={signalFilter}
label="신호 필터"
onChange={(e) => {
setSignalFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="TRADES"></MenuItem>
<MenuItem value="ALL"></MenuItem>
<MenuItem value="BUY"></MenuItem>
<MenuItem value="SELL"></MenuItem>
<MenuItem value="HOLD"></MenuItem>
</Select>
</FormControl>
</Box>
</Box>
<TableContainer sx={{ maxHeight: 500 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 100 }}></TableCell>
<TableCell align="center" sx={{ minWidth: 60 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 100 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 90 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 110 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 70 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 110 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 100 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 70 }}></TableCell>
<TableCell align="right" sx={{ minWidth: 110 }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedData.map((row, index) => {
const sim = findSimulation(row);
const isTrade = row.signal === 'BUY' || row.signal === 'SELL';
return (
<TableRow
key={index}
onClick={() => {
if (onRowClick && (row.signal === 'BUY' || row.signal === 'SELL')) {
onRowClick(row, sim);
}
}}
sx={{
'&:last-child td, &:last-child th': { border: 0 },
backgroundColor: row.signal !== 'HOLD' ?
(row.signal === 'BUY' ? 'rgba(76, 175, 80, 0.1)' : 'rgba(244, 67, 54, 0.1)') :
'inherit',
cursor: (row.signal === 'BUY' || row.signal === 'SELL') && onRowClick ? 'pointer' : 'default',
'&:hover': {
backgroundColor: (row.signal === 'BUY' || row.signal === 'SELL') && onRowClick
? (row.signal === 'BUY' ? 'rgba(76, 175, 80, 0.2)' : 'rgba(244, 67, 54, 0.2)')
: undefined,
},
}}
>
<TableCell component="th" scope="row" sx={{ fontSize: '0.8rem' }}>
{formatDate(row)}
</TableCell>
<TableCell align="center">
<Chip
label={getSignalText(row.signal)}
color={getSignalColor(row.signal) as any}
size="small"
variant={row.signal !== 'HOLD' ? 'filled' : 'outlined'}
sx={{ fontSize: '0.75rem', height: 24 }}
/>
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem' }}>
{row.close?.toLocaleString()}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem' }}>
{isTrade && sim ? (
<Tooltip title={`${sim.quantity.toFixed(8)}`} arrow>
<span>{sim.quantity.toFixed(4)}</span>
</Tooltip>
) : '-'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem' }}>
{isTrade && sim ? (
<Typography
variant="body2"
sx={{
fontSize: '0.8rem',
color: row.signal === 'BUY' ? '#ef4444' : '#22c55e',
fontWeight: 500,
}}
>
{row.signal === 'BUY' ? '-' : '+'}{sim.tradeAmount.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</Typography>
) : '-'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
{isTrade && sim ? (
`${sim.commission.toLocaleString(undefined, { maximumFractionDigits: 0 })}원`
) : '-'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem' }}>
{isTrade && sim ? (
<Typography
variant="body2"
sx={{
fontSize: '0.8rem',
color: row.signal === 'BUY' ? '#ef4444' : '#22c55e',
fontWeight: 600,
}}
>
{row.signal === 'BUY' ? '-' : '+'}{sim.netAmount.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</Typography>
) : '-'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem' }}>
{row.signal === 'SELL' && sim ? (
<Typography
variant="body2"
sx={{
fontSize: '0.8rem',
color: sim.profitLoss >= 0 ? '#22c55e' : '#ef4444',
fontWeight: 600,
}}
>
{sim.profitLoss >= 0 ? '+' : ''}{sim.profitLoss.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</Typography>
) : '-'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem' }}>
{row.signal === 'SELL' && sim ? (
<Chip
label={`${sim.profitLossRate >= 0 ? '+' : ''}${sim.profitLossRate.toFixed(1)}%`}
size="small"
sx={{
fontSize: '0.7rem',
height: 22,
bgcolor: sim.profitLossRate >= 0 ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
color: sim.profitLossRate >= 0 ? '#22c55e' : '#ef4444',
fontWeight: 600,
}}
/>
) : '-'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
{isTrade && sim ? (
`${sim.totalAsset.toLocaleString(undefined, { maximumFractionDigits: 0 })}원`
) : '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50]}
component="div"
count={filteredData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage="페이지당 행 수:"
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} / 총 ${count !== -1 ? count : `${to}개 이상`}`
}
/>
</Box>
</Paper>
);
};
export default SignalTable;
@@ -0,0 +1,802 @@
import React, { memo } from "react";
import {
Box,
Typography,
Dialog,
DialogTitle,
DialogContent,
IconButton,
Chip,
useTheme,
} from "@mui/material";
import { Close as CloseIcon } from "@mui/icons-material";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
ReferenceLine,
ComposedChart,
Bar,
Cell,
ReferenceDot,
} from "recharts";
import DraggablePaper from "./DraggablePaper";
import type { TradeRecord, BacktestResult } from "../services/backtestApi";
export type TradeSignalDetailDialogProps = {
open: boolean;
trade: TradeRecord | null;
backtestResult: BacktestResult | null;
onClose: () => void;
};
function TradeSignalDetailDialogInner({
open,
trade,
backtestResult,
onClose,
}: TradeSignalDetailDialogProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const normalizeTimeKey = (v?: string | null): string => {
if (!v) return '';
const s = v.replace(' ', 'T');
// 초/밀리초 차이를 흡수하기 위해 분 단위까지 비교
return s.length >= 16 ? s.slice(0, 16) : s;
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
scroll="paper"
hideBackdrop={true} // ✅ 백드랍 제거 - 일반 팝업으로 동작
disableScrollLock={true} // ✅ 배경 스크롤 허용
PaperComponent={DraggablePaper} // ✅ 드래그 가능한 Paper 사용
sx={{
zIndex: 9999, // ✅ 최상위 레이어로 설정
pointerEvents: 'none', // ✅ Dialog 자체는 클릭 무시
'& .MuiDialog-container': {
pointerEvents: 'none', // ✅ container도 클릭 무시
alignItems: 'flex-start', // ✅ 상단 정렬로 변경
},
}}
PaperProps={{
sx: {
bgcolor: isDark ? '#1a1b2e' : '#fff',
color: isDark ? '#e0e0e0' : '#333',
borderRadius: 2,
border: isDark ? '2px solid rgba(80, 100, 160, 0.5)' : '1px solid #ddd',
maxHeight: '75vh',
width: '600px',
pointerEvents: 'auto', // ✅ Paper만 클릭 가능
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', // ✅ 그림자 추가로 더 잘 보이게
}
}}
>
{/* ✅ 통일된 파란색 타이틀바 */}
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
{trade?.tradeType === 'BUY' ? '📈 매수 신호 상세' : '📉 매도 신호 상세'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="caption" sx={{ color: '#fff', fontSize: '13px' }}>
{trade?.timestamp}
</Typography>
<IconButton
onClick={() => onClose()}
size="small"
sx={{ color: '#fff' }}
>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{
pt: 2,
pb: 2,
overflowY: 'auto',
// ✅ 스크롤바 스타일 개선
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: isDark ? 'rgba(30, 35, 50, 0.5)' : '#f1f1f1',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
background: isDark ? 'rgba(80, 100, 160, 0.5)' : '#888',
borderRadius: '4px',
'&:hover': {
background: isDark ? 'rgba(80, 100, 160, 0.7)' : '#555',
},
},
}}>
{trade && (
<Box>
{/* 기본 거래 정보 */}
<Box sx={{
mb: 2,
p: 2,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
border: isDark ? '1px solid rgba(80, 100, 160, 0.2)' : 'none',
}}>
<Typography sx={{ fontWeight: 600, mb: 1.5, fontSize: '13px', color: isDark ? '#a1a1aa' : '#666' }}>
📊
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontWeight: 600, fontSize: '14px' }}>{trade.price?.toLocaleString()}</Typography>
</Box>
<Box>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontWeight: 600, fontSize: '14px' }}>{trade.quantity?.toFixed(6)}</Typography>
</Box>
<Box>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontWeight: 600, fontSize: '14px' }}>{trade.totalAmount?.toLocaleString()}</Typography>
</Box>
<Box>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontWeight: 600, fontSize: '14px' }}>{trade.commission?.toLocaleString()}</Typography>
</Box>
{trade.tradeType === 'SELL' && (
<>
<Box>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{
fontWeight: 600,
fontSize: '14px',
color: (trade.profitLoss ?? 0) >= 0 ? '#22c55e' : '#ef4444',
}}>
{(trade.profitLoss ?? 0) >= 0 ? '+' : ''}{trade.profitLoss?.toLocaleString()}
</Typography>
</Box>
<Box>
<Typography sx={{ fontSize: '11px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{
fontWeight: 600,
fontSize: '14px',
color: (trade.returnRate ?? 0) >= 0 ? '#22c55e' : '#ef4444',
}}>
{(trade.returnRate ?? 0) >= 0 ? '+' : ''}{trade.returnRate?.toFixed(2)}%
</Typography>
</Box>
</>
)}
</Box>
</Box>
{/* 캔들 차트 정보 (OHLCV) */}
{(trade.open || trade.high || trade.low || trade.close) && (
<Box sx={{
mb: 2,
p: 2,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
border: isDark ? '1px solid rgba(80, 100, 160, 0.2)' : 'none',
}}>
<Typography sx={{ fontWeight: 600, mb: 1.5, fontSize: '13px', color: isDark ? '#a1a1aa' : '#666' }}>
🕯
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1 }}>
<Box sx={{ textAlign: 'center', p: 1, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}> (O)</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '12px', color: isDark ? '#e0e0e0' : '#333' }}>
{trade.open?.toLocaleString()}
</Typography>
</Box>
<Box sx={{ textAlign: 'center', p: 1, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '10px', color: '#ef4444' }}> (H)</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '12px', color: '#ef4444' }}>
{trade.high?.toLocaleString()}
</Typography>
</Box>
<Box sx={{ textAlign: 'center', p: 1, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '10px', color: '#3b82f6' }}> (L)</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '12px', color: '#3b82f6' }}>
{trade.low?.toLocaleString()}
</Typography>
</Box>
<Box sx={{ textAlign: 'center', p: 1, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}> (C)</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '12px', color: isDark ? '#e0e0e0' : '#333' }}>
{trade.close?.toLocaleString()}
</Typography>
</Box>
<Box sx={{ textAlign: 'center', p: 1, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontWeight: 600, fontSize: '12px', color: isDark ? '#e0e0e0' : '#333' }}>
{trade.volume?.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</Typography>
</Box>
</Box>
</Box>
)}
{/* 주요 지표 스냅샷 */}
{trade.indicators && (
<Box sx={{
mb: 2,
p: 2,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
border: isDark ? '1px solid rgba(80, 100, 160, 0.2)' : 'none',
}}>
<Typography sx={{ fontWeight: 600, mb: 1.5, fontSize: '13px', color: isDark ? '#a1a1aa' : '#666' }}>
📈
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5 }}>
{/* 이동평균선 */}
<Box sx={{ p: 1.5, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '11px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 0.5 }}></Typography>
<Box sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}>
{trade.indicators.ma10 && <Box>MA10: {trade.indicators.ma10.toLocaleString()}</Box>}
{trade.indicators.ma20 && <Box>MA20: {trade.indicators.ma20.toLocaleString()}</Box>}
{trade.indicators.ma60 && <Box>MA60: {trade.indicators.ma60.toLocaleString()}</Box>}
</Box>
</Box>
{/* MACD */}
<Box sx={{ p: 1.5, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '11px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 0.5 }}>MACD</Typography>
<Box sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}>
{trade.indicators.macdLine !== undefined && <Box>MACD: {trade.indicators.macdLine?.toFixed(2)}</Box>}
{trade.indicators.macdSignal !== undefined && <Box>Signal: {trade.indicators.macdSignal?.toFixed(2)}</Box>}
{trade.indicators.macdHistogram !== undefined && (
<Box sx={{ color: (trade.indicators.macdHistogram ?? 0) >= 0 ? '#22c55e' : '#ef4444' }}>
Histogram: {trade.indicators.macdHistogram?.toFixed(2)}
</Box>
)}
</Box>
</Box>
{/* Stochastic */}
<Box sx={{ p: 1.5, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '11px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 0.5 }}>Stochastic</Typography>
<Box sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}>
{trade.indicators.stochK !== undefined && <Box>%K: {trade.indicators.stochK?.toFixed(2)}</Box>}
{trade.indicators.stochD !== undefined && <Box>%D: {trade.indicators.stochD?.toFixed(2)}</Box>}
</Box>
</Box>
{/* RSI */}
{trade.indicators.rsi !== undefined && (
<Box sx={{ p: 1.5, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '11px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 0.5 }}>RSI</Typography>
<Typography sx={{
fontSize: '14px',
fontWeight: 600,
color: (trade.indicators.rsi ?? 50) > 70 ? '#ef4444' : (trade.indicators.rsi ?? 50) < 30 ? '#22c55e' : (isDark ? '#e0e0e0' : '#333')
}}>
{trade.indicators.rsi?.toFixed(2)}
</Typography>
</Box>
)}
{/* ADX */}
{trade.indicators.adx !== undefined && (
<Box sx={{ p: 1.5, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '11px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 0.5 }}>ADX</Typography>
<Box sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}>
<Box>ADX: {trade.indicators.adx?.toFixed(2)}</Box>
{trade.indicators.plusDi !== undefined && <Box sx={{ color: '#22c55e' }}>+DI: {trade.indicators.plusDi?.toFixed(2)}</Box>}
{trade.indicators.minusDi !== undefined && <Box sx={{ color: '#ef4444' }}>-DI: {trade.indicators.minusDi?.toFixed(2)}</Box>}
</Box>
</Box>
)}
{/* 일목균형표 */}
{(trade.indicators.ichimokuTenkan !== undefined || trade.indicators.ichimokuKijun !== undefined) && (
<Box sx={{ p: 1.5, bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff', borderRadius: 1 }}>
<Typography sx={{ fontSize: '11px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 0.5 }}></Typography>
<Box sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#999' }}>
{trade.indicators.ichimokuTenkan !== undefined && <Box>: {trade.indicators.ichimokuTenkan?.toLocaleString()}</Box>}
{trade.indicators.ichimokuKijun !== undefined && <Box>: {trade.indicators.ichimokuKijun?.toLocaleString()}</Box>}
</Box>
</Box>
)}
</Box>
</Box>
)}
{/* 적용 전략 정보 */}
{trade.strategyName && (
<Box sx={{
mb: 2,
p: 2,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
border: isDark ? '1px solid rgba(80, 100, 160, 0.2)' : 'none',
}}>
<Typography sx={{ fontWeight: 600, mb: 1.5, fontSize: '13px', color: isDark ? '#a1a1aa' : '#666' }}>
🎯
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={trade.strategyName}
size="small"
sx={{
bgcolor: isDark ? 'rgba(59, 130, 246, 0.2)' : 'rgba(59, 130, 246, 0.1)',
color: '#3b82f6',
fontWeight: 600,
}}
/>
{trade.trendStatus && (
<Chip
label={trade.trendStatus}
size="small"
sx={{
bgcolor: trade.trendStatus?.includes('정배열')
? 'rgba(34, 197, 94, 0.2)'
: trade.trendStatus?.includes('역배열')
? 'rgba(239, 68, 68, 0.2)'
: 'rgba(168, 162, 158, 0.2)',
color: trade.trendStatus?.includes('정배열')
? '#22c55e'
: trade.trendStatus?.includes('역배열')
? '#ef4444'
: '#a8a29e',
fontWeight: 500,
fontSize: '11px',
}}
/>
)}
</Box>
</Box>
)}
{/* 충족 조건 목록 */}
{trade.matchedConditions && trade.matchedConditions.length > 0 && (
<Box sx={{
p: 2,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
border: isDark ? '1px solid rgba(80, 100, 160, 0.2)' : 'none',
}}>
<Typography sx={{ fontWeight: 600, mb: 1.5, fontSize: '13px', color: isDark ? '#a1a1aa' : '#666' }}>
({trade.matchedConditions.filter(c => c.matched).length}/{trade.matchedConditions.length})
</Typography>
{trade.matchedConditions.map((cond, idx) => (
<Box key={idx} sx={{
mb: 1.5,
p: 1.5,
bgcolor: isDark ? 'rgba(20, 25, 40, 0.5)' : '#fff',
borderRadius: 1,
border: `1px solid ${cond.matched ? (isDark ? 'rgba(34, 197, 94, 0.3)' : 'rgba(34, 197, 94, 0.5)') : (isDark ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.5)')}`,
opacity: cond.matched ? 1 : 0.7,
}}>
{/* 조건 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography sx={{ fontSize: '14px' }}>
{cond.matched ? '✅' : '❌'}
</Typography>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: isDark ? '#e0e0e0' : '#333' }}>
{cond.indicatorName || cond.conditionType}
</Typography>
<Typography sx={{ fontSize: '11px', color: isDark ? '#a1a1aa' : '#666' }}>
{cond.description}
</Typography>
</Box>
</Box>
{/* 값 정보 */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 1, mb: 1 }}>
{cond.currentValue != null && (
<Box sx={{ p: 0.5, bgcolor: isDark ? 'rgba(30, 35, 50, 0.5)' : '#f5f5f5', borderRadius: 0.5 }}>
<Typography sx={{ fontSize: '9px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#e0e0e0' : '#333' }}>
{typeof cond.currentValue === 'number' ? cond.currentValue.toFixed(4) : cond.currentValue}
</Typography>
</Box>
)}
{cond.previousValue != null && (
<Box sx={{ p: 0.5, bgcolor: isDark ? 'rgba(30, 35, 50, 0.5)' : '#f5f5f5', borderRadius: 0.5 }}>
<Typography sx={{ fontSize: '9px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#e0e0e0' : '#333' }}>
{cond.previousValue.toFixed(4)}
</Typography>
</Box>
)}
{cond.targetValue != null && (
<Box sx={{ p: 0.5, bgcolor: isDark ? 'rgba(30, 35, 50, 0.5)' : '#f5f5f5', borderRadius: 0.5 }}>
<Typography sx={{ fontSize: '9px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#e0e0e0' : '#333' }}>
{cond.targetValue.toFixed(4)}
</Typography>
</Box>
)}
{cond.compareValue != null && (
<Box sx={{ p: 0.5, bgcolor: isDark ? 'rgba(30, 35, 50, 0.5)' : '#f5f5f5', borderRadius: 0.5 }}>
<Typography sx={{ fontSize: '9px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#e0e0e0' : '#333' }}>
{cond.compareValue.toFixed(4)}
</Typography>
</Box>
)}
{cond.secondaryValue != null && (
<Box sx={{ p: 0.5, bgcolor: isDark ? 'rgba(30, 35, 50, 0.5)' : '#f5f5f5', borderRadius: 0.5 }}>
<Typography sx={{ fontSize: '9px', color: isDark ? '#71717a' : '#999' }}></Typography>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#e0e0e0' : '#333' }}>
{cond.secondaryValue.toFixed(4)}
</Typography>
</Box>
)}
</Box>
{/* 상세 설명 */}
{cond.detailDescription && (
<Box sx={{
mt: 1,
p: 1,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.8)' : '#f0f0f0',
borderRadius: 0.5,
borderLeft: `3px solid ${cond.matched ? '#22c55e' : '#ef4444'}`,
}}>
<Typography sx={{
fontSize: '11px',
color: isDark ? '#a1a1aa' : '#666',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
}}>
{cond.detailDescription}
</Typography>
</Box>
)}
</Box>
))}
</Box>
)}
{/* 조건 정보가 없을 때 */}
{(!trade.matchedConditions || trade.matchedConditions.length === 0) && !trade.strategyName && (
<Box sx={{
p: 2,
textAlign: 'center',
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
}}>
<Typography sx={{ fontSize: '13px', color: isDark ? '#71717a' : '#999' }}>
.
<br />
.
</Typography>
</Box>
)}
{/* 보조지표 차트 (알림/투자분석 공통: 시계열이 있으면 최대한 표시) */}
{backtestResult?.indicatorData && backtestResult.indicatorData.length > 0 && (() => {
// 거래 시점 찾기
const tradeTimestamp = trade.timestamp;
const tradeTimeKey = normalizeTimeKey(tradeTimestamp);
let tradeIndex = (backtestResult.indicatorData as any[]).findIndex((ind) => {
const indKey = normalizeTimeKey(ind.time);
return indKey !== '' && indKey === tradeTimeKey;
});
// 알림 상세는 전후 시계열만 내려오는 경우가 있어 정확히 매칭 실패할 수 있음.
// 이때는 시계열 중앙을 거래 시점으로 간주하여 조건 차트를 표시한다.
if (tradeIndex < 0) {
tradeIndex = Math.floor(((backtestResult.indicatorData as any[]).length - 1) / 2);
}
// 전후 5개 데이터 추출 (총 11개) - 더 집중된 뷰
const startIdx = Math.max(0, tradeIndex - 5);
const endIdx = Math.min((backtestResult.indicatorData as any[]).length, tradeIndex + 6);
const chartRange = (backtestResult.indicatorData as any[]).slice(startIdx, endIdx);
// 조건에서 사용된 지표 타입 추출
const usedIndicators = new Set<string>();
(trade.matchedConditions ?? []).forEach(cond => {
const type = cond.conditionType?.toUpperCase();
const indicatorName = cond.indicatorName?.toUpperCase();
const hint = `${type || ''} ${indicatorName || ''}`;
if (hint.includes('MACD') || hint.includes('HISTOGRAM')) usedIndicators.add('MACD');
if (hint.includes('RSI')) usedIndicators.add('RSI');
if (hint.includes('STOCHASTIC') || hint.includes('STOCH')) usedIndicators.add('STOCHASTIC');
if (hint.includes('CCI')) usedIndicators.add('CCI');
if (hint.includes('ADX') || hint.includes('DI') || hint.includes('DMI')) usedIndicators.add('ADX');
if (hint.includes('TRIX')) usedIndicators.add('TRIX');
});
// 알림 상세는 조건 메타가 비어있을 수 있어, 데이터 존재 기반으로 보조지표 차트를 보강한다.
if (usedIndicators.size === 0) {
const has = (key: string) => chartRange.some((d: any) => d[key] != null);
if (has('stochK') || has('stochD')) usedIndicators.add('STOCHASTIC');
if (has('macdLine') || has('signalLine') || has('histogram')) usedIndicators.add('MACD');
if (has('rsi')) usedIndicators.add('RSI');
if (has('cci')) usedIndicators.add('CCI');
if (has('adx') || has('plusDI') || has('minusDI')) usedIndicators.add('ADX');
if (has('trix') || has('trixSignal')) usedIndicators.add('TRIX');
}
if (usedIndicators.size === 0) return null;
// 차트 데이터 포맷
const formattedData = chartRange.map((ind, idx) => {
const date = ind.time?.includes('T')
? ind.time.split('T')[1]?.substring(0, 5) || ind.time.split('T')[0]?.substring(5)
: ind.time?.substring(5) || '';
return {
date,
fullTime: ind.time,
isTradePoint: startIdx + idx === tradeIndex,
// MACD
macdLine: ind.macdLine,
signalLine: ind.signalLine,
histogram: ind.histogram,
// RSI
rsi: ind.rsi,
// Stochastic
stochK: ind.stochK || ind.stochasticK,
stochD: ind.stochD || ind.stochasticD,
// CCI
cci: ind.cci || ind.cci13,
// ADX
adx: ind.adx,
plusDI: ind.plusDI || ind.plusDi,
minusDI: ind.minusDI || ind.minusDi,
// TRIX
trix: ind.trix,
trixSignal: ind.trixSignal,
};
});
const tradePointIndex = tradeIndex - startIdx;
// 정사각형 차트 박스 스타일 - 팝업 전체 너비 활용
const chartBoxStyle = {
width: '100%',
height: 350,
bgcolor: isDark ? 'rgba(20, 25, 40, 0.8)' : '#fff',
borderRadius: 2,
p: 2,
border: isDark ? '1px solid rgba(80, 100, 160, 0.3)' : '1px solid #e0e0e0',
boxShadow: isDark ? '0 4px 12px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.1)',
};
// 마커 크기
const markerSize = 6;
return (
<Box sx={{
mt: 2,
p: 2,
bgcolor: isDark ? 'rgba(30, 35, 50, 0.7)' : '#f5f5f5',
borderRadius: 1,
border: isDark ? '1px solid rgba(80, 100, 160, 0.2)' : 'none',
}}>
<Typography sx={{ fontWeight: 600, mb: 1.5, fontSize: '13px', color: isDark ? '#a1a1aa' : '#666' }}>
📊 ( )
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
{/* MACD 차트 */}
{usedIndicators.has('MACD') && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 1 }}>MACD</Typography>
<Box sx={chartBoxStyle}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={formattedData} margin={{ top: 15, right: 15, bottom: 25, left: 15 }}>
<CartesianGrid stroke={isDark ? '#27272a' : '#e0e0e0'} strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} interval={0} angle={-45} textAnchor="end" height={35} />
<YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} width={45} />
<ReferenceLine y={0} stroke={isDark ? '#52525b' : '#ccc'} strokeDasharray="2 2" />
<Bar dataKey="histogram" barSize={18}>
{formattedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={(entry.histogram ?? 0) >= 0 ? '#22c55e' : '#ef4444'} />
))}
</Bar>
<Line type="monotone" dataKey="macdLine" stroke="#2196F3" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="signalLine" stroke="#FF9800" strokeWidth={2} dot={{ r: 3 }} />
<ReferenceLine x={formattedData[tradePointIndex]?.date} stroke={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'} strokeWidth={2} />
{formattedData[tradePointIndex]?.macdLine != null && (
<ReferenceDot
x={formattedData[tradePointIndex]?.date}
y={formattedData[tradePointIndex]?.macdLine}
r={markerSize}
fill={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'}
stroke="#fff"
strokeWidth={1.5}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</Box>
</Box>
)}
{/* RSI 차트 */}
{usedIndicators.has('RSI') && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 1 }}>RSI</Typography>
<Box sx={chartBoxStyle}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData} margin={{ top: 15, right: 15, bottom: 25, left: 15 }}>
<CartesianGrid stroke={isDark ? '#27272a' : '#e0e0e0'} strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} interval={0} angle={-45} textAnchor="end" height={35} />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} width={35} ticks={[30, 50, 70]} />
<ReferenceLine y={70} stroke="#ef4444" strokeDasharray="3 3" strokeWidth={1.5} />
<ReferenceLine y={30} stroke="#22c55e" strokeDasharray="3 3" strokeWidth={1.5} />
<Line type="monotone" dataKey="rsi" stroke="#00BCD4" strokeWidth={2.5} dot={{ r: 4, fill: '#00BCD4' }} />
<ReferenceLine x={formattedData[tradePointIndex]?.date} stroke={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'} strokeWidth={2} />
{formattedData[tradePointIndex]?.rsi != null && (
<ReferenceDot
x={formattedData[tradePointIndex]?.date}
y={formattedData[tradePointIndex]?.rsi}
r={markerSize}
fill={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'}
stroke="#fff"
strokeWidth={1.5}
/>
)}
</LineChart>
</ResponsiveContainer>
</Box>
</Box>
)}
{/* Stochastic 차트 */}
{usedIndicators.has('STOCHASTIC') && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 1 }}>Stochastic</Typography>
<Box sx={chartBoxStyle}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData} margin={{ top: 15, right: 15, bottom: 25, left: 15 }}>
<CartesianGrid stroke={isDark ? '#27272a' : '#e0e0e0'} strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} interval={0} angle={-45} textAnchor="end" height={35} />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} width={35} ticks={[20, 50, 80]} />
<ReferenceLine y={80} stroke="#ef4444" strokeDasharray="3 3" strokeWidth={1.5} />
<ReferenceLine y={20} stroke="#22c55e" strokeDasharray="3 3" strokeWidth={1.5} />
<Line type="monotone" dataKey="stochK" stroke="#FF9500" strokeWidth={2.5} dot={{ r: 4, fill: '#FF9500' }} name="%K" />
<Line type="monotone" dataKey="stochD" stroke="#2196F3" strokeWidth={2} dot={{ r: 3, fill: '#2196F3' }} name="%D" />
<ReferenceLine x={formattedData[tradePointIndex]?.date} stroke={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'} strokeWidth={2} />
{formattedData[tradePointIndex]?.stochK != null && (
<ReferenceDot
x={formattedData[tradePointIndex]?.date}
y={formattedData[tradePointIndex]?.stochK}
r={markerSize}
fill={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'}
stroke="#fff"
strokeWidth={1.5}
/>
)}
</LineChart>
</ResponsiveContainer>
</Box>
</Box>
)}
{/* CCI 차트 */}
{usedIndicators.has('CCI') && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 1 }}>CCI</Typography>
<Box sx={chartBoxStyle}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData} margin={{ top: 15, right: 15, bottom: 25, left: 15 }}>
<CartesianGrid stroke={isDark ? '#27272a' : '#e0e0e0'} strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} interval={0} angle={-45} textAnchor="end" height={35} />
<YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} width={45} />
<ReferenceLine y={100} stroke="#ef4444" strokeDasharray="3 3" strokeWidth={1.5} />
<ReferenceLine y={-100} stroke="#22c55e" strokeDasharray="3 3" strokeWidth={1.5} />
<ReferenceLine y={0} stroke={isDark ? '#52525b' : '#ccc'} strokeDasharray="2 2" />
<Line type="monotone" dataKey="cci" stroke="#9C27B0" strokeWidth={2.5} dot={{ r: 4, fill: '#9C27B0' }} />
<ReferenceLine x={formattedData[tradePointIndex]?.date} stroke={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'} strokeWidth={2} />
{formattedData[tradePointIndex]?.cci != null && (
<ReferenceDot
x={formattedData[tradePointIndex]?.date}
y={formattedData[tradePointIndex]?.cci}
r={markerSize}
fill={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'}
stroke="#fff"
strokeWidth={1.5}
/>
)}
</LineChart>
</ResponsiveContainer>
</Box>
</Box>
)}
{/* ADX 차트 */}
{usedIndicators.has('ADX') && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 1 }}>ADX / DI</Typography>
<Box sx={chartBoxStyle}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData} margin={{ top: 15, right: 15, bottom: 25, left: 15 }}>
<CartesianGrid stroke={isDark ? '#27272a' : '#e0e0e0'} strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} interval={0} angle={-45} textAnchor="end" height={35} />
<YAxis domain={[0, 'auto']} tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} width={35} />
<Line type="monotone" dataKey="adx" stroke="#FFD700" strokeWidth={2.5} dot={{ r: 4, fill: '#FFD700' }} name="ADX" />
<Line type="monotone" dataKey="plusDI" stroke="#22c55e" strokeWidth={2} dot={{ r: 3 }} name="+DI" />
<Line type="monotone" dataKey="minusDI" stroke="#ef4444" strokeWidth={2} dot={{ r: 3 }} name="-DI" />
<ReferenceLine x={formattedData[tradePointIndex]?.date} stroke={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'} strokeWidth={2} />
{formattedData[tradePointIndex]?.adx != null && (
<ReferenceDot
x={formattedData[tradePointIndex]?.date}
y={formattedData[tradePointIndex]?.adx}
r={markerSize}
fill={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'}
stroke="#fff"
strokeWidth={1.5}
/>
)}
</LineChart>
</ResponsiveContainer>
</Box>
</Box>
)}
{/* TRIX 차트 */}
{usedIndicators.has('TRIX') && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 600, color: isDark ? '#a1a1aa' : '#666', mb: 1 }}>TRIX</Typography>
<Box sx={chartBoxStyle}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData} margin={{ top: 15, right: 15, bottom: 25, left: 15 }}>
<CartesianGrid stroke={isDark ? '#27272a' : '#e0e0e0'} strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} interval={0} angle={-45} textAnchor="end" height={35} />
<YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#888' }} width={45} />
<ReferenceLine y={0} stroke={isDark ? '#52525b' : '#ccc'} strokeDasharray="2 2" />
<Line type="monotone" dataKey="trix" stroke="#E91E63" strokeWidth={2.5} dot={{ r: 4, fill: '#E91E63' }} name="TRIX" />
<Line type="monotone" dataKey="trixSignal" stroke="#9C27B0" strokeWidth={2} dot={{ r: 3, fill: '#9C27B0' }} name="Signal" />
<ReferenceLine x={formattedData[tradePointIndex]?.date} stroke={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'} strokeWidth={2} />
{formattedData[tradePointIndex]?.trix != null && (
<ReferenceDot
x={formattedData[tradePointIndex]?.date}
y={formattedData[tradePointIndex]?.trix}
r={markerSize}
fill={trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444'}
stroke="#fff"
strokeWidth={1.5}
/>
)}
</LineChart>
</ResponsiveContainer>
</Box>
</Box>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mt: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: trade.tradeType === 'BUY' ? '#22c55e' : '#ef4444', border: '1.5px solid #fff' }} />
<Typography sx={{ fontSize: '10px', color: isDark ? '#71717a' : '#888' }}>
{trade.tradeType === 'BUY' ? '매수' : '매도'}
</Typography>
</Box>
<Typography sx={{ fontSize: '10px', color: isDark ? '#52525b' : '#999' }}>
| 범위: 전후 5
</Typography>
</Box>
</Box>
);
})()}
</Box>
)}
</DialogContent>
</Dialog>
);
}
/** 투자분석 매수/매도 신호 상세 — 페이지 내부에 정의하면 매 렌더마다 타입이 바뀌어 Dialog가 깜빡임 */
export const TradeSignalDetailDialog = memo(TradeSignalDetailDialogInner);
@@ -0,0 +1,369 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Checkbox,
IconButton,
Typography,
Divider,
Select,
MenuItem,
FormControl,
Slider,
Popover,
TextField,
} from '@mui/material';
import { Close as CloseIcon, Edit as EditIcon } from '@mui/icons-material';
import DraggablePaper from './DraggablePaper';
export interface VerticalLineSettings {
showLine: boolean; // 라인
extended: boolean; // 확장 (보조지표까지)
showLabel: boolean; // 타입 라벨 (날짜)
customText?: string; // 커스텀 텍스트 (상단 표시)
color: string;
lineStyle: 'solid' | 'dashed' | 'dotted';
lineWidth: number;
opacity?: number; // 투명도 (0-100)
}
interface VerticalLineDialogProps {
open: boolean;
onClose: () => void;
settings: VerticalLineSettings;
onSave: (settings: VerticalLineSettings) => void;
onDelete?: () => void;
}
export const defaultVerticalLineSettings: VerticalLineSettings = {
showLine: true,
extended: true, // ✅ 기본값: 보조지표까지 확장
showLabel: true,
customText: '',
color: '#2962ff',
lineStyle: 'solid',
lineWidth: 2,
opacity: 100,
};
const VerticalLineDialog: React.FC<VerticalLineDialogProps> = ({
open,
onClose,
settings,
onSave,
onDelete,
}) => {
const [localSettings, setLocalSettings] = useState<VerticalLineSettings>(settings);
const [colorPickerAnchor, setColorPickerAnchor] = useState<HTMLElement | null>(null);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
// 색상 팔레트
const colorPalette = [
['#000000', '#434651', '#666666', '#999999', '#B2B5BE', '#D1D4DC', '#E1E3EB', '#F0F3FA'],
['#F23645', '#FF9800', '#FDD835', '#26A69A', '#00BCD4', '#2196F3', '#3F51B5', '#9C27B0'],
['#880E4F', '#B71C1C', '#E65100', '#F57F17', '#827717', '#33691E', '#1B5E20', '#004D40'],
['#FF5252', '#FF6E40', '#FFAB40', '#FFD740', '#AEEA00', '#69F0AE', '#40C4FF', '#448AFF'],
['#FF80AB', '#FF4081', '#F50057', '#C51162', '#880E4F', '#4A148C', '#311B92', '#1A237E'],
['#EA80FC', '#E040FB', '#D500F9', '#AA00FF', '#6200EA', '#304FFE', '#2962FF', '#0091EA'],
['#8C9EFF', '#536DFE', '#3D5AFE', '#1976D2', '#0277BD', '#006064', '#004D40', '#1B5E20'],
];
const handleColorPickerOpen = (event: React.MouseEvent<HTMLElement>) => {
setColorPickerAnchor(event.currentTarget);
};
const handleColorPickerClose = () => {
setColorPickerAnchor(null);
};
const handleColorSelect = (color: string) => {
setLocalSettings({ ...localSettings, color });
};
const handleSave = () => {
onSave(localSettings);
onClose();
};
const handleReset = () => {
setLocalSettings(defaultVerticalLineSettings);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
hideBackdrop={true}
disableScrollLock={true}
PaperComponent={DraggablePaper}
sx={{
pointerEvents: 'none',
'& .MuiDialog-container': {
pointerEvents: 'none',
alignItems: 'flex-start',
},
}}
PaperProps={{
sx: {
bgcolor: 'background.paper',
borderRadius: 2,
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
},
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider />
<DialogContent sx={{ pt: 3, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* 라인 체크박스 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Checkbox
checked={localSettings.showLine}
onChange={(e) => setLocalSettings({ ...localSettings, showLine: e.target.checked })}
size="small"
/>
<Typography variant="body2"></Typography>
{/* 색상 선택 */}
{localSettings.showLine && (
<Box
onClick={handleColorPickerOpen}
sx={{
ml: 'auto',
width: 60,
height: 30,
bgcolor: localSettings.color,
borderRadius: 1,
border: '2px solid',
borderColor: 'primary.main',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
opacity: 0.8,
},
}}
>
<EditIcon sx={{ fontSize: 16, color: 'white', textShadow: '0 0 2px black' }} />
</Box>
)}
</Box>
{/* 선 스타일 */}
{localSettings.showLine && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, pl: 4 }}>
<Typography variant="caption" sx={{ minWidth: 60 }}>:</Typography>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={localSettings.lineStyle}
onChange={(e) => setLocalSettings({ ...localSettings, lineStyle: e.target.value as any })}
>
<MenuItem value="solid"></MenuItem>
<MenuItem value="dashed">- - -</MenuItem>
<MenuItem value="dotted">· · ·</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" sx={{ minWidth: 40, ml: 2 }}>:</Typography>
<FormControl size="small" sx={{ minWidth: 80 }}>
<Select
value={localSettings.lineWidth}
onChange={(e) => setLocalSettings({ ...localSettings, lineWidth: Number(e.target.value) })}
>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
<MenuItem value={3}>3</MenuItem>
<MenuItem value={4}>4</MenuItem>
</Select>
</FormControl>
</Box>
)}
<Divider />
{/* 확장 체크박스 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Checkbox
checked={localSettings.extended}
onChange={(e) => setLocalSettings({ ...localSettings, extended: e.target.checked })}
size="small"
/>
<Typography variant="body2"></Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{localSettings.extended ? '보조지표까지 표시' : '캔들차트만 표시'}
</Typography>
</Box>
{/* 타입 라벨 체크박스 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Checkbox
checked={localSettings.showLabel}
onChange={(e) => setLocalSettings({ ...localSettings, showLabel: e.target.checked })}
size="small"
/>
<Typography variant="body2"> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{localSettings.showLabel ? '날짜 표시' : '날짜 숨김'}
</Typography>
</Box>
<Divider />
{/* 커스텀 텍스트 입력 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
</Typography>
<TextField
fullWidth
size="small"
placeholder="텍스트를 입력하세요"
value={localSettings.customText || ''}
onChange={(e) => setLocalSettings({ ...localSettings, customText: e.target.value })}
helperText="입력한 텍스트가 수직선 상단에 표시됩니다"
/>
</Box>
</Box>
{/* 색상 팔레트 팝오버 */}
<Popover
open={Boolean(colorPickerAnchor)}
anchorEl={colorPickerAnchor}
onClose={handleColorPickerClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Box sx={{ p: 2, width: 240 }}>
{/* 색상 팔레트 */}
{colorPalette.map((row, rowIndex) => (
<Box key={rowIndex} sx={{ display: 'flex', gap: 0.5, mb: 0.5 }}>
{row.map((color, colIndex) => (
<Box
key={colIndex}
onClick={() => {
handleColorSelect(color);
handleColorPickerClose();
}}
sx={{
width: 24,
height: 24,
bgcolor: color,
cursor: 'pointer',
border: '1px solid rgba(0,0,0,0.1)',
'&:hover': {
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.3)',
},
}}
/>
))}
</Box>
))}
{/* 투명도 슬라이더 */}
<Box sx={{ mt: 2 }}>
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={localSettings.opacity || 100}
onChange={(e, value) => {
setLocalSettings({ ...localSettings, opacity: value as number });
}}
min={0}
max={100}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
sx={{ width: '100%' }}
/>
</Box>
{/* 커스텀 색상 입력 */}
<Box sx={{ mt: 2, display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption"> :</Typography>
<input
type="text"
placeholder="#000000"
value={localSettings.color}
onChange={(e) => {
const color = e.target.value;
if (/^#[0-9A-F]{6}$/i.test(color)) {
handleColorSelect(color);
}
}}
style={{
flex: 1,
padding: '4px 8px',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px',
}}
/>
</Box>
</Box>
</Popover>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2, gap: 1 }}>
{onDelete && (
<Button
onClick={() => {
if (window.confirm('이 수직선을 삭제하시겠습니까?')) {
onDelete();
onClose();
}
}}
variant="outlined"
color="error"
>
</Button>
)}
<Button onClick={handleReset} variant="outlined" color="secondary">
</Button>
<Box sx={{ flex: 1 }} />
<Button onClick={onClose} variant="outlined">
</Button>
<Button onClick={handleSave} variant="contained" color="primary">
</Button>
</DialogActions>
</Dialog>
);
};
export default VerticalLineDialog;
@@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
DialogTitle,
DialogContent,
DialogActions,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Typography,
} from '@mui/material';
import {
addCondition,
updateCondition,
AlertConditionDto,
IndicatorType,
ConditionType,
} from '../../services/alertApi';
interface AlertConditionEditorProps {
alertId: number;
condition?: AlertConditionDto | null;
onSave: () => void;
onCancel: () => void;
}
// 지표 옵션
const INDICATOR_OPTIONS: { value: IndicatorType; label: string }[] = [
{ value: 'MACD', label: 'MACD' },
{ value: 'RSI', label: 'RSI' },
{ value: 'STOCHASTIC', label: 'Stochastic' },
{ value: 'CCI', label: 'CCI' },
{ value: 'VOLUME', label: '거래량' },
{ value: 'OBV', label: 'OBV' },
{ value: 'WILLIAMS_R', label: 'Williams %R' },
{ value: 'VR', label: 'VR' },
];
// 조건 타입 옵션
const CONDITION_OPTIONS: { value: ConditionType; label: string }[] = [
{ value: 'CROSS_UP', label: '상향 돌파' },
{ value: 'CROSS_DOWN', label: '하향 돌파' },
{ value: 'ABOVE', label: '초과' },
{ value: 'BELOW', label: '미만' },
{ value: 'BETWEEN', label: '범위 내' },
{ value: 'ZERO_CROSS_UP', label: '0선 상향 돌파' },
{ value: 'ZERO_CROSS_DOWN', label: '0선 하향 돌파' },
{ value: 'SIGNAL_CROSS_UP', label: '시그널선 상향 돌파' },
{ value: 'SIGNAL_CROSS_DOWN', label: '시그널선 하향 돌파' },
];
/**
* 알림 조건 에디터
*/
const AlertConditionEditor: React.FC<AlertConditionEditorProps> = ({
alertId,
condition,
onSave,
onCancel,
}) => {
const [formData, setFormData] = useState<AlertConditionDto>({
indicatorType: 'RSI',
conditionType: 'ABOVE',
targetValue: undefined,
secondaryValue: undefined,
period: undefined,
enabled: true,
orderIndex: 0,
description: '',
logicalOperator: 'AND',
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (condition) {
setFormData(condition);
}
}, [condition]);
const handleChange = (field: keyof AlertConditionDto, value: any) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
};
const needsTargetValue = (): boolean => {
const { conditionType } = formData;
return ['ABOVE', 'BELOW', 'EQUAL'].includes(conditionType);
};
const needsSecondaryValue = (): boolean => {
return formData.conditionType === 'BETWEEN';
};
const needsPeriod = (): boolean => {
const { indicatorType } = formData;
return ['RSI', 'STOCHASTIC', 'CCI', 'WILLIAMS_R', 'MACD', 'OBV', 'VR'].includes(indicatorType);
};
const handleSubmit = async () => {
try {
setSaving(true);
if (condition?.id) {
// 수정
await updateCondition(condition.id, formData);
} else {
// 추가
await addCondition(alertId, formData);
}
onSave();
} catch (error) {
console.error('조건 저장 실패:', error);
alert('조건 저장에 실패했습니다.');
} finally {
setSaving(false);
}
};
return (
<>
<DialogTitle>
{condition ? '조건 수정' : '조건 추가'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
{/* 지표 선택 */}
<Grid item xs={12} sm={6}>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select
value={formData.indicatorType}
label="지표"
onChange={(e) => handleChange('indicatorType', e.target.value as IndicatorType)}
>
{INDICATOR_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 조건 선택 */}
<Grid item xs={12} sm={6}>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select
value={formData.conditionType}
label="조건"
onChange={(e) => handleChange('conditionType', e.target.value as ConditionType)}
>
{CONDITION_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 기준값 */}
{needsTargetValue() && (
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label="기준값"
type="number"
value={formData.targetValue || ''}
onChange={(e) => handleChange('targetValue', parseFloat(e.target.value) || undefined)}
helperText="예: RSI 30, 70"
/>
</Grid>
)}
{/* 보조값 (BETWEEN) */}
{needsSecondaryValue() && (
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label="상한값"
type="number"
value={formData.secondaryValue || ''}
onChange={(e) => handleChange('secondaryValue', parseFloat(e.target.value) || undefined)}
helperText="범위의 상한값"
/>
</Grid>
)}
{/* 기간 */}
{needsPeriod() && (
<Grid item xs={12} sm={6}>
<TextField
fullWidth
size="small"
label="기간"
type="number"
value={formData.period || ''}
onChange={(e) => handleChange('period', parseInt(e.target.value) || undefined)}
helperText="예: RSI 14, Stochastic 12"
/>
</Grid>
)}
{/* 설명 */}
<Grid item xs={12}>
<TextField
fullWidth
size="small"
label="설명 (선택)"
multiline
rows={2}
value={formData.description || ''}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="조건에 대한 설명을 입력하세요"
/>
</Grid>
{/* 논리 연산자 */}
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel> </InputLabel>
<Select
value={formData.logicalOperator || 'AND'}
label="다음 조건과의 관계"
onChange={(e) => handleChange('logicalOperator', e.target.value)}
>
<MenuItem value="AND">AND ()</MenuItem>
<MenuItem value="OR">OR ()</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
.
</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={saving}
>
{saving ? '저장 중...' : '저장'}
</Button>
</DialogActions>
</>
);
};
export default AlertConditionEditor;
@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Card,
CardContent,
CardActions,
Typography,
IconButton,
Chip,
Stack,
Dialog,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
PlayArrow as PlayIcon,
Pause as PauseIcon,
} from '@mui/icons-material';
import {
getConditions,
deleteCondition,
toggleCondition,
AlertConditionDto,
} from '../../services/alertApi';
import AlertConditionEditor from './AlertConditionEditor';
interface AlertConditionsPanelProps {
alertId?: number;
symbol?: string;
koreanName?: string;
}
/**
* 알림 조건 패널
*/
const AlertConditionsPanel: React.FC<AlertConditionsPanelProps> = ({
alertId,
symbol,
koreanName,
}) => {
const [conditions, setConditions] = useState<AlertConditionDto[]>([]);
const [editorOpen, setEditorOpen] = useState(false);
const [editingCondition, setEditingCondition] = useState<AlertConditionDto | null>(null);
useEffect(() => {
if (alertId) {
loadConditions();
} else {
setConditions([]);
}
}, [alertId]);
const loadConditions = async () => {
if (!alertId) return;
try {
const data = await getConditions(alertId);
setConditions(data);
} catch (error) {
console.error('조건 목록 로드 실패:', error);
}
};
const handleAddCondition = () => {
setEditingCondition(null);
setEditorOpen(true);
};
const handleEditCondition = (condition: AlertConditionDto) => {
setEditingCondition(condition);
setEditorOpen(true);
};
const handleDeleteCondition = async (conditionId: number) => {
if (!window.confirm('이 조건을 삭제하시겠습니까?')) return;
try {
await deleteCondition(conditionId);
setConditions(prev => prev.filter(c => c.id !== conditionId));
} catch (error) {
console.error('조건 삭제 실패:', error);
alert('조건 삭제에 실패했습니다.');
}
};
const handleToggleCondition = async (condition: AlertConditionDto) => {
if (!condition.id) return;
try {
const updated = await toggleCondition(condition.id, !condition.enabled);
setConditions(prev => prev.map(c => c.id === updated.id ? updated : c));
} catch (error) {
console.error('조건 토글 실패:', error);
alert('조건 상태 변경에 실패했습니다.');
}
};
const handleSaveCondition = () => {
setEditorOpen(false);
setEditingCondition(null);
loadConditions();
};
const handleCloseEditor = () => {
setEditorOpen(false);
setEditingCondition(null);
};
const getConditionDescription = (condition: AlertConditionDto): string => {
let desc = `${condition.indicatorType} ${condition.conditionType}`;
if (condition.targetValue !== undefined) {
desc += ` ${condition.targetValue}`;
}
if (condition.period) {
desc += ` (${condition.period}기간)`;
}
return desc;
};
// 알림이 선택되지 않은 경우
if (!alertId) {
return (
<Box sx={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="body2" color="text.secondary" align="center">
<br />
.
</Typography>
</Box>
);
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6">
{koreanName || symbol}
</Typography>
<Typography variant="caption" color="text.secondary">
{symbol}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddCondition}
size="small"
>
</Button>
</Box>
{/* Conditions List */}
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
{conditions.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
.
</Typography>
</Box>
) : (
<Stack spacing={2}>
{conditions.map((condition) => (
<Card key={condition.id} variant="outlined">
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" fontWeight="medium">
{getConditionDescription(condition)}
</Typography>
<Chip
label={condition.enabled ? '활성' : '비활성'}
size="small"
color={condition.enabled ? 'success' : 'default'}
/>
</Box>
{condition.description && (
<Typography variant="caption" color="text.secondary">
{condition.description}
</Typography>
)}
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', pt: 0 }}>
<IconButton
size="small"
onClick={() => handleToggleCondition(condition)}
color={condition.enabled ? 'primary' : 'default'}
>
{condition.enabled ? <PauseIcon /> : <PlayIcon />}
</IconButton>
<IconButton
size="small"
onClick={() => handleEditCondition(condition)}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => condition.id && handleDeleteCondition(condition.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</CardActions>
</Card>
))}
</Stack>
)}
</Box>
{/* Condition Editor Dialog */}
<Dialog
open={editorOpen}
onClose={handleCloseEditor}
maxWidth="md"
fullWidth
>
<AlertConditionEditor
alertId={alertId}
condition={editingCondition}
onSave={handleSaveCondition}
onCancel={handleCloseEditor}
/>
</Dialog>
</Box>
);
};
export default AlertConditionsPanel;
@@ -0,0 +1,464 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Button,
Box,
Typography,
Grid,
Paper,
Chip,
Divider,
CircularProgress,
Table,
TableBody,
TableCell,
TableRow,
Alert,
} from '@mui/material';
import {
Close as CloseIcon,
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
ShowChart as ShowChartIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { getNotificationDetail, AlertNotificationDetailDto, markAsRead } from '../../services/alertApi';
import { format } from 'date-fns';
interface AlertDetailDialogProps {
open: boolean;
notificationId: number | null;
onClose: () => void;
onMarkAsRead?: () => void;
}
/**
* 알림 상세보기 다이얼로그
*/
const AlertDetailDialog: React.FC<AlertDetailDialogProps> = ({
open,
notificationId,
onClose,
onMarkAsRead,
}) => {
const navigate = useNavigate();
const [detail, setDetail] = useState<AlertNotificationDetailDto | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open && notificationId) {
loadDetail();
}
}, [open, notificationId]);
const loadDetail = async () => {
if (!notificationId) return;
try {
setLoading(true);
setError(null);
const data = await getNotificationDetail(notificationId);
setDetail(data);
// 읽음 처리
if (!data.isRead) {
await markAsRead(notificationId);
if (onMarkAsRead) {
onMarkAsRead();
}
}
} catch (err: any) {
console.error('알림 상세 정보 로드 실패:', err);
setError('알림 상세 정보를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setDetail(null);
setError(null);
onClose();
};
const handleShowChart = () => {
if (detail?.symbol) {
// 차트 분석 화면으로 이동하면서 종목 정보 전달
navigate('/candlestick-test', {
state: {
symbol: detail.symbol,
koreanName: detail.koreanName,
englishName: detail.englishName
}
});
handleClose();
}
};
const formatNumber = (value?: number, decimals: number = 2) => {
if (value === undefined || value === null) return '-';
return value.toFixed(decimals);
};
const formatPrice = (value?: number) => {
if (value === undefined || value === null) return '-';
return value.toLocaleString('ko-KR');
};
const formatPercent = (value?: number) => {
if (value === undefined || value === null) return '-';
const sign = value >= 0 ? '+' : '';
return `${sign}${(value * 100).toFixed(2)}%`;
};
/** 시간봉 문자열을 한글 라벨로 변환 (1m→1분봉, 1h→1시간봉 등) */
const getTimeIntervalLabel = (timeInterval?: string) => {
if (!timeInterval) return '1시간봉';
const unit = timeInterval.replace(/[^0-9]/g, '');
const type = timeInterval.replace(/[0-9]/g, '').toLowerCase();
const num = unit ? parseInt(unit, 10) : 1;
if (type === 'm') return `${num}분봉`;
if (type === 'h') return `${num}시간봉`;
if (type === 'd') return '일봉';
if (type === 'w') return '주봉';
return '1시간봉';
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
minHeight: '60vh',
},
}}
>
<DialogTitle
sx={{
bgcolor: 'primary.main',
color: 'white',
py: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ color: 'white' }}>
{detail && `- ${detail.koreanName || detail.symbol}`}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{detail?.symbol && (
<Button
onClick={handleShowChart}
startIcon={<ShowChartIcon />}
variant="contained"
color="inherit"
sx={{
bgcolor: 'white',
color: 'primary.main',
'&:hover': {
bgcolor: 'grey.100',
}
}}
size="small"
>
</Button>
)}
<Button
onClick={handleClose}
variant="contained"
color="inherit"
sx={{
bgcolor: 'white',
color: 'primary.main',
'&:hover': {
bgcolor: 'grey.100',
}
}}
size="small"
>
</Button>
<Button
onClick={handleClose}
startIcon={<CloseIcon />}
variant="outlined"
sx={{
borderColor: 'white',
color: 'white',
'&:hover': {
borderColor: 'white',
bgcolor: 'rgba(255, 255, 255, 0.1)',
}
}}
size="small"
>
</Button>
</Box>
</Box>
</DialogTitle>
<DialogContent dividers>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 300 }}>
<CircularProgress />
</Box>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{!loading && !error && detail && (
<Box>
{/* 기본 정보 */}
<Paper elevation={0} sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Typography variant="h5" fontWeight="bold">
{detail.koreanName || detail.symbol}
</Typography>
<Chip
label={detail.symbol}
size="small"
variant="outlined"
/>
{detail.isRead ? (
<Chip label="읽음" size="small" color="default" />
) : (
<Chip label="새 알림" size="small" color="primary" />
)}
</Box>
<Typography variant="body2" color="text.secondary">
{detail.englishName}
</Typography>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">
{detail.triggeredAt
? format(new Date(detail.triggeredAt), 'yyyy-MM-dd HH:mm:ss')
: '-'}
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">
{detail.notifiedAt
? format(new Date(detail.notifiedAt), 'yyyy-MM-dd HH:mm:ss')
: '-'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Alert severity="success" sx={{ mt: 0.5 }}>
<Typography
variant="body1"
sx={{
whiteSpace: 'pre-line',
'& > div': { mt: 1 }
}}
>
{detail.message}
</Typography>
</Alert>
</Grid>
{detail.conditionDescription && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-line' }}>
{detail.conditionDescription}
</Typography>
</Grid>
)}
</Grid>
</Paper>
{/* 현재 시세 정보 */}
{detail.currentPrice && (
<Paper elevation={0} sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>
({getTimeIntervalLabel(detail.timeInterval)} )
</Typography>
<Grid container spacing={2}>
<Grid item xs={6} md={4}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="h6">{formatPrice(detail.currentPrice)} </Typography>
</Grid>
<Grid item xs={6} md={4}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{detail.changeRate && detail.changeRate >= 0 ? (
<TrendingUpIcon color="error" fontSize="small" />
) : (
<TrendingDownIcon color="primary" fontSize="small" />
)}
<Typography
variant="h6"
color={detail.changeRate && detail.changeRate >= 0 ? 'error' : 'primary'}
>
{formatPercent(detail.changeRate)}
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">{formatPrice(detail.volume)}</Typography>
</Grid>
</Grid>
</Paper>
)}
{/* 기술적 지표 */}
{detail.indicatorData && (
<Paper elevation={0} sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>
({getTimeIntervalLabel(detail.timeInterval)} )
</Typography>
<Table size="small">
<TableBody>
{detail.indicatorData.rsi !== undefined && (
<TableRow>
<TableCell><strong>RSI</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.rsi)}</TableCell>
<TableCell>
{detail.indicatorData.rsi > 70 ? (
<Chip label="과매수" size="small" color="error" />
) : detail.indicatorData.rsi < 30 ? (
<Chip label="과매도" size="small" color="primary" />
) : (
<Chip label="중립" size="small" color="default" />
)}
</TableCell>
</TableRow>
)}
{detail.indicatorData.macdLine !== undefined && (
<>
<TableRow>
<TableCell><strong>MACD</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.macdLine)}</TableCell>
<TableCell></TableCell>
</TableRow>
{detail.indicatorData.signalLine !== undefined && (
<TableRow>
<TableCell><strong>Signal</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.signalLine)}</TableCell>
<TableCell></TableCell>
</TableRow>
)}
{detail.indicatorData.histogram !== undefined && (
<TableRow>
<TableCell><strong>Histogram</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.histogram)}</TableCell>
<TableCell>
{detail.indicatorData.histogram > 0 ? (
<Chip label="상승" size="small" color="error" />
) : (
<Chip label="하락" size="small" color="primary" />
)}
</TableCell>
</TableRow>
)}
</>
)}
{detail.indicatorData.stochK !== undefined && (
<TableRow>
<TableCell><strong>Stochastic K</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.stochK)}</TableCell>
<TableCell></TableCell>
</TableRow>
)}
{detail.indicatorData.stochD !== undefined && (
<TableRow>
<TableCell><strong>Stochastic D</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.stochD)}</TableCell>
<TableCell></TableCell>
</TableRow>
)}
{detail.indicatorData.cci !== undefined && (
<TableRow>
<TableCell><strong>CCI</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.cci)}</TableCell>
<TableCell></TableCell>
</TableRow>
)}
{detail.indicatorData.adx !== undefined && (
<TableRow>
<TableCell><strong>ADX</strong></TableCell>
<TableCell align="right">{formatNumber(detail.indicatorData.adx)}</TableCell>
<TableCell>
{detail.indicatorData.adx > 25 ? (
<Chip label="강한 추세" size="small" color="error" />
) : (
<Chip label="약한 추세" size="small" color="default" />
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Paper>
)}
{/* 충족된 조건 목록 */}
{detail.satisfiedConditions && detail.satisfiedConditions.length > 0 && (
<Paper elevation={0} sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
</Typography>
{detail.satisfiedConditions.map((condition, index) => (
<Box key={index} sx={{ mb: 1 }}>
<Chip
label={`${condition.indicatorType} ${condition.conditionType}`}
size="small"
color="success"
sx={{ mr: 1 }}
/>
{condition.description && (
<Typography variant="body2" component="span" color="text.secondary">
{condition.description}
</Typography>
)}
</Box>
))}
</Paper>
)}
</Box>
)}
</DialogContent>
</Dialog>
);
};
export default AlertDetailDialog;
@@ -0,0 +1,521 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
List,
ListItem,
ListItemText,
IconButton,
Typography,
Divider,
CircularProgress,
Alert,
Chip,
Tooltip,
} from '@mui/material';
import {
Info as InfoIcon,
Delete as DeleteIcon,
FiberManualRecord as UnreadIcon,
ExpandMore,
ExpandLess,
FormatListBulleted as AlertListIcon,
OpenInNew as OpenInNewIcon,
Analytics as AnalyticsIcon,
} from '@mui/icons-material';
import { getNotifications, deleteNotification, getNotificationDetail, AlertNotificationDto } from '../../services/alertApi';
import type { BacktestResult, TradeRecord } from '../../services/backtestApi';
import { TradeSignalDetailDialog } from '../TradeSignalDetailDialog';
import { format } from 'date-fns';
import AlertDetailDialog from './AlertDetailDialog';
import {
buildBacktestResultFromAlertDetail,
buildTradeRecordFromAlertDetail,
} from '../../utils/alertTradeRecordFromDetail';
import { useAlertNotification } from '../../contexts/AlertNotificationContext';
import { useNavigation } from '../../contexts/NavigationContext';
import upbitApiService from '../../services/upbitApi';
import {
extractStrategyNames,
extractStockStrategyPairs,
resolveStockStrategyPairRow,
resolveSymbols,
} from '../../utils/alertMessageParser';
import {
alertMarkerTimeIsoFromDto,
inferAlertSignalFromNotification,
} from '../../utils/alertSignalInference';
import { getUpbitWebExchangeUrl } from '../../utils/upbitWebUrls';
interface AlertNotificationListProps {
maxItems?: number;
onDoubleClickNotification?: (notification: AlertNotificationDto) => void;
}
/** AlertSnackbar와 동일: 통합/단일 알림에 대해 타이틀(전략명)과 종목·전략 쌍 */
function getTitleAndPairs(
notification: AlertNotificationDto
): { title: string; pairs: Array<{ koreanName: string; strategyName: string; symbol?: string }> } {
if (!notification) return { title: '', pairs: [] };
const msg = notification.message || '';
if (msg.includes('알림 조건 충족') && msg.includes('개)')) {
const strategyNames = extractStrategyNames(msg);
const pairs = extractStockStrategyPairs(msg) ?? [];
const title =
strategyNames && strategyNames.length > 0
? strategyNames.length <= 3
? strategyNames.join(', ')
: `${strategyNames.slice(0, 2).join(', ')}${strategyNames.length - 2}개 전략`
: '🎯 통합 알림';
return { title, pairs: pairs.map((p) => ({ ...p, symbol: undefined })) };
}
if (notification.koreanName) {
const firstLine = msg.split('\n')[0]?.trim() || '';
const strategyName = firstLine.replace(/^[\s🧪🎯✅]*/, '').trim() || '알림';
const title = strategyName;
const pairs = [{ koreanName: notification.koreanName, strategyName, symbol: notification.symbol }];
return { title, pairs };
}
return { title: notification.symbol || '알림', pairs: [] };
}
/**
* 알림 목록 컴포넌트 (알림 팝업과 동일한 종목 펼침 UI)
*/
const AlertNotificationList: React.FC<AlertNotificationListProps> = ({ maxItems = 20, onDoubleClickNotification }) => {
const { latestNotification } = useAlertNotification();
const { navigateToPage } = useNavigation();
const [cryptoMap, setCryptoMap] = useState<Map<string, string>>(new Map());
const [notifications, setNotifications] = useState<AlertNotificationDto[]>([]);
const [loading, setLoading] = useState(false);
const [initialFetchDone, setInitialFetchDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [selectedNotificationId, setSelectedNotificationId] = useState<number | null>(null);
const [expandedById, setExpandedById] = useState<Record<number, boolean>>({});
const [signalDetailOpen, setSignalDetailOpen] = useState(false);
const [signalDetailTrade, setSignalDetailTrade] = useState<TradeRecord | null>(null);
const [signalDetailBacktestResult, setSignalDetailBacktestResult] = useState<BacktestResult | null>(null);
useEffect(() => {
upbitApiService
.getActiveCryptos()
.then((cryptos) => {
const map = new Map<string, string>();
cryptos.forEach((c) => map.set(c.koreanName, c.symbol));
setCryptoMap(map);
})
.catch(() => {});
}, []);
const fetchList = useCallback(
async (showSpinner: boolean) => {
try {
if (showSpinner) {
setLoading(true);
setError(null);
}
const data = await getNotifications(undefined, 0, maxItems);
setNotifications(data.content);
} catch (err: unknown) {
console.error('알림 목록 로드 실패:', err);
if (showSpinner) {
setError('알림 목록을 불러오는데 실패했습니다.');
}
} finally {
if (showSpinner) {
setLoading(false);
}
setInitialFetchDone(true);
}
},
[maxItems]
);
useEffect(() => {
void fetchList(true);
}, [fetchList]);
useEffect(() => {
if (!initialFetchDone || latestNotification?.id == null) return;
setNotifications((prev) => {
if (prev.some((n) => n.id === latestNotification.id)) return prev;
return [latestNotification, ...prev].slice(0, maxItems);
});
void fetchList(false);
// eslint-disable-next-line react-hooks/exhaustive-deps -- id 기준 동기화
}, [latestNotification?.id, initialFetchDone, maxItems, fetchList]);
const handleShowDetail = (notificationId: number) => {
setSelectedNotificationId(notificationId);
setDetailDialogOpen(true);
};
const handleCloseDetail = () => {
setDetailDialogOpen(false);
setSelectedNotificationId(null);
void fetchList(true);
};
const handleDelete = async (notificationId: number, event: React.MouseEvent) => {
event.stopPropagation();
if (!window.confirm('이 알림을 삭제하시겠습니까?')) {
return;
}
try {
await deleteNotification(notificationId);
setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
setExpandedById((prev) => {
const next = { ...prev };
delete next[notificationId];
return next;
});
} catch (err: unknown) {
console.error('알림 삭제 실패:', err);
alert('알림 삭제에 실패했습니다.');
}
};
const handleDoubleClick = (notification: AlertNotificationDto) => {
if (onDoubleClickNotification && notification.symbol) {
onDoubleClickNotification(notification);
} else if (notification.id) {
handleShowDetail(notification.id);
}
};
const handleDetailButtonClick = (notificationId: number, event: React.MouseEvent) => {
event.stopPropagation();
if (notificationId) {
handleShowDetail(notificationId);
}
};
const handleOpenSignalDetail = async (
e: React.MouseEvent,
notification: AlertNotificationDto,
pair: { koreanName: string; strategyName: string; navSymbol?: string; symbol?: string }
) => {
e.stopPropagation();
if (!notification.id) return;
try {
const rowSym = (pair.navSymbol ?? pair.symbol ?? '').trim();
const detail = await getNotificationDetail(
notification.id,
rowSym ? { symbol: rowSym } : undefined
);
const inferred =
inferAlertSignalFromNotification(notification, pair.koreanName) ?? 'BUY';
const strategy =
pair.strategyName?.trim() ||
(notification.conditionDescription || '').replace(/^전략:\s*/, '').trim() ||
undefined;
const trade = buildTradeRecordFromAlertDetail(detail, inferred, strategy);
const backtestResult = buildBacktestResultFromAlertDetail(detail, trade);
setSignalDetailTrade(trade);
setSignalDetailBacktestResult(backtestResult);
setSignalDetailOpen(true);
} catch (err) {
console.error(err);
alert('신호 상세를 불러오지 못했습니다.');
}
};
const handleStockClick = (notification: AlertNotificationDto, koreanName: string, symbol?: string) => {
const timeIso = alertMarkerTimeIsoFromDto(notification);
const inferred = inferAlertSignalFromNotification(notification, koreanName);
const extras: { alertSignal?: 'BUY' | 'SELL'; alertMarkerTime?: string } = {};
if (inferred && timeIso) {
extras.alertSignal = inferred;
extras.alertMarkerTime = timeIso;
}
if (symbol) {
navigateToPage('alert-list', { symbol, koreanName, ...extras });
return;
}
const resolved = resolveSymbols([koreanName], cryptoMap);
if (resolved.length > 0) {
navigateToPage('alert-list', {
symbol: resolved[0].symbol,
koreanName: resolved[0].koreanName,
...extras,
});
}
};
const toggleExpand = (id: number, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedById((prev) => ({ ...prev, [id]: !prev[id] }));
};
if (loading && notifications.length === 0) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ m: 2 }}>
{error}
</Alert>
);
}
if (notifications.length === 0) {
return (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
.
</Typography>
</Box>
);
}
return (
<Box>
<List disablePadding>
{notifications.map((notification, index) => {
const { title, pairs } = getTitleAndPairs(notification);
const hasStockList = pairs.length > 0;
const displayPairs = pairs.map((p) => resolveStockStrategyPairRow(p, cryptoMap));
const firstName = displayPairs[0]?.koreanName ?? notification.koreanName ?? '';
const otherCount = Math.max(0, displayPairs.length - 1);
const stockSummaryLine =
firstName !== '' ? `${firstName}${otherCount}종목` : '종목 정보 없음';
const expanded = notification.id != null && !!expandedById[notification.id];
return (
<React.Fragment key={notification.id ?? `n-${index}`}>
{index > 0 && <Divider />}
<ListItem
alignItems="flex-start"
onDoubleClick={() => handleDoubleClick(notification)}
sx={{
flexDirection: 'column',
alignItems: 'stretch',
py: 1.25,
px: 1.5,
bgcolor: notification.isRead ? 'transparent' : 'action.hover',
'&:hover': { bgcolor: 'action.selected' },
cursor: 'pointer',
}}
>
<Box sx={{ display: 'flex', width: '100%', gap: 1 }}>
<Box sx={{ pt: 0.25, width: 20, flexShrink: 0 }}>
{!notification.isRead && <UnreadIcon color="primary" fontSize="small" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mb: 0.5,
width: '100%',
}}
>
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 1,
}}
>
<Typography variant="subtitle1" fontWeight={notification.isRead ? 'normal' : 'bold'}>
{title}
</Typography>
{!notification.isRead && <Chip label="새 알림" size="small" color="primary" />}
</Box>
<Box
sx={{
display: 'flex',
flexShrink: 0,
alignItems: 'center',
gap: 0.25,
mt: -0.25,
}}
>
<IconButton
size="small"
onClick={(e) => notification.id && handleDetailButtonClick(notification.id, e)}
title="상세보기"
color="primary"
>
<InfoIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={(e) => notification.id && handleDelete(notification.id, e)}
title="삭제"
color="error"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mb: 0.5 }}>
{hasStockList && notification.id != null && (
<IconButton
size="small"
onClick={(e) => toggleExpand(notification.id!, e)}
aria-expanded={expanded}
aria-label={expanded ? '종목 목록 접기' : '종목 목록 펼치기'}
sx={{ p: 0.5, mr: 0.25 }}
>
{expanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</IconButton>
)}
<Typography variant="body2" color="text.secondary">
{stockSummaryLine}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{notification.triggeredAt ? format(new Date(notification.triggeredAt), 'yyyy-MM-dd HH:mm:ss') : '-'}
</Typography>
{expanded && hasStockList && (
<List
dense
disablePadding
sx={{
mt: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
maxHeight: 220,
overflowY: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
{displayPairs.map((pair, i) => {
const marketSym = pair.navSymbol ?? pair.symbol;
const upbitUrl = getUpbitWebExchangeUrl(marketSym, notification.timeInterval);
return (
<ListItem
key={`${pair.navSymbol ?? pair.displaySymbol}-${pair.koreanName}-${i}`}
sx={{
py: 0.5,
px: 1.5,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
borderBottom: i < displayPairs.length - 1 ? '1px solid' : 'none',
borderColor: 'divider',
}}
onClick={() => handleStockClick(notification, pair.koreanName, pair.navSymbol ?? pair.symbol)}
secondaryAction={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mr: -0.5 }}
onClick={(e) => e.stopPropagation()}
>
<Tooltip
title="알림목록으로 이동"
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
>
<IconButton
size="small"
edge="end"
onClick={(e) => {
e.stopPropagation();
handleStockClick(notification, pair.koreanName, pair.navSymbol ?? pair.symbol);
}}
aria-label="알림목록으로 이동"
>
<AlertListIcon fontSize="small" />
</IconButton>
</Tooltip>
{upbitUrl ? (
<Tooltip
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
title={
notification.timeInterval
? `업비트 웹 차트 (시간봉 ${notification.timeInterval})`
: '업비트 웹에서 차트 보기'
}
>
<IconButton
size="small"
component="a"
href={upbitUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label="업비트에서 차트 열기"
sx={{ color: 'success.main' }}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null}
<Tooltip
title="알림 발생 시점 기준 전략·신호 상세 (해당 행 종목)"
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
>
<span>
<IconButton
size="small"
onClick={(e) => handleOpenSignalDetail(e, notification, pair)}
aria-label="전략 신호 상세"
color="primary"
>
<AnalyticsIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
}
>
<ListItemText
primary={pair.koreanName}
secondary={pair.displaySymbol}
primaryTypographyProps={{ fontSize: '13px', fontWeight: 500 }}
secondaryTypographyProps={{ fontSize: '11px', color: 'text.secondary' }}
/>
</ListItem>
);
})}
</List>
)}
</Box>
</Box>
</ListItem>
</React.Fragment>
);
})}
</List>
<AlertDetailDialog
open={detailDialogOpen}
notificationId={selectedNotificationId}
onClose={handleCloseDetail}
onMarkAsRead={() => void fetchList(true)}
/>
<TradeSignalDetailDialog
open={signalDetailOpen}
trade={signalDetailTrade}
backtestResult={signalDetailBacktestResult}
onClose={() => {
setSignalDetailOpen(false);
setSignalDetailTrade(null);
setSignalDetailBacktestResult(null);
}}
/>
</Box>
);
};
export default AlertNotificationList;
@@ -0,0 +1,817 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Draggable from 'react-draggable';
import {
Box,
Chip,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
useTheme,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import ShowChartIcon from '@mui/icons-material/ShowChart';
import {
evaluateAlertStrategy,
getAllAlerts,
getGlobalConditions,
type CryptoAlertDto,
} from '../../services/alertApi';
import { getEnabledAlertStrategies } from '../../services/alertStrategyApi';
import { getStrategyRule } from '../../services/strategyRuleApi';
import {
MultiChartMarketDataProvider,
useMultiChartMarketData,
type CandleUpdate,
type ConnectionStatus,
} from '../../contexts/MultiChartMarketDataContext';
import { chartIntervalToWsTimeframe } from '../../utils/investmentChart/wsCandleHelpers';
import {
BASE_PRICE_COLUMNS,
buildFlatCompareMap,
buildFlatDisplayMap,
buildStrategyMetricColumns,
collectIndicatorTypesFromAlertsAndGlobal,
collectIndicatorTypesFromStrategyRule,
type StrategyMetricColumn,
} from '../../utils/alert/alertRealtimeStrategyColumns';
const FLASH_LIME = '#CCFF00';
const FLASH_MS = 650;
const DEFAULT_PANEL_W = 960;
const DEFAULT_PANEL_H = 520;
const MIN_PANEL_W = 520;
const MIN_PANEL_H = 280;
const MAX_INSET = 8;
const EVAL_DEBOUNCE_MS = 550;
const MIN_LIVE_CANDLES_FOR_EVAL = 60;
function msFromCandleTimestamp(ts: number): number {
if (!Number.isFinite(ts)) return Date.now();
return ts > 1e12 ? ts : ts * 1000;
}
/** 백엔드 `UpbitCandleDto.candleDateTimeKst` 파싱 형식과 동일 */
function formatKstYmdHms(ms: number): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(new Date(ms));
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? '00';
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
}
function candleUpdatesToLiveUpbitMaps(market: string, candles: CandleUpdate[]): Record<string, unknown>[] {
return candles.map((c) => {
const ms = msFromCandleTimestamp(c.timestamp);
const kst = formatKstYmdHms(ms);
return {
market,
candle_date_time_kst: kst,
candle_date_time_utc: new Date(ms).toISOString().slice(0, 19),
opening_price: c.open,
high_price: c.high,
low_price: c.low,
trade_price: c.close,
timestamp: ms,
candle_acc_trade_price: null,
candle_acc_trade_volume: c.volume,
};
});
}
function clamp(x: number, y: number, w: number, h: number) {
if (typeof window === 'undefined') return { x, y };
const m = 8;
const maxX = Math.max(m, window.innerWidth - w - m);
const maxY = Math.max(m, window.innerHeight - h - m);
return { x: Math.min(Math.max(m, x), maxX), y: Math.min(Math.max(m, y), maxY) };
}
function nearAnchor(anchor: HTMLElement, w: number, h: number) {
const r = anchor.getBoundingClientRect();
return clamp(r.right - w, r.bottom + 8, w, h);
}
function statusColor(s: ConnectionStatus): 'default' | 'primary' | 'success' | 'error' | 'warning' {
if (s === 'connected') return 'success';
if (s === 'connecting') return 'warning';
if (s === 'error') return 'error';
return 'default';
}
function statusLabel(s: ConnectionStatus): string {
if (s === 'connected') return '연결';
if (s === 'connecting') return '연결중';
if (s === 'error') return '오류';
return '끊김';
}
interface StrategyGridRowProps {
alert: CryptoAlertDto;
timeframe: string;
columns: StrategyMetricColumn[];
/** null이면 전략 평가·행 하이라이트 없음 */
strategyRuleId: number | null;
/** 스케줄·평가 API용 봉 간격 (예: 3m) */
scheduleInterval: string;
}
const StrategyGridRow: React.FC<StrategyGridRowProps> = ({
alert,
timeframe,
columns,
strategyRuleId,
scheduleInterval,
}) => {
const multi = useMultiChartMarketData();
const [candle, setCandle] = useState<CandleUpdate | null>(null);
const [rawInd, setRawInd] = useState<Record<string, number>>({});
const conn: ConnectionStatus = useMemo(() => {
if (!multi) return 'disconnected';
const item = multi.connectionList.find((c) => c.market === alert.symbol && c.timeframe === timeframe);
return item?.status ?? 'disconnected';
}, [multi?.connectionList, alert.symbol, timeframe]);
const prevFlatRef = useRef<Record<string, string>>({});
const [flash, setFlash] = useState<Set<string>>(() => new Set());
const [conditionMet, setConditionMet] = useState(false);
const candlesRef = useRef<CandleUpdate[]>([]);
const evalTimerRef = useRef<number | null>(null);
const evalSeqRef = useRef(0);
const flashTimeoutRef = useRef<number | null>(null);
const connRef = useRef(conn);
connRef.current = conn;
const runEvaluate = useCallback(async () => {
if (strategyRuleId == null) {
setConditionMet(false);
return;
}
const buf = candlesRef.current;
if (buf.length < MIN_LIVE_CANDLES_FOR_EVAL) {
setConditionMet(false);
return;
}
const seq = ++evalSeqRef.current;
try {
const liveCandles = candleUpdatesToLiveUpbitMaps(alert.symbol, buf);
const res = await evaluateAlertStrategy({
symbol: alert.symbol,
strategyRuleId,
timeInterval: scheduleInterval,
liveCandles,
useLiveCandleTail: true,
});
if (seq === evalSeqRef.current) setConditionMet(!!res.conditionMet);
} catch {
if (seq === evalSeqRef.current) setConditionMet(false);
}
}, [alert.symbol, scheduleInterval, strategyRuleId]);
useEffect(() => {
if (conn !== 'connected') {
if (flashTimeoutRef.current != null) {
window.clearTimeout(flashTimeoutRef.current);
flashTimeoutRef.current = null;
}
setFlash(new Set());
}
}, [conn]);
useEffect(() => {
if (!multi) return undefined;
const unsub = multi.subscribe(alert.symbol, timeframe, (data) => {
const last = data.candles?.[data.candles.length - 1];
if (!last) return;
candlesRef.current = data.candles || [];
setCandle(last);
setRawInd(data.indicators || {});
if (strategyRuleId != null && (data.candles?.length ?? 0) >= MIN_LIVE_CANDLES_FOR_EVAL) {
if (evalTimerRef.current != null) window.clearTimeout(evalTimerRef.current);
evalTimerRef.current = window.setTimeout(() => {
evalTimerRef.current = null;
void runEvaluate();
}, EVAL_DEBOUNCE_MS);
} else if (strategyRuleId == null) {
setConditionMet(false);
} else {
setConditionMet(false);
}
if (!data.isUpdate) {
prevFlatRef.current = buildFlatCompareMap(columns, data.indicators || {}, last);
return;
}
const cur = buildFlatCompareMap(columns, data.indicators || {}, last);
const prev = prevFlatRef.current;
const changed = new Set<string>();
for (const k of Object.keys(cur)) {
if (prev[k] !== undefined && prev[k] !== cur[k]) changed.add(k);
}
prevFlatRef.current = { ...cur };
if (changed.size === 0) return;
if (connRef.current !== 'connected') return;
setFlash((s) => {
const n = new Set(s);
changed.forEach((k) => n.add(k));
return n;
});
if (flashTimeoutRef.current != null) window.clearTimeout(flashTimeoutRef.current);
flashTimeoutRef.current = window.setTimeout(() => {
flashTimeoutRef.current = null;
setFlash((s) => {
const n = new Set(s);
changed.forEach((k) => n.delete(k));
return n;
});
}, FLASH_MS);
});
return () => {
unsub();
if (flashTimeoutRef.current != null) {
window.clearTimeout(flashTimeoutRef.current);
flashTimeoutRef.current = null;
}
if (evalTimerRef.current != null) {
window.clearTimeout(evalTimerRef.current);
evalTimerRef.current = null;
}
};
}, [multi, alert.symbol, timeframe, columns, strategyRuleId, runEvaluate]);
const displayMap = useMemo(() => {
if (!candle) return {} as Record<string, string>;
return buildFlatDisplayMap(columns, rawInd, candle);
}, [columns, candle, rawInd]);
const flashSx = (id: string) =>
conn === 'connected' && flash.has(id)
? {
color: FLASH_LIME,
fontWeight: 700,
textShadow: '0 0 8px rgba(204,255,0,0.45)',
fontFamily: 'monospace',
whiteSpace: 'nowrap' as const,
}
: { fontFamily: 'monospace', whiteSpace: 'nowrap' as const };
const rowHighlightSx =
strategyRuleId != null && conditionMet
? {
bgcolor: 'rgba(204,255,0,0.07)',
boxShadow: `inset 0 0 0 1.5px ${FLASH_LIME}, 0 0 20px rgba(204,255,0,0.35)`,
}
: undefined;
return (
<TableRow hover sx={rowHighlightSx}>
<TableCell sx={{ fontWeight: 600 }}>{alert.symbol}</TableCell>
<TableCell>{alert.koreanName || '—'}</TableCell>
<TableCell>
<Chip size="small" label={statusLabel(conn)} color={statusColor(conn)} variant="outlined" />
</TableCell>
{columns.map((col) => (
<TableCell key={col.id} align="right" sx={{ ...flashSx(col.id), fontSize: '0.75rem' }}>
{displayMap[col.id] ?? '—'}
</TableCell>
))}
</TableRow>
);
};
interface InnerGridProps {
alerts: CryptoAlertDto[];
timeframe: string;
metricColumns: StrategyMetricColumn[];
/** 동일 심볼이 여러 전략 블록에 있을 때 React key 구분 */
rowKeyPrefix: string;
strategyRuleId: number | null;
scheduleInterval: string;
}
const InnerStrategyGrid: React.FC<InnerGridProps> = ({
alerts,
timeframe,
metricColumns,
rowKeyPrefix,
strategyRuleId,
scheduleInterval,
}) => {
const allColumns = useMemo(() => [...BASE_PRICE_COLUMNS, ...metricColumns], [metricColumns]);
if (alerts.length === 0) {
return (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
. .
</Typography>
);
}
return (
<TableContainer sx={{ flex: 1, minHeight: 0, maxHeight: '100%' }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700 }}></TableCell>
<TableCell sx={{ fontWeight: 700 }}></TableCell>
<TableCell sx={{ fontWeight: 700 }}>WS</TableCell>
{allColumns.map((c) => (
<TableCell key={c.id} align="right" sx={{ fontWeight: 700, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{c.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{alerts.map((a) => (
<StrategyGridRow
key={`${rowKeyPrefix}:${a.symbol}`}
alert={a}
timeframe={timeframe}
columns={allColumns}
strategyRuleId={strategyRuleId}
scheduleInterval={scheduleInterval}
/>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export type AlertStrategySection = {
key: string;
title: string;
metricColumns: StrategyMetricColumn[];
/** 공통 블록(전략 없음)은 null */
strategyRuleId: number | null;
};
export interface AlertRealtimeStrategyPopupProps {
anchorEl: HTMLElement | null;
onClose: () => void;
/** 스케줄 설정의 평가 시간봉 (예: 5m, 1h) — 멀티차트 WS와 동일 */
timeInterval: string;
/** 스케줄러 마지막 전략 체크 실행 시각 (ISO 문자열·Jackson 배열 등) */
lastStrategyRunTime?: unknown;
/** 스케줄러 다음 전략 체크 예정 시각 */
nextStrategyRunTime?: unknown;
}
/** 백엔드 LocalDateTime JSON (ISO 또는 [y,m,d,h,mi,s,nano]) 파싱 */
function parseScheduleDateTime(v: unknown): Date | null {
if (v == null || v === '') return null;
if (typeof v === 'number' && Number.isFinite(v)) {
return new Date(v > 1e12 ? v : v * 1000);
}
if (typeof v === 'string') {
const d = new Date(v);
return Number.isFinite(d.getTime()) ? d : null;
}
if (Array.isArray(v) && v.length >= 3) {
const y = Number(v[0]);
const mo = Number(v[1]);
const day = Number(v[2]);
const h = v.length > 3 ? Number(v[3]) : 0;
const mi = v.length > 4 ? Number(v[4]) : 0;
const s = v.length > 5 ? Number(v[5]) : 0;
const nano = v.length > 6 ? Number(v[6]) : 0;
const ms = Number.isFinite(nano) ? Math.floor(nano / 1e6) : 0;
if (![y, mo, day].every((x) => Number.isFinite(x))) return null;
return new Date(
y,
(Number.isFinite(mo) ? mo : 1) - 1,
Number.isFinite(day) ? day : 1,
Number.isFinite(h) ? h : 0,
Number.isFinite(mi) ? mi : 0,
Number.isFinite(s) ? s : 0,
ms,
);
}
return null;
}
function formatScheduleInstant(v: unknown): string {
const d = parseScheduleDateTime(v);
return d && Number.isFinite(d.getTime()) ? d.toLocaleString('ko-KR') : '—';
}
const AlertRealtimeStrategyPopupShell: React.FC<{
children: React.ReactNode;
anchorEl: HTMLElement;
onClose: () => void;
isDark: boolean;
lastStrategyRunTime?: unknown;
nextStrategyRunTime?: unknown;
}> = ({ children, anchorEl, onClose, isDark, lastStrategyRunTime, nextStrategyRunTime }) => {
const nodeRef = useRef<HTMLDivElement>(null);
const [dragPos, setDragPos] = useState({ x: 24, y: 80 });
const [panelW, setPanelW] = useState(DEFAULT_PANEL_W);
const [panelH, setPanelH] = useState(DEFAULT_PANEL_H);
const [isMaximized, setIsMaximized] = useState(false);
const preMaxRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
const resizeListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
useLayoutEffect(() => {
setDragPos(nearAnchor(anchorEl, panelW, panelH));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchorEl]);
const onDrag = useCallback(
(_: unknown, data: { x: number; y: number }) => {
setDragPos(clamp(data.x, data.y, panelW, panelH));
},
[panelW, panelH],
);
const toggleMaximize = useCallback(() => {
if (typeof window === 'undefined') return;
if (!isMaximized) {
preMaxRef.current = { x: dragPos.x, y: dragPos.y, w: panelW, h: panelH };
setIsMaximized(true);
return;
}
const saved = preMaxRef.current;
setIsMaximized(false);
if (saved) {
setPanelW(saved.w);
setPanelH(saved.h);
setDragPos(clamp(saved.x, saved.y, saved.w, saved.h));
}
preMaxRef.current = null;
}, [isMaximized, dragPos.x, dragPos.y, panelW, panelH]);
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
const sx = e.clientX;
const sy = e.clientY;
const sw = panelW;
const sh = panelH;
const onMove = (ev: MouseEvent) => {
if (typeof window === 'undefined') return;
const maxW = window.innerWidth - MAX_INSET * 2;
const maxH = window.innerHeight - MAX_INSET * 2;
const nw = Math.min(maxW, Math.max(MIN_PANEL_W, sw + (ev.clientX - sx)));
const nh = Math.min(maxH, Math.max(MIN_PANEL_H, sh + (ev.clientY - sy)));
setPanelW(nw);
setPanelH(nh);
setDragPos((p) => clamp(p.x, p.y, nw, nh));
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
resizeListenersRef.current = null;
};
resizeListenersRef.current = { move: onMove, up: onUp };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
[isMaximized, panelW, panelH],
);
useEffect(() => {
return () => {
const h = resizeListenersRef.current;
if (h) {
window.removeEventListener('mousemove', h.move);
window.removeEventListener('mouseup', h.up);
resizeListenersRef.current = null;
}
};
}, []);
const shell = (
<Box
ref={nodeRef}
sx={{
position: 'fixed',
left: isMaximized ? MAX_INSET : 0,
top: isMaximized ? MAX_INSET : 0,
zIndex: (t) => t.zIndex.modal + 30,
width: isMaximized ? `calc(100vw - ${MAX_INSET * 2}px)` : panelW,
height: isMaximized ? `calc(100vh - ${MAX_INSET * 2}px)` : panelH,
pointerEvents: 'auto',
boxSizing: 'border-box',
}}
>
<Paper
elevation={10}
sx={{
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: isDark ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.12)',
bgcolor: 'background.paper',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.75,
flexShrink: 0,
borderBottom: 1,
borderColor: 'divider',
bgcolor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
}}
>
<Box
className="rt-strategy-drag-handle"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flex: 1,
minWidth: 0,
cursor: isMaximized ? 'default' : 'grab',
userSelect: 'none',
'&:active': { cursor: isMaximized ? 'default' : 'grabbing' },
}}
>
<ShowChartIcon sx={{ fontSize: 20, color: 'primary.main', flexShrink: 0 }} />
<DragIndicatorIcon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0, opacity: 0.85 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexShrink: 0 }}>
</Typography>
<Typography
variant="caption"
color="text.secondary"
component="span"
title={`마지막 전략체크: ${formatScheduleInstant(lastStrategyRunTime)} · 다음 실행: ${formatScheduleInstant(nextStrategyRunTime)}`}
sx={{
ml: 'auto',
pl: 1,
pr: 0.5,
textAlign: 'right',
fontSize: { xs: '0.65rem', sm: '0.72rem' },
lineHeight: 1.35,
minWidth: 0,
maxWidth: { xs: 'min(62vw, 340px)', sm: 'min(58vw, 520px)' },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
: {formatScheduleInstant(lastStrategyRunTime)} · :{' '}
{formatScheduleInstant(nextStrategyRunTime)}
</Typography>
</Box>
<IconButton
className="rt-strategy-no-drag"
size="small"
onClick={toggleMaximize}
aria-label={isMaximized ? '이전 크기로' : '최대화'}
>
{isMaximized ? <FullscreenExitIcon fontSize="small" /> : <FullscreenIcon fontSize="small" />}
</IconButton>
<IconButton className="rt-strategy-no-drag" size="small" onClick={onClose} aria-label="닫기">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>{children}</Box>
{!isMaximized && (
<Box
className="rt-strategy-no-drag"
role="presentation"
aria-hidden
onMouseDown={onResizeStart}
sx={{
position: 'absolute',
right: 0,
bottom: 0,
width: 22,
height: 22,
cursor: 'nwse-resize',
zIndex: 2,
background: isDark
? 'linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.12) 50%)'
: 'linear-gradient(135deg, transparent 50%, rgba(0,0,0,0.08) 50%)',
}}
/>
)}
</Paper>
</Box>
);
if (isMaximized) {
return createPortal(shell, document.body);
}
return createPortal(
<Draggable
nodeRef={nodeRef}
handle=".rt-strategy-drag-handle"
position={dragPos}
onDrag={onDrag}
bounds="body"
cancel=".rt-strategy-no-drag"
>
{shell}
</Draggable>,
document.body,
);
};
const AlertRealtimeStrategyPopup: React.FC<AlertRealtimeStrategyPopupProps> = ({
anchorEl,
onClose,
timeInterval,
lastStrategyRunTime,
nextStrategyRunTime,
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
const wsTf = useMemo(() => chartIntervalToWsTimeframe(timeInterval || '1h'), [timeInterval]);
const scheduleInterval = wsTf;
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [enabledAlerts, setEnabledAlerts] = useState<CryptoAlertDto[]>([]);
const [strategySections, setStrategySections] = useState<AlertStrategySection[]>([]);
useEffect(() => {
if (!anchorEl) return undefined;
let cancelled = false;
(async () => {
setLoading(true);
setLoadError(null);
try {
const [alertsAll, globals, strats] = await Promise.all([
getAllAlerts(),
getGlobalConditions(),
getEnabledAlertStrategies().catch(() => []),
]);
if (cancelled) return;
const enabled = alertsAll.filter((a) => a.enabled);
setEnabledAlerts(enabled);
const baseTypes = collectIndicatorTypesFromAlertsAndGlobal(enabled, globals || []);
const sections: AlertStrategySection[] = [];
if (!strats.length) {
sections.push({
key: 'no-strategy',
title: '공통 (활성 알림 전략 없음)',
metricColumns: buildStrategyMetricColumns(new Set(Array.from(baseTypes))),
strategyRuleId: null,
});
} else {
for (const st of strats) {
if (cancelled) return;
const merged = new Set<string>(Array.from(baseTypes));
const rid = st.strategyRuleId;
if (rid != null) {
try {
const rule = await getStrategyRule(rid);
if (cancelled) return;
collectIndicatorTypesFromStrategyRule(rule).forEach((t) => merged.add(t));
} catch {
/* 전략 상세 없으면 공통 지표만 */
}
}
const title = st.strategyRuleName?.trim() || `전략 #${rid ?? st.id ?? ''}`;
const key = st.id != null ? `as-${st.id}` : `sr-${rid}-${sections.length}`;
sections.push({
key,
title,
metricColumns: buildStrategyMetricColumns(merged),
strategyRuleId: rid ?? null,
});
}
}
if (cancelled) return;
setStrategySections(sections);
} catch (e: any) {
if (!cancelled) setLoadError(e?.message || '데이터를 불러오지 못했습니다.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [anchorEl]);
const subscriptions = useMemo(
() => enabledAlerts.map((a) => ({ market: a.symbol, timeframe: wsTf, koreanName: a.koreanName })),
[enabledAlerts, wsTf],
);
if (!anchorEl || typeof document === 'undefined' || !document.body) {
return null;
}
const body = (
<AlertRealtimeStrategyPopupShell
anchorEl={anchorEl}
onClose={onClose}
isDark={isDark}
lastStrategyRunTime={lastStrategyRunTime}
nextStrategyRunTime={nextStrategyRunTime}
>
<Box sx={{ px: 1.5, pt: 1, flexShrink: 0 }}>
<Typography variant="caption" color="text.secondary">
: <strong>{timeInterval || '1h'}</strong> (WS: {wsTf}) · .
</Typography>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={32} />
</Box>
) : loadError ? (
<Typography color="error" sx={{ p: 2 }}>
{loadError}
</Typography>
) : enabledAlerts.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
. .
</Typography>
) : (
<MultiChartMarketDataProvider subscriptions={subscriptions}>
<Box
sx={{
flex: 1,
minHeight: 0,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 2,
py: 1,
px: 0.5,
}}
>
{strategySections.map((sec) => (
<Paper
key={sec.key}
variant="outlined"
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: 0,
flex: strategySections.length > 1 ? '1 1 auto' : '1 1 0',
overflow: 'hidden',
borderColor: 'divider',
}}
>
<Box
sx={{
px: 1.25,
py: 0.75,
borderBottom: 1,
borderColor: 'divider',
bgcolor: (t) => (t.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{strategySections.length > 1 ? `전략 그룹: ${sec.title}` : `전략: ${sec.title}`}
</Typography>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<InnerStrategyGrid
alerts={enabledAlerts}
timeframe={wsTf}
metricColumns={sec.metricColumns}
rowKeyPrefix={sec.key}
strategyRuleId={sec.strategyRuleId}
scheduleInterval={scheduleInterval}
/>
</Box>
</Paper>
))}
</Box>
</MultiChartMarketDataProvider>
)}
</AlertRealtimeStrategyPopupShell>
);
return body;
};
export default AlertRealtimeStrategyPopup;
@@ -0,0 +1,475 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Paper,
IconButton,
Typography,
Box,
List,
ListItem,
ListItemText,
Tooltip,
} from '@mui/material';
import {
Close as CloseIcon,
Notifications as NotificationsIcon,
Info as InfoIcon,
ShowChart as ShowChartIcon,
FormatListBulleted as AlertListIcon,
OpenInNew as OpenInNewIcon,
Analytics as AnalyticsIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useAlertNotification } from '../../contexts/AlertNotificationContext';
import { useNavigation } from '../../contexts/NavigationContext';
import { AlertNotificationDto, getNotificationDetail } from '../../services/alertApi';
import type { BacktestResult, TradeRecord } from '../../services/backtestApi';
import { TradeSignalDetailDialog } from '../TradeSignalDetailDialog';
import upbitApiService from '../../services/upbitApi';
import {
extractStrategyNames,
extractStockStrategyPairs,
resolveStockStrategyPairRow,
resolveSymbols,
} from '../../utils/alertMessageParser';
import {
alertMarkerTimeIsoFromDto,
inferAlertSignalFromNotification,
} from '../../utils/alertSignalInference';
import { getUpbitWebExchangeUrl } from '../../utils/upbitWebUrls';
import AlertDetailDialog from './AlertDetailDialog';
import {
buildBacktestResultFromAlertDetail,
buildTradeRecordFromAlertDetail,
} from '../../utils/alertTradeRecordFromDetail';
/**
* 알림 팝업 컴포넌트
* 우측 상단에서 아래로 차례대로 표시, 사용자가 닫을 때까지 유지
*/
const AlertSnackbar: React.FC = () => {
const navigate = useNavigate();
const { navigateToPage } = useNavigation();
const { notifications, markNotificationAsRead, dismissNotification, loadNotifications } = useAlertNotification();
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [detailNotificationId, setDetailNotificationId] = useState<number | null>(null);
const [cryptoMap, setCryptoMap] = useState<Map<string, string>>(new Map());
const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
const dragStateRef = useRef<{ key: string; startX: number; startY: number; startLeft: number; startTop: number } | null>(null);
const [signalDetailOpen, setSignalDetailOpen] = useState(false);
const [signalDetailTrade, setSignalDetailTrade] = useState<TradeRecord | null>(null);
const [signalDetailBacktestResult, setSignalDetailBacktestResult] = useState<BacktestResult | null>(null);
useEffect(() => {
upbitApiService.getActiveCryptos().then((cryptos) => {
const map = new Map<string, string>();
cryptos.forEach((c) => map.set(c.koreanName, c.symbol));
setCryptoMap(map);
}).catch(() => {});
}, []);
const getNotificationKey = useCallback((n: AlertNotificationDto, index: number) => n.id != null ? `n-${n.id}` : `n-${index}`, []);
const handleTitleMouseDown = useCallback((e: React.MouseEvent, key: string) => {
if ((e.target as HTMLElement).closest('button')) return;
const el = (e.currentTarget as HTMLElement).closest('[data-notification-paper]') as HTMLElement;
if (!el) return;
const rect = el.getBoundingClientRect();
const pos = positions[key];
dragStateRef.current = {
key,
startX: e.clientX,
startY: e.clientY,
startLeft: pos ? pos.x : rect.left,
startTop: pos ? pos.y : rect.top,
};
}, [positions]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const state = dragStateRef.current;
if (!state) return;
const dx = e.clientX - state.startX;
const dy = e.clientY - state.startY;
setPositions(prev => ({
...prev,
[state.key]: { x: state.startLeft + dx, y: state.startTop + dy },
}));
};
const handleMouseUp = () => { dragStateRef.current = null; };
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const handleShowDetail = (notification: AlertNotificationDto) => {
if (notification.id != null) {
setDetailNotificationId(notification.id);
setDetailDialogOpen(true);
}
};
const handleOpenSignalDetail = async (
e: React.MouseEvent,
notification: AlertNotificationDto,
pair?: { koreanName: string; strategyName: string; navSymbol?: string; symbol?: string }
) => {
e.stopPropagation();
if (!notification.id) return;
try {
const rowSym = (pair?.navSymbol ?? pair?.symbol ?? notification.symbol ?? '').trim();
const detail = await getNotificationDetail(
notification.id,
rowSym ? { symbol: rowSym } : undefined
);
const inferred =
inferAlertSignalFromNotification(notification, pair?.koreanName) ?? 'BUY';
const strategy =
pair?.strategyName?.trim() ||
(notification.conditionDescription || '').replace(/^전략:\s*/, '').trim() ||
undefined;
const trade = buildTradeRecordFromAlertDetail(detail, inferred, strategy);
const backtestResult = buildBacktestResultFromAlertDetail(detail, trade);
setSignalDetailTrade(trade);
setSignalDetailBacktestResult(backtestResult);
setSignalDetailOpen(true);
} catch (err) {
console.error(err);
alert('신호 상세를 불러오지 못했습니다.');
}
};
const handleCloseDetail = () => {
setDetailDialogOpen(false);
setDetailNotificationId(null);
loadNotifications();
};
const handleShowChart = (notification: AlertNotificationDto) => {
if (notification?.symbol && notification.id != null) {
const n = notification as AlertNotificationDto & { englishName?: string };
navigate('/candlestick-test', {
state: {
symbol: notification.symbol,
koreanName: notification.koreanName,
englishName: n.englishName
}
});
markNotificationAsRead(notification.id);
}
};
const handleStockClick = (notification: AlertNotificationDto, koreanName: string, symbol?: string) => {
const timeIso = alertMarkerTimeIsoFromDto(notification);
const inferred = inferAlertSignalFromNotification(notification, koreanName);
const extras: { alertSignal?: 'BUY' | 'SELL'; alertMarkerTime?: string } = {};
if (inferred && timeIso) {
extras.alertSignal = inferred;
extras.alertMarkerTime = timeIso;
}
if (symbol) {
navigateToPage('alert-list', { symbol, koreanName, ...extras });
return;
}
const resolved = resolveSymbols([koreanName], cryptoMap);
if (resolved.length > 0) {
navigateToPage('alert-list', {
symbol: resolved[0].symbol,
koreanName: resolved[0].koreanName,
...extras,
});
}
};
/** 통합/단일 알림 모두에 대해 타이틀(전략명)과 종목 목록 반환 */
const getTitleAndPairs = (notification: AlertNotificationDto): { title: string; pairs: Array<{ koreanName: string; strategyName: string; symbol?: string }> } => {
if (!notification) return { title: '', pairs: [] };
const msg = notification.message || '';
// 통합 알림: "🎯 알림 조건 충족 (N개)\n종목1 × 전략1\n..."
if (msg.includes('알림 조건 충족') && msg.includes('개)')) {
const strategyNames = extractStrategyNames(msg);
const pairs = extractStockStrategyPairs(msg) ?? [];
const title = strategyNames && strategyNames.length > 0
? strategyNames.length <= 3
? strategyNames.join(', ')
: `${strategyNames.slice(0, 2).join(', ')}${strategyNames.length - 2}개 전략`
: '🎯 통합 알림';
return { title, pairs: pairs.map((p) => ({ ...p, symbol: undefined })) };
}
// 단일 종목 알림: koreanName/symbol 있고 message에 전략 정보
if (notification.koreanName) {
const firstLine = msg.split('\n')[0]?.trim() || '';
const strategyName = firstLine.replace(/^[\s🧪🎯✅]*/, '').trim() || '알림';
const title = strategyName;
const pairs = [{ koreanName: notification.koreanName, strategyName, symbol: notification.symbol }];
return { title, pairs };
}
return { title: notification.symbol || '알림', pairs: [] };
};
if (notifications.length === 0) {
return (
<AlertDetailDialog
open={detailDialogOpen}
notificationId={detailNotificationId}
onClose={handleCloseDetail}
onMarkAsRead={loadNotifications}
/>
);
}
return (
<>
<Box
sx={{
position: 'fixed',
top: 72,
right: 16,
zIndex: 10000,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
maxHeight: 'calc(100vh - 100px)',
overflowY: 'auto',
}}
>
{notifications.map((notification, index) => {
const { title, pairs } = getTitleAndPairs(notification);
const hasStockList = pairs.length > 0;
// 표시용 symbol 보강 (통합 알림의 경우 resolve)
const displayPairs = pairs.map((p) => resolveStockStrategyPairRow(p, cryptoMap));
const nKey = getNotificationKey(notification, index);
const pos = positions[nKey];
return (
<Paper
key={notification.id ?? `n-${index}`}
data-notification-paper
elevation={8}
sx={{
minWidth: 360,
maxWidth: 420,
flexShrink: 0,
overflow: 'hidden',
borderRadius: 1.5,
border: '1px solid',
borderColor: '#829EF1',
...(pos && { position: 'fixed' as const, left: pos.x, top: pos.y, right: 'auto', zIndex: 10001 }),
}}
>
{/* 타이틀바 (드래그로 이동) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.5,
py: 1.25,
bgcolor: 'action.hover',
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'move',
userSelect: 'none',
}}
onMouseDown={(e) => handleTitleMouseDown(e, nKey)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<NotificationsIcon sx={{ color: 'success.main', fontSize: 20 }} />
<Typography variant="subtitle2" fontWeight="bold" noWrap sx={{ flex: 1 }}>
{title}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
{notification.symbol && !hasStockList && (
<>
<IconButton size="small" onClick={() => handleShowChart(notification)} title="차트보기">
<ShowChartIcon fontSize="small" />
</IconButton>
{notification.id != null && (
<Tooltip
title="알림 발생 시점 기준 전략·신호 상세"
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
>
<IconButton
size="small"
onClick={(e) => handleOpenSignalDetail(e, notification)}
aria-label="전략 신호 상세"
color="primary"
>
<AnalyticsIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</>
)}
{notification.id && (
<IconButton size="small" onClick={() => handleShowDetail(notification)} title="상세보기">
<InfoIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small" onClick={() => dismissNotification(notification)} title="닫기">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* 바디: 목록 또는 메시지 */}
<Box sx={{ maxHeight: 220, overflowY: 'auto' }}>
{hasStockList ? (
<List dense disablePadding sx={{ py: 0 }}>
{displayPairs.map((pair, i) => {
const marketSym = pair.navSymbol ?? pair.symbol;
const upbitUrl = getUpbitWebExchangeUrl(marketSym, notification.timeInterval);
return (
<ListItem
key={`${pair.navSymbol ?? pair.displaySymbol}-${pair.koreanName}-${i}`}
sx={{
py: 0.5,
px: 1.5,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
borderBottom: i < displayPairs.length - 1 ? '1px solid' : 'none',
borderColor: 'divider',
}}
onClick={() => handleStockClick(notification, pair.koreanName, pair.navSymbol ?? pair.symbol)}
secondaryAction={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mr: -0.5 }}
onClick={(e) => e.stopPropagation()}
>
<Tooltip
title="알림목록으로 이동"
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
>
<IconButton
size="small"
edge="end"
onClick={(e) => {
e.stopPropagation();
handleStockClick(notification, pair.koreanName, pair.navSymbol ?? pair.symbol);
}}
aria-label="알림목록으로 이동"
>
<AlertListIcon fontSize="small" />
</IconButton>
</Tooltip>
{upbitUrl ? (
<Tooltip
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
title={
notification.timeInterval
? `업비트 웹 차트 (시간봉 ${notification.timeInterval})`
: '업비트 웹에서 차트 보기'
}
>
<IconButton
size="small"
component="a"
href={upbitUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label="업비트에서 차트 열기"
sx={{ color: 'success.main' }}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null}
<Tooltip
title="알림 발생 시점 기준 전략·신호 상세 (해당 행 종목)"
placement="top"
PopperProps={{ sx: { zIndex: 11050 } }}
>
<span>
<IconButton
size="small"
onClick={(e) => handleOpenSignalDetail(e, notification, pair)}
aria-label="전략 신호 상세"
color="primary"
>
<AnalyticsIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
}
>
<ListItemText
primary={pair.koreanName}
secondary={pair.displaySymbol}
primaryTypographyProps={{ fontSize: '13px', fontWeight: 500 }}
secondaryTypographyProps={{ fontSize: '11px', color: 'text.secondary' }}
/>
</ListItem>
);
})}
</List>
) : (
<Box sx={{ px: 1.5, py: 1.5 }}>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{(notification.message || '').split('\n')[0]?.substring(0, 80) || ''}
</Typography>
</Box>
)}
{notification.id && (
<Box
sx={{
px: 1.5,
py: 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Typography
variant="caption"
sx={{
color: 'primary.main',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': { color: 'primary.dark' },
}}
onClick={() => handleShowDetail(notification)}
>
</Typography>
</Box>
)}
</Box>
</Paper>
);
})}
</Box>
<AlertDetailDialog
open={detailDialogOpen}
notificationId={detailNotificationId}
onClose={handleCloseDetail}
onMarkAsRead={loadNotifications}
/>
<TradeSignalDetailDialog
open={signalDetailOpen}
trade={signalDetailTrade}
backtestResult={signalDetailBacktestResult}
onClose={() => {
setSignalDetailOpen(false);
setSignalDetailTrade(null);
setSignalDetailBacktestResult(null);
}}
/>
</>
);
};
export default AlertSnackbar;
@@ -0,0 +1,478 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Autocomplete,
TextField,
Button,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Chip,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Divider,
Paper,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
CheckCircle as CheckCircleIcon,
Info as InfoIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import { getStrategyRules, StrategyRuleDto } from '../../services/strategyRuleApi';
import {
getAllAlertStrategies,
addAlertStrategy,
deleteAlertStrategy,
AlertStrategyDto,
} from '../../services/alertStrategyApi';
/**
* 알림 조건 (전략 목록) 패널
*/
const AlertStrategyPanel: React.FC = () => {
const [strategies, setStrategies] = useState<StrategyRuleDto[]>([]);
const [alertStrategies, setAlertStrategies] = useState<AlertStrategyDto[]>([]);
const [selectedStrategy, setSelectedStrategy] = useState<StrategyRuleDto | null>(null);
const [loading, setLoading] = useState(true);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [detailStrategy, setDetailStrategy] = useState<StrategyRuleDto | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
loadStrategies();
loadAlertStrategies();
}, []);
const loadStrategies = async () => {
try {
console.log('[Alert Strategy] 전략 목록 로드 시작 (BUY, enabled=true)');
// BUY 신호 전략만 조회
const strategyList = await getStrategyRules('BUY', true);
console.log('[Alert Strategy] 전략 목록 로드 완료:', {
count: strategyList.length,
strategies: strategyList.map(s => ({ id: s.id, name: s.name }))
});
setStrategies(strategyList);
} catch (error) {
console.error('[Alert Strategy] 전략 목록 로드 실패:', error);
}
};
const loadAlertStrategies = async () => {
try {
setLoading(true);
console.log('[Alert Strategy] 알림 전략 목록 로드 시작');
const alertStrategyList = await getAllAlertStrategies();
console.log('[Alert Strategy] 알림 전략 목록 로드 완료:', {
count: alertStrategyList.length,
strategies: alertStrategyList.map(s => ({ id: s.id, strategyRuleId: s.strategyRuleId, name: s.strategyRuleName }))
});
setAlertStrategies(alertStrategyList);
} catch (error) {
console.error('[Alert Strategy] 알림 전략 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleAdd = async () => {
if (!selectedStrategy) {
console.warn('[Alert Strategy] 선택된 전략이 없습니다');
return;
}
if (!selectedStrategy.id) {
console.error('[Alert Strategy] 전략 ID가 없습니다:', selectedStrategy);
alert('전략 ID가 없습니다. 전략 목록을 다시 불러오세요.');
return;
}
try {
console.log('[Alert Strategy] 추가 요청:', {
strategyId: selectedStrategy.id,
strategyName: selectedStrategy.name
});
await addAlertStrategy(selectedStrategy.id);
await loadAlertStrategies();
setSelectedStrategy(null);
console.log('[Alert Strategy] 추가 성공');
alert('알림 전략이 추가되었습니다.');
// 전략 화면에 알림 상태 변경 알림 (커스텀 이벤트)
window.dispatchEvent(new CustomEvent('alertStrategyChanged'));
} catch (error: any) {
console.error('[Alert Strategy] 추가 실패:', error);
console.error('[Alert Strategy] 에러 상세:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
statusText: error.response?.statusText
});
const errorMessage = error.response?.data?.message
|| error.response?.data
|| error.message
|| '알림 전략 추가에 실패했습니다.';
alert(`알림 전략 추가 실패:\n${errorMessage}`);
}
};
const handleDelete = async (id: number) => {
if (!window.confirm('이 알림 조건을 삭제하시겠습니까?')) return;
try {
await deleteAlertStrategy(id);
await loadAlertStrategies();
// 전략 화면에 알림 상태 변경 알림 (커스텀 이벤트)
window.dispatchEvent(new CustomEvent('alertStrategyChanged'));
} catch (error) {
console.error('알림 전략 삭제 실패:', error);
alert('알림 전략 삭제에 실패했습니다.');
}
};
const handleShowDetail = async (strategyRuleId: number) => {
try {
setDetailLoading(true);
setDetailDialogOpen(true);
// 전략 상세 정보 조회
const strategyDetail = strategies.find(s => s.id === strategyRuleId);
if (strategyDetail) {
setDetailStrategy(strategyDetail);
} else {
// 전략 목록에 없으면 API로 다시 조회
const allStrategies = await getStrategyRules();
const foundStrategy = allStrategies.find(s => s.id === strategyRuleId);
setDetailStrategy(foundStrategy || null);
}
} catch (error) {
console.error('전략 상세 정보 로드 실패:', error);
alert('전략 상세 정보를 불러오는데 실패했습니다.');
} finally {
setDetailLoading(false);
}
};
const handleCloseDetail = () => {
setDetailDialogOpen(false);
setDetailStrategy(null);
};
// 이미 추가된 전략 제외
const availableStrategies = strategies.filter(
(strategy) => !alertStrategies.some((as) => as.strategyRuleId === strategy.id)
);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" gutterBottom sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }}>
( )
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, fontSize: { xs: '0.8rem', sm: '0.875rem' } }}>
. .
</Typography>
{/* 전략 추가 */}
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1, mb: 3 }}>
<Autocomplete
options={availableStrategies}
getOptionLabel={(option) => option.name || ''}
value={selectedStrategy}
onChange={(_, newValue) => setSelectedStrategy(newValue)}
renderInput={(params) => (
<TextField
{...params}
label="전략 검색"
placeholder="전략을 검색하세요..."
size="small"
/>
)}
renderOption={(props, option) => (
<li {...props}>
<Typography variant="body2">{option.name}</Typography>
</li>
)}
sx={{ flexGrow: 1, minWidth: { xs: '100%', sm: '200px' } }}
noOptionsText="사용 가능한 전략이 없습니다"
/>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
disabled={!selectedStrategy}
sx={{ minWidth: { xs: '100%', sm: 'auto' } }}
>
</Button>
</Box>
{/* 추가된 알림 전략 목록 */}
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
{alertStrategies.length === 0 ? (
<Box
sx={{
p: 3,
textAlign: 'center',
border: '2px dashed',
borderColor: 'divider',
borderRadius: 1,
}}
>
<Typography variant="body2" color="text.secondary">
.
<br />
.
</Typography>
</Box>
) : (
<List dense>
{alertStrategies.map((alertStrategy, index) => (
<ListItem
key={alertStrategy.id}
sx={{
borderRadius: 1,
mb: 0.5,
backgroundColor: 'action.hover',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}>
<CheckCircleIcon color="success" fontSize="small" />
</Box>
<ListItemText
primary={
<Typography variant="body2" fontWeight="medium">
{index + 1}. {alertStrategy.strategyRuleName || `전략 ID: ${alertStrategy.strategyRuleId}`}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
: {new Date(alertStrategy.createdAt!).toLocaleString()}
</Typography>
}
/>
<ListItemSecondaryAction>
<IconButton
onClick={() => handleShowDetail(alertStrategy.strategyRuleId!)}
color="info"
size="small"
title="상세보기"
sx={{ mr: 1 }}
>
<InfoIcon />
</IconButton>
<IconButton
edge="end"
onClick={() => handleDelete(alertStrategy.id!)}
color="error"
size="small"
title="삭제"
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* 통계 */}
{alertStrategies.length > 0 && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
{alertStrategies.length} .
</Typography>
</Box>
)}
</Box>
{/* 전략 상세 정보 Dialog */}
<Dialog
open={detailDialogOpen}
onClose={handleCloseDetail}
maxWidth="md"
fullWidth
>
<DialogTitle
sx={{
bgcolor: 'primary.main',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 2,
}}
>
<Typography variant="h6" component="span">
</Typography>
<IconButton
onClick={handleCloseDetail}
size="small"
sx={{
color: 'white',
'&:hover': {
bgcolor: 'rgba(255, 255, 255, 0.1)',
},
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{detailLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : detailStrategy ? (
<Box>
{/* 기본 정보 */}
<Paper sx={{ p: 2, mb: 2, bgcolor: 'background.default' }}>
<Typography variant="h6" gutterBottom>
{detailStrategy.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{detailStrategy.description || '설명이 없습니다.'}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={detailStrategy.signalType === 'BUY' ? '매수 신호' : '매도 신호'}
color={detailStrategy.signalType === 'BUY' ? 'success' : 'error'}
size="small"
/>
<Chip
label={detailStrategy.enabled ? '활성화' : '비활성화'}
color={detailStrategy.enabled ? 'success' : 'default'}
size="small"
/>
{detailStrategy.trendType && (
<Chip
label={`추세: ${detailStrategy.trendType}`}
size="small"
variant="outlined"
/>
)}
</Box>
</Paper>
{/* 매수 조건 그룹 */}
{detailStrategy.buyConditionGroups && detailStrategy.buyConditionGroups.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold', color: 'success.main' }}>
</Typography>
{detailStrategy.buyConditionGroups.map((group, groupIndex) => (
<Paper key={groupIndex} sx={{ p: 2, mb: 1, bgcolor: 'background.default' }}>
<Typography variant="body2" fontWeight="medium" gutterBottom>
{groupIndex + 1} ({group.logicalOperator || 'AND'})
</Typography>
{group.conditions && group.conditions.length > 0 ? (
<Box sx={{ pl: 2 }}>
{group.conditions.map((condition, condIndex) => (
<Box key={condIndex} sx={{ mb: 0.5 }}>
<Typography variant="body2">
{condition.indicatorType}
{condition.period && ` (${condition.period})`}
{' '}{condition.conditionType}
{condition.targetValue !== null && condition.targetValue !== undefined && ` ${condition.targetValue}`}
{condition.secondaryValue !== null && condition.secondaryValue !== undefined && ` ~ ${condition.secondaryValue}`}
{condition.description && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
({condition.description})
</Typography>
)}
</Typography>
</Box>
))}
</Box>
) : (
<Typography variant="caption" color="text.secondary">
.
</Typography>
)}
</Paper>
))}
</Box>
)}
{/* 매도 조건 그룹 */}
{detailStrategy.sellConditionGroups && detailStrategy.sellConditionGroups.length > 0 && (
<Box>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold', color: 'error.main' }}>
</Typography>
{detailStrategy.sellConditionGroups.map((group, groupIndex) => (
<Paper key={groupIndex} sx={{ p: 2, mb: 1, bgcolor: 'background.default' }}>
<Typography variant="body2" fontWeight="medium" gutterBottom>
{groupIndex + 1} ({group.logicalOperator || 'AND'})
</Typography>
{group.conditions && group.conditions.length > 0 ? (
<Box sx={{ pl: 2 }}>
{group.conditions.map((condition, condIndex) => (
<Box key={condIndex} sx={{ mb: 0.5 }}>
<Typography variant="body2">
{condition.indicatorType}
{condition.period && ` (${condition.period})`}
{' '}{condition.conditionType}
{condition.targetValue !== null && condition.targetValue !== undefined && ` ${condition.targetValue}`}
{condition.secondaryValue !== null && condition.secondaryValue !== undefined && ` ~ ${condition.secondaryValue}`}
{condition.description && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
({condition.description})
</Typography>
)}
</Typography>
</Box>
))}
</Box>
) : (
<Typography variant="caption" color="text.secondary">
.
</Typography>
)}
</Paper>
))}
</Box>
)}
{/* 추가 정보 */}
<Divider sx={{ my: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary" display="block">
: {detailStrategy.createdAt ? new Date(detailStrategy.createdAt).toLocaleString() : '-'}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
: {detailStrategy.updatedAt ? new Date(detailStrategy.updatedAt).toLocaleString() : '-'}
</Typography>
</Box>
</Box>
) : (
<Typography color="text.secondary"> .</Typography>
)}
</DialogContent>
</Dialog>
</Box>
);
};
export default AlertStrategyPanel;
@@ -0,0 +1,264 @@
import React, { useState, useEffect } from 'react';
import {
Box,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
TextField,
InputAdornment,
Typography,
CircularProgress,
Divider,
} from '@mui/material';
import {
NotificationsActive,
NotificationsOff,
Search as SearchIcon,
} from '@mui/icons-material';
import { upbitApiService } from '../../services/upbitApi';
import { getAllAlerts, createAlert, toggleAlert, CryptoAlertDto } from '../../services/alertApi';
interface CryptoInfo {
symbol: string;
koreanName: string;
englishName: string;
}
interface CryptoListPanelProps {
onSelectCrypto: (crypto: CryptoInfo, alertId?: number) => void;
selectedSymbol?: string;
onDoubleClickCrypto?: (crypto: CryptoInfo) => void;
}
/**
* 암호화폐 목록 패널
*/
const CryptoListPanel: React.FC<CryptoListPanelProps> = ({ onSelectCrypto, selectedSymbol, onDoubleClickCrypto }) => {
const [cryptoList, setCryptoList] = useState<CryptoInfo[]>([]);
const [alerts, setAlerts] = useState<CryptoAlertDto[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadCryptos();
loadAlerts();
}, []);
// 투자분석 화면에서 알림 토글 시 목록 동기화
useEffect(() => {
const handleCryptoAlertChanged = () => {
loadAlerts();
};
window.addEventListener('cryptoAlertChanged', handleCryptoAlertChanged);
return () => window.removeEventListener('cryptoAlertChanged', handleCryptoAlertChanged);
}, []);
const loadCryptos = async () => {
try {
setLoading(true);
const markets = await upbitApiService.getMarkets();
const cryptos = markets.map(m => ({
symbol: m.market,
koreanName: m.korean_name,
englishName: m.english_name,
}));
setCryptoList(cryptos);
} catch (error) {
console.error('암호화폐 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
const loadAlerts = async () => {
try {
const alertList = await getAllAlerts();
setAlerts(alertList);
} catch (error) {
console.error('알림 목록 로드 실패:', error);
}
};
const getAlertForSymbol = (symbol: string): CryptoAlertDto | undefined => {
return alerts.find(a => a.symbol === symbol);
};
const isAlertEnabled = (symbol: string): boolean => {
const alert = getAlertForSymbol(symbol);
return alert?.enabled || false;
};
const handleToggleAlert = async (crypto: CryptoInfo) => {
try {
const existingAlert = getAlertForSymbol(crypto.symbol);
if (existingAlert) {
// 기존 알림이 있으면 토글
const updated = await toggleAlert(existingAlert.id!, !existingAlert.enabled);
setAlerts(prev => prev.map(a => a.id === updated.id ? updated : a));
// 투자분석 화면과 동기화: cryptoAlertChanged 이벤트 dispatch
window.dispatchEvent(new CustomEvent('cryptoAlertChanged', { detail: { symbol: crypto.symbol } }));
// 활성화되면 해당 암호화폐 선택
if (updated.enabled) {
onSelectCrypto(crypto, updated.id);
}
} else {
// 새로운 알림 생성
const newAlert: CryptoAlertDto = {
symbol: crypto.symbol,
koreanName: crypto.koreanName,
englishName: crypto.englishName,
enabled: true,
};
const created = await createAlert(newAlert);
setAlerts(prev => [...prev, created]);
// 투자분석 화면과 동기화: cryptoAlertChanged 이벤트 dispatch
window.dispatchEvent(new CustomEvent('cryptoAlertChanged', { detail: { symbol: crypto.symbol } }));
// 새로 생성된 암호화폐 선택
onSelectCrypto(crypto, created.id);
}
} catch (error) {
console.error('알림 토글 실패:', error);
alert('알림 설정에 실패했습니다.');
}
};
const handleCryptoClick = (crypto: CryptoInfo) => {
const alert = getAlertForSymbol(crypto.symbol);
if (alert && alert.enabled) {
onSelectCrypto(crypto, alert.id);
}
};
// 검색 필터링 및 정렬 (활성화된 알림이 상단에)
const filteredCryptos = cryptoList
.filter(crypto => {
const query = searchQuery.toLowerCase();
return (
crypto.koreanName.toLowerCase().includes(query) ||
crypto.englishName.toLowerCase().includes(query) ||
crypto.symbol.toLowerCase().includes(query)
);
})
.sort((a, b) => {
const aEnabled = isAlertEnabled(a.symbol);
const bEnabled = isAlertEnabled(b.symbol);
// 활성화된 알림이 먼저 오도록 정렬
if (aEnabled && !bEnabled) return -1;
if (!aEnabled && bEnabled) return 1;
// 같은 상태면 한글 이름으로 정렬
return a.koreanName.localeCompare(b.koreanName, 'ko');
});
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Search */}
<TextField
fullWidth
size="small"
placeholder="암호화폐 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
sx={{ mb: 2 }}
/>
{/* List */}
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
{/* 활성화된 알림 개수 표시 */}
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', px: 2 }}>
: {alerts.filter(a => a.enabled).length}
</Typography>
<List dense>
{filteredCryptos.map((crypto, index) => {
const enabled = isAlertEnabled(crypto.symbol);
const isSelected = crypto.symbol === selectedSymbol;
const prevEnabled = index > 0 ? isAlertEnabled(filteredCryptos[index - 1].symbol) : true;
const isLastItem = index === filteredCryptos.length - 1;
// 활성화/비활성화 구분선
const showSectionDivider = index > 0 && prevEnabled && !enabled;
return (
<React.Fragment key={crypto.symbol}>
{showSectionDivider && (
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
)}
<ListItem
button
selected={isSelected}
onClick={() => handleCryptoClick(crypto)}
onDoubleClick={() => onDoubleClickCrypto?.(crypto)}
sx={{
borderRadius: 1,
backgroundColor: enabled ? 'rgba(255, 193, 7, 0.08)' : 'transparent',
'&.Mui-selected': {
backgroundColor: 'action.selected',
},
'&:hover': {
backgroundColor: enabled ? 'rgba(255, 193, 7, 0.15)' : 'action.hover',
},
}}
>
<ListItemText
primary={crypto.koreanName}
secondary={
<Typography variant="caption" color="text.secondary">
{crypto.englishName}
</Typography>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
handleToggleAlert(crypto);
}}
sx={{
color: enabled ? 'warning.main' : 'action.disabled',
}}
>
{enabled ? <NotificationsActive /> : <NotificationsOff />}
</IconButton>
</ListItemSecondaryAction>
</ListItem>
{!isLastItem && <Divider sx={{ my: 0.5 }} />}
</React.Fragment>
);
})}
</List>
</Box>
</Box>
);
};
export default CryptoListPanel;
@@ -0,0 +1,445 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
DialogTitle,
DialogContent,
DialogActions,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
addGlobalCondition,
updateCondition,
AlertConditionDto,
IndicatorType,
ConditionType,
} from '../../services/alertApi';
import { loadIndicatorSettingsFromStorage } from '../../contexts/IndicatorSettingsContext';
interface GlobalAlertConditionEditorProps {
condition?: AlertConditionDto | null;
onSave: () => void;
onCancel: () => void;
}
// 지표 옵션
const INDICATOR_OPTIONS: { value: IndicatorType; label: string }[] = [
{ value: 'MACD', label: 'MACD' },
{ value: 'RSI', label: 'RSI' },
{ value: 'STOCHASTIC', label: 'Stochastic' },
{ value: 'CCI', label: 'CCI' },
{ value: 'VOLUME', label: '거래량' },
{ value: 'OBV', label: 'OBV' },
{ value: 'WILLIAMS_R', label: 'Williams %R' },
{ value: 'VR', label: 'VR' },
{ value: 'ADX', label: 'ADX' },
{ value: 'TRIX', label: 'TRIX' },
{ value: 'DISPARITY', label: '이격도' },
];
// 조건 타입별 라벨
const CONDITION_LABELS: Record<ConditionType, string> = {
CROSS_UP: '상향 돌파',
CROSS_DOWN: '하향 돌파',
ABOVE: '초과',
BELOW: '미만',
BETWEEN: '범위 내',
EQUAL: '같음',
ZERO_CROSS_UP: '0선 상향 돌파',
ZERO_CROSS_DOWN: '0선 하향 돌파',
SIGNAL_CROSS_UP: '시그널선 상향 돌파',
SIGNAL_CROSS_DOWN: '시그널선 하향 돌파',
HISTOGRAM_POSITIVE: '히스토그램 양수',
HISTOGRAM_NEGATIVE: '히스토그램 음수',
HISTOGRAM_INCREASE: '히스토그램 증가',
HISTOGRAM_DECREASE: '히스토그램 감소',
K_CROSS_D_UP: '%K가 %D 상향돌파',
K_CROSS_D_DOWN: '%K가 %D 하향돌파',
VOLUME_SPIKE: '거래량 급증',
VOLUME_ABOVE_AVG: '평균 이상',
VOLUME_BELOW_AVG: '평균 이하',
INCREASE: '증가',
DECREASE: '감소',
GOLDEN_CROSS: '골든크로스',
DEAD_CROSS: '데드크로스',
};
// 지표별 허용 조건
const INDICATOR_CONDITIONS: Partial<Record<IndicatorType, ConditionType[]>> = {
MACD: ['ZERO_CROSS_UP', 'ZERO_CROSS_DOWN', 'SIGNAL_CROSS_UP', 'SIGNAL_CROSS_DOWN',
'HISTOGRAM_POSITIVE', 'HISTOGRAM_NEGATIVE', 'HISTOGRAM_INCREASE', 'HISTOGRAM_DECREASE'],
RSI: ['ABOVE', 'BELOW', 'BETWEEN', 'CROSS_UP', 'CROSS_DOWN'],
STOCHASTIC: ['ABOVE', 'BELOW', 'BETWEEN', 'K_CROSS_D_UP', 'K_CROSS_D_DOWN'],
CCI: ['ABOVE', 'BELOW', 'ZERO_CROSS_UP', 'ZERO_CROSS_DOWN'],
VOLUME: ['VOLUME_SPIKE', 'VOLUME_ABOVE_AVG', 'VOLUME_BELOW_AVG'],
OBV: ['INCREASE', 'DECREASE', 'CROSS_UP', 'CROSS_DOWN'],
WILLIAMS_R: ['ABOVE', 'BELOW', 'BETWEEN'],
VR: ['ABOVE', 'BELOW', 'BETWEEN'],
ADX: ['ABOVE', 'BELOW'],
TRIX: ['ZERO_CROSS_UP', 'ZERO_CROSS_DOWN', 'ABOVE', 'BELOW'],
DISPARITY: ['ABOVE', 'BELOW', 'BETWEEN'],
};
/**
* 전역 알림 조건 에디터
*/
const GlobalAlertConditionEditor: React.FC<GlobalAlertConditionEditorProps> = ({
condition,
onSave,
onCancel,
}) => {
const [formData, setFormData] = useState<AlertConditionDto>({
indicatorType: 'RSI',
conditionType: 'ABOVE',
targetValue: undefined,
secondaryValue: undefined,
period: undefined,
enabled: true,
orderIndex: 0,
description: '',
logicalOperator: 'OR', // 기본값 OR
});
const [saving, setSaving] = useState(false);
const indicatorSettings = loadIndicatorSettingsFromStorage();
useEffect(() => {
if (condition) {
setFormData({
...condition,
logicalOperator: 'OR', // OR로 고정
});
}
}, [condition]);
const handleChange = (field: keyof AlertConditionDto, value: any) => {
setFormData(prev => {
const updated = { ...prev, [field]: value };
// 지표 변경 시 조건 타입 초기화
if (field === 'indicatorType') {
const newConditions = INDICATOR_CONDITIONS[value as IndicatorType] || [];
updated.conditionType = newConditions[0] || 'ABOVE';
updated.targetValue = undefined;
updated.secondaryValue = undefined;
updated.period = getDefaultPeriod(value as IndicatorType);
}
// 조건 타입 변경 시 값 초기화
if (field === 'conditionType') {
if (!needsTargetValue(updated.indicatorType, value as ConditionType)) {
updated.targetValue = undefined;
}
if (value !== 'BETWEEN') {
updated.secondaryValue = undefined;
}
}
return updated;
});
};
const getDefaultPeriod = (indicatorType: IndicatorType): number | undefined => {
switch (indicatorType) {
case 'RSI': return indicatorSettings.rsiPeriod || 14;
case 'STOCHASTIC': return indicatorSettings.stochasticKPeriod || 12;
case 'CCI': return indicatorSettings.cciPeriod || 14;
case 'MACD': return indicatorSettings.macdSignalPeriod || 9;
case 'WILLIAMS_R': return indicatorSettings.williamsRPeriod || 14;
case 'ADX': return 14;
case 'TRIX': return indicatorSettings.trixPeriod || 12;
case 'DISPARITY': return indicatorSettings.disparityShortPeriod || 5;
default: return undefined;
}
};
const needsTargetValue = (indicatorType: IndicatorType, conditionType: ConditionType): boolean => {
if (indicatorType === 'MACD') return false;
return ['ABOVE', 'BELOW', 'BETWEEN', 'CROSS_UP', 'CROSS_DOWN'].includes(conditionType);
};
const needsSecondaryValue = (): boolean => {
return formData.conditionType === 'BETWEEN';
};
const needsPeriod = (indicatorType: IndicatorType): boolean => {
return ['RSI', 'STOCHASTIC', 'CCI', 'WILLIAMS_R', 'MACD', 'ADX', 'TRIX', 'DISPARITY', 'VR', 'OBV'].includes(indicatorType);
};
// 기간 옵션 (지표별)
const getPeriodOptions = () => {
switch (formData.indicatorType) {
case 'MACD':
return [{ value: indicatorSettings.macdSignalPeriod, label: 'MACD선' }];
case 'STOCHASTIC':
return [
{ value: indicatorSettings.stochasticKPeriod, label: '%K' },
{ value: indicatorSettings.stochasticDPeriod, label: '%D' },
];
case 'RSI':
return [{ value: indicatorSettings.rsiPeriod, label: 'RSI' }];
case 'CCI':
return [{ value: indicatorSettings.cciPeriod, label: 'CCI' }];
case 'TRIX':
return [{ value: indicatorSettings.trixPeriod, label: 'TRIX' }];
case 'WILLIAMS_R':
return [{ value: indicatorSettings.williamsRPeriod, label: 'Williams %R' }];
case 'DISPARITY':
return [
{ value: indicatorSettings.disparityUltraShortPeriod, label: '초단기' },
{ value: indicatorSettings.disparityShortPeriod, label: '단기' },
{ value: indicatorSettings.disparityMidPeriod, label: '중기' },
{ value: indicatorSettings.disparityLongPeriod, label: '장기' },
];
case 'ADX':
return [{ value: 14, label: 'ADX' }];
case 'VR':
return [{ value: 26, label: 'VR' }];
default:
return [];
}
};
// 기준값 프리셋 옵션
const getTargetValueOptions = () => {
switch (formData.indicatorType) {
case 'RSI':
return [
{ value: indicatorSettings.rsiOversold, label: `침체이탈(${indicatorSettings.rsiOversold})` },
{ value: indicatorSettings.rsiMiddle, label: `중립(${indicatorSettings.rsiMiddle})` },
{ value: indicatorSettings.rsiOverbought, label: `과열진입(${indicatorSettings.rsiOverbought})` },
];
case 'STOCHASTIC':
return [
{ value: indicatorSettings.stochasticOversold, label: `침체이탈(${indicatorSettings.stochasticOversold})` },
{ value: 50, label: '중립(50)' },
{ value: indicatorSettings.stochasticOverbought, label: `과열진입(${indicatorSettings.stochasticOverbought})` },
];
case 'CCI':
return [
{ value: indicatorSettings.cciOversold, label: `침체이탈(${indicatorSettings.cciOversold})` },
{ value: indicatorSettings.cciMiddle, label: `중립(${indicatorSettings.cciMiddle})` },
{ value: indicatorSettings.cciOverbought, label: `과열진입(${indicatorSettings.cciOverbought})` },
];
case 'WILLIAMS_R':
return [
{ value: -80, label: '침체이탈(-80)' },
{ value: -50, label: '중립(-50)' },
{ value: -20, label: '과열진입(-20)' },
];
case 'VR':
return [
{ value: 150, label: '침체(150)' },
{ value: 250, label: '정상(250)' },
{ value: 350, label: '과열(350)' },
];
case 'ADX':
return [
{ value: 20, label: '약한추세(20)' },
{ value: 40, label: '강한추세(40)' },
];
default:
return [];
}
};
const handleSubmit = async () => {
try {
setSaving(true);
const dataToSave: AlertConditionDto = {
...formData,
logicalOperator: 'OR' as 'OR', // OR로 강제 설정
};
if (condition?.id) {
// 수정
await updateCondition(condition.id, dataToSave);
} else {
// 추가
await addGlobalCondition(dataToSave);
}
onSave();
} catch (error) {
console.error('조건 저장 실패:', error);
alert('조건 저장에 실패했습니다.');
} finally {
setSaving(false);
}
};
const availableConditions = INDICATOR_CONDITIONS[formData.indicatorType] || [];
const periodOptions = getPeriodOptions();
const targetValueOptions = getTargetValueOptions();
const showTargetValue = needsTargetValue(formData.indicatorType, formData.conditionType);
const showSecondaryValue = needsSecondaryValue();
const showPeriod = needsPeriod(formData.indicatorType);
return (
<>
<DialogTitle>
{condition ? '전역 조건 수정' : '전역 조건 추가'}
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
.
<br />
OR ( ).
</Typography>
<Grid container spacing={2}>
{/* 지표 선택 */}
<Grid item xs={12} sm={6} md={3}>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select
value={formData.indicatorType}
label="지표"
onChange={(e) => handleChange('indicatorType', e.target.value as IndicatorType)}
>
{INDICATOR_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 기준선 (기간) */}
{showPeriod && (
<Grid item xs={12} sm={6} md={3}>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select
value={formData.period || ''}
label="기준선"
onChange={(e) => handleChange('period', parseInt(e.target.value as string))}
>
{periodOptions.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
)}
{/* 조건 선택 */}
<Grid item xs={12} sm={6} md={3}>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select
value={formData.conditionType}
label="조건"
onChange={(e) => handleChange('conditionType', e.target.value as ConditionType)}
>
{availableConditions.map(condType => (
<MenuItem key={condType} value={condType}>
{CONDITION_LABELS[condType]}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 기준값 */}
{showTargetValue && (
<Grid item xs={12} sm={6} md={3}>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select
value={formData.targetValue || ''}
label="기준값"
onChange={(e) => handleChange('targetValue', parseFloat(e.target.value as string))}
>
{targetValueOptions.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
<MenuItem value="custom"> </MenuItem>
</Select>
</FormControl>
{formData.targetValue === undefined ||
!targetValueOptions.some(opt => opt.value === formData.targetValue) ? (
<TextField
fullWidth
size="small"
type="number"
value={formData.targetValue || ''}
onChange={(e) => handleChange('targetValue', parseFloat(e.target.value) || undefined)}
placeholder="직접 입력"
sx={{ mt: 1 }}
/>
) : null}
</Grid>
)}
{/* 보조값 (BETWEEN) */}
{showSecondaryValue && (
<Grid item xs={12} sm={6} md={3}>
<TextField
fullWidth
size="small"
label="상한값"
type="number"
value={formData.secondaryValue || ''}
onChange={(e) => handleChange('secondaryValue', parseFloat(e.target.value) || undefined)}
helperText="범위의 상한값"
/>
</Grid>
)}
{/* 설명 (선택사항) */}
<Grid item xs={12}>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2"> ()</Typography>
</AccordionSummary>
<AccordionDetails>
<TextField
fullWidth
size="small"
multiline
rows={3}
value={formData.description || ''}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="조건에 대한 설명을 입력하세요"
/>
</AccordionDetails>
</Accordion>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={saving}
>
{saving ? '저장 중...' : '저장'}
</Button>
</DialogActions>
</>
);
};
export default GlobalAlertConditionEditor;
@@ -0,0 +1,225 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Card,
CardContent,
CardActions,
Typography,
IconButton,
Chip,
Stack,
Dialog,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
PlayArrow as PlayIcon,
Pause as PauseIcon,
} from '@mui/icons-material';
import {
getGlobalConditions,
deleteCondition,
toggleCondition,
AlertConditionDto,
} from '../../services/alertApi';
import GlobalAlertConditionEditor from './GlobalAlertConditionEditor';
/**
* 전역 알림 조건 패널
* 모든 활성화된 종목에 적용되는 전역 조건 관리
*/
const GlobalAlertConditionsPanel: React.FC = () => {
const [conditions, setConditions] = useState<AlertConditionDto[]>([]);
const [editorOpen, setEditorOpen] = useState(false);
const [editingCondition, setEditingCondition] = useState<AlertConditionDto | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadConditions();
}, []);
const loadConditions = async () => {
try {
setLoading(true);
const data = await getGlobalConditions();
setConditions(data);
} catch (error) {
console.error('전역 조건 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleAddCondition = () => {
setEditingCondition(null);
setEditorOpen(true);
};
const handleEditCondition = (condition: AlertConditionDto) => {
setEditingCondition(condition);
setEditorOpen(true);
};
const handleDeleteCondition = async (conditionId: number) => {
if (!window.confirm('이 조건을 삭제하시겠습니까?')) return;
try {
await deleteCondition(conditionId);
setConditions(prev => prev.filter(c => c.id !== conditionId));
} catch (error) {
console.error('조건 삭제 실패:', error);
alert('조건 삭제에 실패했습니다.');
}
};
const handleToggleCondition = async (condition: AlertConditionDto) => {
if (!condition.id) return;
try {
const updated = await toggleCondition(condition.id, !condition.enabled);
setConditions(prev => prev.map(c => c.id === updated.id ? updated : c));
} catch (error) {
console.error('조건 토글 실패:', error);
alert('조건 상태 변경에 실패했습니다.');
}
};
const handleSaveCondition = () => {
setEditorOpen(false);
setEditingCondition(null);
loadConditions();
};
const handleCloseEditor = () => {
setEditorOpen(false);
setEditingCondition(null);
};
const getConditionDescription = (condition: AlertConditionDto): string => {
let desc = `${condition.indicatorType} ${condition.conditionType}`;
if (condition.targetValue !== undefined) {
desc += ` ${condition.targetValue}`;
}
if (condition.period) {
desc += ` (${condition.period}기간)`;
}
return desc;
};
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6">
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddCondition}
size="small"
>
</Button>
</Box>
{/* Conditions List */}
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
{loading ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
...
</Typography>
</Box>
) : conditions.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
.
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
"조건 추가" .
</Typography>
</Box>
) : (
<Stack spacing={2}>
{conditions.map((condition) => (
<Card key={condition.id} variant="outlined">
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" fontWeight="medium">
{getConditionDescription(condition)}
</Typography>
<Chip
label={condition.enabled ? '활성' : '비활성'}
size="small"
color={condition.enabled ? 'success' : 'default'}
/>
</Box>
{condition.description && (
<Typography variant="caption" color="text.secondary">
{condition.description}
</Typography>
)}
{condition.logicalOperator && (
<Chip
label={condition.logicalOperator}
size="small"
variant="outlined"
sx={{ ml: 1, mt: 0.5 }}
/>
)}
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', pt: 0 }}>
<IconButton
size="small"
onClick={() => handleToggleCondition(condition)}
color={condition.enabled ? 'primary' : 'default'}
>
{condition.enabled ? <PauseIcon /> : <PlayIcon />}
</IconButton>
<IconButton
size="small"
onClick={() => handleEditCondition(condition)}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => condition.id && handleDeleteCondition(condition.id)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</CardActions>
</Card>
))}
</Stack>
)}
</Box>
{/* Condition Editor Dialog */}
<Dialog
open={editorOpen}
onClose={handleCloseEditor}
maxWidth="md"
fullWidth
>
<GlobalAlertConditionEditor
condition={editingCondition}
onSave={handleSaveCondition}
onCancel={handleCloseEditor}
/>
</Dialog>
</Box>
);
};
export default GlobalAlertConditionsPanel;
@@ -0,0 +1,524 @@
/**
* WebSocket + /upbit/analyze 재계산 결과를 차트 시리즈에 직접 반영하기 위한 데이터 빌더
* (차트 dispose 없이 setData만 사용)
*/
import type { Time } from 'lightweight-charts';
import type { CandleData, IndicatorData } from '../services/backtestApi';
import { chartTimeValueToUnixSeconds } from '../utils/chartCandleTime';
/** CandlestickChart buildCandleDataRaw / maxUnixFromCandles와 동일 규칙 */
const toUnix = (t: string | number | undefined): number => chartTimeValueToUnixSeconds(t) ?? 0;
/** 캔들 봉 시각(초) — time 외 date/timestamp 폴백 */
function candleOpenTimeUnix(c: CandleData): number {
return toUnix(c.time ?? (c as any).date ?? (c as any).timestamp);
}
/** 표시 캔들 중 가장 늦은 봉의 Unix 초 (서브지표·재계산: 미래 행만 제외 — Set.has는 타임존/형식 불일치로 전부 탈락할 수 있음) */
export function maxUnixFromCandles(displayCandles: CandleData[]): number {
let m = 0;
for (const c of displayCandles) {
const u = candleOpenTimeUnix(c);
if (u > m) m = u;
}
return m;
}
/**
* 서브차트 시리즈: 표시 캔들과 길이가 같으면 각 포인트 time을 해당 캔들 봉 시각으로 덮어씀.
* (/upbit/analyze 지표 time 문자열이 캔들과 어긋나 일목 미래축에 선이 닿는 현상 방지)
*/
export function alignSubChartSeriesToDisplayCandles<T extends { time: Time }>(
displayCandles: CandleData[],
points: T[]
): T[] {
if (points.length === 0 || displayCandles.length === 0) return points;
const maxSec = maxUnixFromCandles(displayCandles);
if (points.length === displayCandles.length) {
return points.map((p, i) => ({
...p,
time: candleOpenTimeUnix(displayCandles[i]) as Time,
}));
}
if (maxSec <= 0) {
return points.filter((p) => (p.time as number) > 0);
}
return points.filter((p) => {
const tu = p.time as number;
return tu > 0 && tu <= maxSec;
});
}
function passesSubChartRowTime(tu: number, maxDisplayCandleSec: number): boolean {
if (tu <= 0 || !isFinite(tu)) return false;
if (maxDisplayCandleSec > 0 && tu > maxDisplayCandleSec) return false;
return true;
}
export function buildMaLineDataFromApi(
displayIndicators: IndicatorData[],
ma: { id: string; period: number },
maxDisplayCandleSec: number
): { time: Time; value: number }[] {
const keyPeriod = `ma${ma.period}`;
const out: { time: Time; value: number }[] = [];
for (const ind of displayIndicators) {
const mv = ind.maValues as Record<string, number> | undefined;
const v =
mv?.[keyPeriod] ??
mv?.[ma.id] ??
(ind as any)[`sma${ma.period}`] ??
(ind as any)[`ma${ma.period}`];
if (v == null || typeof v !== 'number' || !isFinite(v)) continue;
const timeVal = ind.time ?? ind.timestamp ?? ind.date;
const tu = toUnix(timeVal as string);
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
out.push({ time: tu as Time, value: v });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildBollingerFromIndicators(displayIndicators: IndicatorData[]): {
upper: { time: Time; value: number }[];
middle: { time: Time; value: number }[];
lower: { time: Time; value: number }[];
bandBg: Array<{ time: number; upper: number; lower: number }>;
} {
const upper: { time: Time; value: number }[] = [];
const middle: { time: Time; value: number }[] = [];
const lower: { time: Time; value: number }[] = [];
const bandBg: Array<{ time: number; upper: number; lower: number }> = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (d.bollingerUpper != null && isFinite(d.bollingerUpper)) {
upper.push({ time: tu as Time, value: d.bollingerUpper });
}
if (d.bollingerMiddle != null && isFinite(d.bollingerMiddle)) {
middle.push({ time: tu as Time, value: d.bollingerMiddle });
}
if (d.bollingerLower != null && isFinite(d.bollingerLower)) {
lower.push({ time: tu as Time, value: d.bollingerLower });
}
if (
d.bollingerUpper != null &&
d.bollingerLower != null &&
isFinite(d.bollingerUpper) &&
isFinite(d.bollingerLower)
) {
bandBg.push({ time: tu, upper: d.bollingerUpper, lower: d.bollingerLower });
}
}
const sortFn = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
upper.sort(sortFn);
middle.sort(sortFn);
lower.sort(sortFn);
bandBg.sort((a, b) => a.time - b.time);
return { upper, middle, lower, bandBg };
}
/** 일목 오버레이용 — API 행에서 전환/기준/선행만 추출 (선행은 displacement는 호출부에서 처리) */
export function buildIchimokuRowSeries(
rows: IndicatorData[],
maxDisplayCandleSec: number,
opts: { senkouDisplacement: number }
): {
tenkan: { time: number; value: number }[];
kijun: { time: number; value: number }[];
senkouA: { time: number; value: number }[];
senkouB: { time: number; value: number }[];
cloud: Array<{ time: number; senkouA: number; senkouB: number }>;
} {
const { senkouDisplacement } = opts;
const parsed = rows
.map((d) => {
const t = toUnix(d.time ?? d.timestamp);
return {
time: t,
tenkan: d.ichimokuTenkan,
kijun: d.ichimokuKijun,
senkouA: d.ichimokuSenkouA,
senkouB: d.ichimokuSenkouB,
};
})
.filter((x) => x.time > 0);
const ichimokuTimes = parsed.map((p) => p.time);
const lastTime = ichimokuTimes.length > 0 ? ichimokuTimes[ichimokuTimes.length - 1] : 0;
const intervalSec =
ichimokuTimes.length >= 2 ? lastTime - ichimokuTimes[ichimokuTimes.length - 2] : 60;
const tenkan: { time: number; value: number }[] = [];
const kijun: { time: number; value: number }[] = [];
const senkouA: { time: number; value: number }[] = [];
const senkouB: { time: number; value: number }[] = [];
const cloud: Array<{ time: number; senkouA: number; senkouB: number }> = [];
const useDisp = senkouDisplacement > 0 && parsed.length >= 2;
parsed.forEach((d, i) => {
if (d.tenkan != null && isFinite(d.tenkan) && passesSubChartRowTime(d.time, maxDisplayCandleSec)) {
tenkan.push({ time: d.time, value: d.tenkan });
}
if (d.kijun != null && isFinite(d.kijun) && passesSubChartRowTime(d.time, maxDisplayCandleSec)) {
kijun.push({ time: d.time, value: d.kijun });
}
if (d.senkouA != null && d.senkouB != null && isFinite(d.senkouA) && isFinite(d.senkouB)) {
let targetTime = d.time;
if (useDisp) {
targetTime =
i + senkouDisplacement < parsed.length
? ichimokuTimes[i + senkouDisplacement]
: lastTime + (i + senkouDisplacement - parsed.length + 1) * intervalSec;
}
senkouA.push({ time: targetTime, value: d.senkouA });
senkouB.push({ time: targetTime, value: d.senkouB });
cloud.push({ time: targetTime, senkouA: d.senkouA, senkouB: d.senkouB });
}
});
return { tenkan, kijun, senkouA, senkouB, cloud };
}
export function buildVolumeHistogramData(
displayCandles: CandleData[],
colorSettings?: { volumeRising?: string; volumeFalling?: string }
): { time: Time; value: number; color: string }[] {
const rising = colorSettings?.volumeRising || '#ef5350';
const falling = colorSettings?.volumeFalling || '#2196f3';
return displayCandles
.filter((d) => d.volume != null)
.map((d) => {
const tu = toUnix(d.time);
if (tu <= 0) return null;
const isRising = d.close >= d.open;
return {
time: tu as Time,
value: d.volume!,
color: isRising ? rising : falling,
};
})
.filter((x): x is NonNullable<typeof x> => x != null);
}
export function buildMacdSeriesData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
histogram: { time: Time; value: number; color: string }[];
macdLine: { time: Time; value: number }[];
signalLine: { time: Time; value: number }[];
} {
const pos = '#26a69a';
const neg = '#ef5350';
const histogram: { time: Time; value: number; color: string }[] = [];
const macdLine: { time: Time; value: number }[] = [];
const signalLine: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const h = d.macdHistogram ?? (d as any).histogram;
if (h != null && isFinite(h)) {
histogram.push({ time: tu as Time, value: h, color: h >= 0 ? pos : neg });
}
if (d.macdLine != null && isFinite(d.macdLine)) {
macdLine.push({ time: tu as Time, value: d.macdLine });
}
const sig = d.signalLine ?? (d as any).signal;
if (sig != null && isFinite(sig)) {
signalLine.push({ time: tu as Time, value: sig });
}
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
histogram.sort(st);
macdLine.sort(st);
signalLine.sort(st);
return { histogram, macdLine, signalLine };
}
export function buildRsiLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.rsi != null && isFinite(d.rsi)) {
out.push({ time: tu as Time, value: d.rsi });
}
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildStochasticSeriesData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
kLine: { time: Time; value: number }[];
dLine: { time: Time; value: number }[];
} {
const kLine: { time: Time; value: number }[] = [];
const dLine: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const k = d.stochasticK ?? (d as any).stochK;
const dd = d.stochasticD ?? (d as any).stochD;
if (k != null && isFinite(k)) kLine.push({ time: tu as Time, value: k });
if (dd != null && isFinite(dd)) dLine.push({ time: tu as Time, value: dd });
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
kLine.sort(st);
dLine.sort(st);
return { kLine, dLine };
}
export function buildCciLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const v = d.cci13 ?? (d as any).cci;
if (v != null && isFinite(v)) out.push({ time: tu as Time, value: v });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildObvSeriesData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
obv: { time: Time; value: number }[];
signal: { time: Time; value: number }[];
} {
const obv: { time: Time; value: number }[] = [];
const signal: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.obv != null && isFinite(d.obv)) obv.push({ time: tu as Time, value: d.obv });
if (d.obvSignal != null && isFinite(d.obvSignal)) signal.push({ time: tu as Time, value: d.obvSignal });
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
obv.sort(st);
signal.sort(st);
return { obv, signal };
}
export function buildAdxLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.adx != null && isFinite(d.adx)) out.push({ time: tu as Time, value: d.adx });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildDmiSeriesData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
plusDi: { time: Time; value: number }[];
minusDi: { time: Time; value: number }[];
} {
const plusDi: { time: Time; value: number }[] = [];
const minusDi: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const p = (d as any).plusDI ?? (d as any).plusDi;
const m = (d as any).minusDI ?? (d as any).minusDi;
if (p != null && isFinite(p)) plusDi.push({ time: tu as Time, value: p });
if (m != null && isFinite(m)) minusDi.push({ time: tu as Time, value: m });
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
plusDi.sort(st);
minusDi.sort(st);
return { plusDi, minusDi };
}
export function buildWilliamsRLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.williamsR != null && isFinite(d.williamsR)) out.push({ time: tu as Time, value: d.williamsR });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildTrixSeriesData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
trix: { time: Time; value: number }[];
signal: { time: Time; value: number }[];
} {
const trix: { time: Time; value: number }[] = [];
const signal: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.trix != null && isFinite(d.trix)) trix.push({ time: tu as Time, value: d.trix });
if (d.trixSignal != null && isFinite(d.trixSignal)) signal.push({ time: tu as Time, value: d.trixSignal });
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
trix.sort(st);
signal.sort(st);
return { trix, signal };
}
export function buildBwiLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.bwi != null && isFinite(d.bwi)) out.push({ time: tu as Time, value: d.bwi });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildVolumeOscLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.volumeOsc != null && isFinite(d.volumeOsc)) out.push({ time: tu as Time, value: d.volumeOsc });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildVrSeriesData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
vr: { time: Time; value: number }[];
vr2: { time: Time; value: number }[];
} {
const vr: { time: Time; value: number }[] = [];
const vr2: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
if (d.vr != null && isFinite(d.vr)) vr.push({ time: tu as Time, value: d.vr });
if (d.vr2 != null && isFinite(d.vr2)) vr2.push({ time: tu as Time, value: d.vr2 });
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
vr.sort(st);
vr2.sort(st);
return { vr, vr2 };
}
/** 서브차트 이격도와 동일: 원본(95~105 부근)에서 95를 빼서 표시 */
const DISPARITY_CHART_OFFSET = 95;
export function buildDisparityTransformedSeries(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): {
ultraShort: { time: Time; value: number }[];
short: { time: Time; value: number }[];
mid: { time: Time; value: number }[];
long: { time: Time; value: number }[];
} {
const ultraShort: { time: Time; value: number }[] = [];
const short: { time: Time; value: number }[] = [];
const mid: { time: Time; value: number }[] = [];
const long: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const u = (d as any).disparityUltraShort;
const s = (d as any).disparityShort;
const m = (d as any).disparityMid;
const l = (d as any).disparityLong;
if (u != null && isFinite(u)) ultraShort.push({ time: tu as Time, value: u - DISPARITY_CHART_OFFSET });
if (s != null && isFinite(s)) short.push({ time: tu as Time, value: s - DISPARITY_CHART_OFFSET });
if (m != null && isFinite(m)) mid.push({ time: tu as Time, value: m - DISPARITY_CHART_OFFSET });
if (l != null && isFinite(l)) long.push({ time: tu as Time, value: l - DISPARITY_CHART_OFFSET });
}
const st = (a: { time: Time }, b: { time: Time }) => (a.time as number) - (b.time as number);
ultraShort.sort(st);
short.sort(st);
mid.sort(st);
long.sort(st);
return { ultraShort, short, mid, long };
}
export function buildNewPsychologicalLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const v = d.newPsychological ?? (d as any).newPsy;
if (v != null && isFinite(v)) out.push({ time: tu as Time, value: v });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildInvestPsychologicalLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const v = d.investPsychological ?? (d as any).investPsy;
if (v != null && isFinite(v)) out.push({ time: tu as Time, value: v });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
export function buildCciSignalLineData(
displayIndicators: IndicatorData[],
maxDisplayCandleSec = 0
): { time: Time; value: number }[] {
const out: { time: Time; value: number }[] = [];
for (const d of displayIndicators) {
const tu = toUnix(d.time ?? d.timestamp);
if (tu <= 0) continue;
if (!passesSubChartRowTime(tu, maxDisplayCandleSec)) continue;
const v = (d as any).cciSignal;
if (v != null && isFinite(v)) out.push({ time: tu as Time, value: v });
}
return out.sort((a, b) => (a.time as number) - (b.time as number));
}
@@ -0,0 +1,801 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
List,
ListItemButton,
ListItemText,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
AddLink,
DeleteOutline,
Link as LinkIcon,
Refresh,
TableChart,
} from '@mui/icons-material';
import {
dbExplorerApi,
DbExplorerColumn,
DbExplorerConnection,
DbExplorerRowsResponse,
DbExplorerTable,
} from '../../services/dbExplorerApi';
const DEFAULT_PAGE_SIZE = 50;
function isMysqlLinkFailure(msg: string | null | undefined): boolean {
if (!msg) return false;
const m = msg.toLowerCase();
return (
m.includes('communications link failure') ||
m.includes('not received any packets') ||
m.includes('connection refused')
);
}
function formatCell(v: unknown): string {
if (v === null || v === undefined) return '';
if (typeof v === 'object') {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
return String(v);
}
/** jdbc:mysql://host:3306/dbname?... → dbname */
function jdbcDatabaseName(jdbc: string | undefined): string {
if (!jdbc) return '—';
const m = jdbc.match(/\/\/[^/]+\/([^/?]+)/);
return m ? decodeURIComponent(m[1]) : '—';
}
const DashboardDbExplorerTab: React.FC = () => {
const [connections, setConnections] = useState<DbExplorerConnection[]>([]);
const [activeConnId, setActiveConnId] = useState<string>('default');
const [tables, setTables] = useState<DbExplorerTable[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [columns, setColumns] = useState<DbExplorerColumn[]>([]);
const [rowsBody, setRowsBody] = useState<DbExplorerRowsResponse | null>(null);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [sortColumn, setSortColumn] = useState<string>('');
const [sortDir, setSortDir] = useState<'ASC' | 'DESC'>('ASC');
const [searchColumn, setSearchColumn] = useState<string>('');
const [searchText, setSearchText] = useState('');
/** 서버로 전달 중인 검색(적용 버튼으로만 갱신) */
const [appliedSearchColumn, setAppliedSearchColumn] = useState<string>('');
const [appliedSearchText, setAppliedSearchText] = useState('');
const [selectedCell, setSelectedCell] = useState<{ column: string; value: unknown } | null>(null);
const [loadConnErr, setLoadConnErr] = useState<string | null>(null);
const [tablesErr, setTablesErr] = useState<string | null>(null);
const [rowsErr, setRowsErr] = useState<string | null>(null);
/** 테이블 목록 로딩 (행 조회와 분리 — Tree/List가 사라지는 현상 방지) */
const [tablesLoading, setTablesLoading] = useState(false);
const [rowsBusy, setRowsBusy] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [formLabel, setFormLabel] = useState('로컬 MySQL');
const [formHost, setFormHost] = useState('127.0.0.1');
const [formPort, setFormPort] = useState('3306');
const [formDb, setFormDb] = useState('stockAnalyzer');
const [formUser, setFormUser] = useState('stock');
const [formPass, setFormPass] = useState('');
const [formSsl, setFormSsl] = useState(false);
const [formApk, setFormApk] = useState(true);
const [formErr, setFormErr] = useState<string | null>(null);
const refreshConnections = useCallback(async () => {
setLoadConnErr(null);
try {
const list = await dbExplorerApi.listConnections();
setConnections(list);
setActiveConnId((prev) => (list.some((c) => c.id === prev) ? prev : 'default'));
} catch (e) {
setLoadConnErr(dbExplorerApi.errMessage(e));
}
}, []);
useEffect(() => {
void refreshConnections();
// 최초 연결 목록만 로드 (refreshConnections는 안정 참조)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadTables = useCallback(async () => {
setTablesErr(null);
setTablesLoading(true);
setTables([]);
setSelectedTable(null);
setRowsBody(null);
setColumns([]);
setAppliedSearchColumn('');
setAppliedSearchText('');
try {
const t = await dbExplorerApi.listTables(activeConnId);
setTables(t);
} catch (e) {
setTablesErr(dbExplorerApi.errMessage(e));
} finally {
setTablesLoading(false);
}
}, [activeConnId]);
useEffect(() => {
void loadTables();
}, [loadTables]);
const loadRows = useCallback(async () => {
if (!selectedTable) return;
setRowsErr(null);
setRowsBusy(true);
try {
const st = appliedSearchText.trim();
const sc = st ? appliedSearchColumn || undefined : undefined;
const body = await dbExplorerApi.fetchRows(activeConnId, selectedTable, {
page,
size: pageSize,
sortColumn: sortColumn || undefined,
sortDir,
searchColumn: sc,
search: st,
});
setRowsBody(body);
if (body.columnNames?.length && !sortColumn) {
setSortColumn(body.columnNames[0]);
}
} catch (e) {
setRowsErr(dbExplorerApi.errMessage(e));
setRowsBody(null);
} finally {
setRowsBusy(false);
}
}, [activeConnId, selectedTable, page, pageSize, sortColumn, sortDir, appliedSearchColumn, appliedSearchText]);
useEffect(() => {
if (!selectedTable) return;
let cancelled = false;
(async () => {
try {
const cols = await dbExplorerApi.listColumns(activeConnId, selectedTable);
if (!cancelled) {
setColumns(cols);
if (cols.length && !searchColumn) {
setSearchColumn(cols[0].name);
}
}
} catch {
if (!cancelled) setColumns([]);
}
})();
return () => {
cancelled = true;
};
}, [activeConnId, selectedTable]);
useEffect(() => {
if (!selectedTable) return;
void loadRows();
}, [selectedTable, loadRows]);
const activeConn = useMemo(
() => connections.find((c) => c.id === activeConnId),
[connections, activeConnId]
);
/** 그리드 헤더: 행 응답 전에는 메타 API 컬럼명으로 표시 */
const dataGridColumns = useMemo(() => {
if (!selectedTable) return [] as string[];
if (rowsBody?.columnNames?.length) return rowsBody.columnNames;
return columns.map((c) => c.name);
}, [selectedTable, rowsBody, columns]);
const openNewConnection = () => {
setFormErr(null);
setDialogOpen(true);
};
const submitConnect = async () => {
setFormErr(null);
const port = parseInt(formPort, 10);
if (!formLabel.trim() || !formHost.trim() || !formDb.trim() || !formUser.trim()) {
setFormErr('이름·호스트·DB·사용자는 필수입니다.');
return;
}
if (Number.isNaN(port) || port < 1 || port > 65535) {
setFormErr('포트는 1~65535 숫자여야 합니다.');
return;
}
try {
const created = await dbExplorerApi.connect({
label: formLabel.trim(),
host: formHost.trim(),
port,
database: formDb.trim(),
username: formUser.trim(),
password: formPass,
useSsl: formSsl,
allowPublicKeyRetrieval: formApk,
});
setDialogOpen(false);
setFormPass('');
await refreshConnections();
setActiveConnId(created.id);
} catch (e) {
setFormErr(dbExplorerApi.errMessage(e));
}
};
const disconnectOne = async (id: string) => {
if (id === 'default') return;
try {
await dbExplorerApi.disconnect(id);
await refreshConnections();
if (activeConnId === id) {
setActiveConnId('default');
}
} catch (e) {
setLoadConnErr(dbExplorerApi.errMessage(e));
}
};
const selectTable = (name: string) => {
setSelectedTable(name);
setRowsBody(null);
setColumns([]);
setPage(0);
setSelectedCell(null);
setAppliedSearchColumn('');
setAppliedSearchText('');
};
const exportTsv = () => {
if (!rowsBody?.columnNames?.length) return;
const header = rowsBody.columnNames.join('\t');
const lines = rowsBody.rows.map((row) =>
rowsBody.columnNames.map((c) => formatCell(row[c]).replace(/\t/g, ' ')).join('\t')
);
const blob = new Blob([header + '\n' + lines.join('\n')], { type: 'text/tab-separated-values;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${selectedTable || 'export'}.tsv`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
DBeaver처럼 MySQL . <strong> </strong> ,
. .
</Typography>
{loadConnErr && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setLoadConnErr(null)}>
{loadConnErr}
</Alert>
)}
{tablesErr && (
<Alert severity="warning" sx={{ mb: 2 }} onClose={() => setTablesErr(null)}>
{tablesErr}
</Alert>
)}
{rowsErr && (
<Alert severity="warning" sx={{ mb: 2 }} onClose={() => setRowsErr(null)}>
{rowsErr}
</Alert>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
minHeight: { xs: 480, md: 'calc(100vh - 260px)' },
maxHeight: { md: 'calc(100vh - 200px)' },
}}
>
<Box sx={{ display: 'flex', flex: 1, gap: 1.5, minHeight: 0, minWidth: 0, flexDirection: { xs: 'column', md: 'row' } }}>
{/* 좌측: DB 클라이언트 네비게이터 — 연결 + 현재 DB + 테이블 목록 */}
<Paper
sx={{
width: { xs: '100%', md: 320 },
maxWidth: '100%',
flexShrink: 0,
p: 1.5,
display: 'flex',
flexDirection: 'column',
minHeight: { xs: 320, md: 0 },
maxHeight: { md: '100%' },
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LinkIcon fontSize="small" />
</Typography>
<Tooltip title="연결 목록 새로고침">
<IconButton size="small" onClick={() => void refreshConnections()} disabled={tablesLoading}>
<Refresh fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Button variant="contained" size="small" startIcon={<AddLink />} onClick={openNewConnection} sx={{ mb: 1 }}>
</Button>
<List dense disablePadding sx={{ maxHeight: { xs: 200, md: '36%' }, flexShrink: 0, overflow: 'auto', mb: 1 }}>
{connections.map((c) => (
<Box key={c.id} sx={{ display: 'flex', alignItems: 'stretch' }}>
<ListItemButton
selected={c.id === activeConnId}
onClick={() => {
setActiveConnId(c.id);
setSelectedTable(null);
}}
sx={{ flex: 1, py: 0.5 }}
>
<ListItemText
primary={c.label}
secondary={c.kind}
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItemButton>
{c.kind === 'CUSTOM' && (
<Tooltip title="연결 끊기">
<IconButton
size="small"
sx={{ alignSelf: 'center' }}
onClick={(ev) => {
ev.stopPropagation();
void disconnectOne(c.id);
}}
>
<DeleteOutline fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</List>
<Divider sx={{ my: 1 }} />
{activeConn && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="text.secondary" display="block">
</Typography>
<Typography variant="body2" fontWeight={600} sx={{ wordBreak: 'break-all' }}>
{jdbcDatabaseName(activeConn.jdbcMasked)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, wordBreak: 'break-all' }}>
{activeConn.jdbcMasked}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TableChart fontSize="small" /> ({tables.length})
</Typography>
<Tooltip title="테이블 목록 다시 불러오기">
<span>
<IconButton size="small" disabled={tablesLoading} onClick={() => void loadTables()}>
{tablesLoading ? <CircularProgress size={18} /> : <Refresh fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{ flex: 1, minHeight: 120, overflow: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
{tablesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : tables.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
. .
</Typography>
) : (
<List dense disablePadding>
{tables.map((t) => (
<ListItemButton
key={t.name}
selected={selectedTable === t.name}
onClick={() => selectTable(t.name)}
sx={{ py: 0.75, alignItems: 'flex-start' }}
>
<ListItemText
primary={t.name}
secondary={
t.approxSizeKb != null || t.tableRowsApprox != null
? `${t.approxSizeKb != null ? `${t.approxSizeKb} KB` : ''}${
t.tableRowsApprox != null ? ` · 약 ${Number(t.tableRowsApprox).toLocaleString()}` : ''
}`
: undefined
}
primaryTypographyProps={{ variant: 'body2', fontFamily: 'monospace', fontSize: '0.8rem' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItemButton>
))}
</List>
)}
</Box>
</Paper>
{/* 우측: 선택 테이블 데이터 그리드 */}
<Paper
sx={{
flex: 1,
minWidth: 0,
p: 1.5,
display: 'flex',
flexDirection: 'column',
minHeight: { xs: 400, md: 0 },
maxHeight: { md: '100%' },
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1, mb: 1, flexShrink: 0 }}>
<Typography variant="subtitle1" sx={{ flex: '1 1 auto', minWidth: 0 }} noWrap>
{selectedTable
? `${jdbcDatabaseName(activeConn?.jdbcMasked)} · ${selectedTable}`
: '테이블을 왼쪽에서 선택하세요'}
</Typography>
<Button size="small" variant="outlined" disabled={!rowsBody} onClick={exportTsv}>
TSV보내기
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Refresh />}
disabled={!selectedTable || rowsBusy}
onClick={() => void loadRows()}
>
</Button>
{rowsBusy && <CircularProgress size={22} sx={{ ml: 0.5 }} />}
</Box>
{!selectedTable ? (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary',
border: 1,
borderColor: 'divider',
borderRadius: 1,
borderStyle: 'dashed',
}}
>
<Typography variant="body2"> .</Typography>
</Box>
) : (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1, alignItems: 'center', flexShrink: 0 }}>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel> </InputLabel>
<Select
label="정렬 컬럼"
value={sortColumn || (columns[0]?.name ?? '')}
onChange={(e) => setSortColumn(String(e.target.value))}
>
{columns.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select label="순서" value={sortDir} onChange={(e) => setSortDir(e.target.value as 'ASC' | 'DESC')}>
<MenuItem value="ASC">ASC</MenuItem>
<MenuItem value="DESC">DESC</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel> </InputLabel>
<Select
label="검색 컬럼"
value={searchColumn || (columns[0]?.name ?? '')}
onChange={(e) => setSearchColumn(String(e.target.value))}
>
{columns.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="검색 (LIKE)"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="값 일부 입력"
sx={{ flex: '1 1 200px', minWidth: 160 }}
/>
<Button
size="small"
variant="contained"
disabled={rowsBusy}
onClick={() => {
setAppliedSearchColumn(searchColumn);
setAppliedSearchText(searchText.trim());
setPage(0);
}}
>
</Button>
</Box>
<TableContainer
sx={{
flex: 1,
minHeight: 200,
overflow: 'auto',
border: 1,
borderColor: 'divider',
borderRadius: 1,
}}
>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
{(dataGridColumns.length ? dataGridColumns : ['…']).map((col) => (
<TableCell key={col} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: 'background.paper' }}>
{col}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rowsBusy && !rowsBody ? (
<TableRow>
<TableCell
colSpan={Math.max(dataGridColumns.length || 1, 1)}
align="center"
sx={{ py: 6 }}
>
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : !rowsBody || !rowsBody.columnNames?.length ? (
<TableRow>
<TableCell
colSpan={Math.max(dataGridColumns.length || 1, 1)}
align="center"
sx={{ py: 4 }}
>
<Typography color="text.secondary"> .</Typography>
</TableCell>
</TableRow>
) : rowsBody.rows.length === 0 ? (
<TableRow>
<TableCell colSpan={Math.max(rowsBody.columnNames.length, 1)} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary"> .</Typography>
</TableCell>
</TableRow>
) : (
rowsBody.rows.map((row, ri) => (
<TableRow key={ri} hover>
{rowsBody.columnNames.map((col) => (
<TableCell
key={col}
onClick={() => setSelectedCell({ column: col, value: row[col] })}
sx={{
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
bgcolor:
selectedCell?.column === col && selectedCell?.value === row[col]
? 'action.selected'
: undefined,
}}
>
{formatCell(row[col])}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{rowsBody && (
<TablePagination
component="div"
sx={{ flexShrink: 0, borderTop: 1, borderColor: 'divider' }}
count={rowsBody.total}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[25, 50, 100, 200]}
labelRowsPerPage="행 수"
labelDisplayedRows={({ from, to, count }) => `${from}-${to} / ${count}`}
/>
)}
{selectedCell && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'action.hover', borderRadius: 1, flexShrink: 0 }}>
<Typography variant="caption" color="text.secondary">
: {selectedCell.column}
</Typography>
<Typography
component="pre"
variant="body2"
sx={{ m: 0, mt: 0.5, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontFamily: 'monospace' }}
>
{formatCell(selectedCell.value)}
</Typography>
</Box>
)}
</>
)}
</Paper>
</Box>
</Box>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle> MySQL </DialogTitle>
<DialogContent dividers>
{formErr && (
<Alert severity="error" sx={{ mb: 2 }}>
{formErr}
</Alert>
)}
{formErr && isMysqlLinkFailure(formErr) && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2" component="div" sx={{ fontWeight: 600, mb: 0.5 }}>
MySQL에
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2.5, mb: 0, '& li': { mb: 0.75 } }}>
<Box component="li">
<strong>Docker</strong> DB도 <code>docker compose</code> MySQL이면 {' '}
<code>127.0.0.1</code> <code>mysql</code>, <code>3306</code>.
</Box>
<Box component="li">
Docker인데 MySQL은 <strong>/ </strong> <code>host.docker.internal:3306</code>
(). Linux는 compose에 <code>extra_hosts: host.docker.internal:host-gateway</code> .
</Box>
<Box component="li">
<strong> </strong>(: exdev) MySQL이면 · <strong> IP</strong> 3306
. PC <strong> </strong> .
</Box>
<Box component="li">
· MySQL이 <code>nc -zv 3306</code> .
</Box>
</Box>
</Alert>
)}
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
. .
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
<strong>Docker </strong> <code>127.0.0.1</code> PC가 . PC의
MySQL이면 <strong>host.docker.internal</strong> , IDE에서 {' '}
<code>127.0.0.1</code> .
</Alert>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
<Button
size="small"
variant="outlined"
onClick={() => {
setFormHost('127.0.0.1');
setFormPort('3306');
setFormDb('stockAnalyzer');
setFormLabel('로컬 127.0.0.1');
}}
>
127.0.0.1
</Button>
<Button
size="small"
variant="outlined"
onClick={() => {
setFormHost('host.docker.internal');
setFormPort('3306');
setFormDb('stockAnalyzer');
setFormLabel('Docker→호스트 MySQL');
}}
>
host.docker.internal
</Button>
<Button
size="small"
variant="outlined"
onClick={() => {
setFormHost('exdev.co.kr');
setFormPort('3306');
setFormDb('stockAnalyzer');
setFormLabel('서버 exdev.co.kr');
}}
>
exdev.co.kr
</Button>
<Button
size="small"
variant="outlined"
onClick={() => {
setFormHost('mysql');
setFormPort('3306');
setFormDb('stockAnalyzer');
setFormLabel('Compose 서비스 mysql');
}}
>
mysql ( )
</Button>
</Box>
<TextField fullWidth margin="dense" label="연결 표시 이름" value={formLabel} onChange={(e) => setFormLabel(e.target.value)} />
<TextField fullWidth margin="dense" label="호스트" value={formHost} onChange={(e) => setFormHost(e.target.value)} />
<TextField fullWidth margin="dense" label="포트" value={formPort} onChange={(e) => setFormPort(e.target.value)} />
<TextField fullWidth margin="dense" label="데이터베이스" value={formDb} onChange={(e) => setFormDb(e.target.value)} />
<TextField fullWidth margin="dense" label="사용자" value={formUser} onChange={(e) => setFormUser(e.target.value)} />
<TextField
fullWidth
margin="dense"
label="비밀번호"
type="password"
value={formPass}
onChange={(e) => setFormPass(e.target.value)}
autoComplete="new-password"
/>
<FormControlLabel control={<Checkbox checked={formSsl} onChange={(e) => setFormSsl(e.target.checked)} />} label="useSSL" />
<FormControlLabel
control={<Checkbox checked={formApk} onChange={(e) => setFormApk(e.target.checked)} />}
label="allowPublicKeyRetrieval"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}></Button>
<Button variant="contained" onClick={() => void submitConnect()}>
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default DashboardDbExplorerTab;
@@ -0,0 +1,272 @@
import React from 'react';
interface GaugeCardProps {
title: string;
subtitle: string;
value: string | number;
subValue: string;
percentage: number;
maxPercentage?: number;
color: string;
gradientColors: {
start: string;
middle?: string;
end: string;
};
}
const GaugeCard: React.FC<GaugeCardProps> = ({
title,
subtitle,
value,
subValue,
percentage,
maxPercentage = 100,
color,
gradientColors,
}) => {
// 게이지는 -135도에서 시작해서 135도까지 (총 270도)
const startAngle = -135;
const endAngle = 135;
const totalAngle = endAngle - startAngle;
// 현재 값에 따른 각도 계산
const currentAngle = startAngle + (percentage / maxPercentage) * totalAngle;
// SVG 경로 생성 함수
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
const angleInRadians = (angleInDegrees * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const describeArc = (x: number, y: number, radius: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
return [
'M', start.x, start.y,
'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y
].join(' ');
};
const centerX = 100;
const centerY = 100;
const radius = 75;
const trackRadius = 75;
// 바늘 끝점 계산
const needleLength = 60;
const needleAngleRad = (currentAngle * Math.PI) / 180;
const needleEndX = centerX + needleLength * Math.cos(needleAngleRad);
const needleEndY = centerY + needleLength * Math.sin(needleAngleRad);
const gradientId = `gradient-${title.replace(/\s+/g, '-')}`;
const glowId = `glow-${title.replace(/\s+/g, '-')}`;
return (
<div
className="relative rounded-2xl p-6 border shadow-2xl backdrop-blur-sm transition-all duration-300 hover:shadow-3xl hover:-translate-y-1"
style={{
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
borderColor: 'rgba(71, 85, 105, 0.5)',
minWidth: '240px',
}}
>
{/* SVG 게이지 */}
<div className="flex justify-center mb-2 relative">
<svg width="180" height="180" viewBox="0 0 200 200" className="overflow-visible">
<defs>
{/* 게이지 그라데이션 */}
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={{ stopColor: gradientColors.start, stopOpacity: 1 }} />
{gradientColors.middle && (
<stop offset="50%" style={{ stopColor: gradientColors.middle, stopOpacity: 1 }} />
)}
<stop offset="100%" style={{ stopColor: gradientColors.end, stopOpacity: 1 }} />
</linearGradient>
{/* 글로우 효과 */}
<filter id={glowId}>
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
{/* 외부 글로우 */}
<filter id={`${glowId}-outer`}>
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feFlood floodColor={color} floodOpacity="0.6"/>
<feComposite in2="coloredBlur" operator="in"/>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* 외부 원 테두리 */}
<circle
cx={centerX}
cy={centerY}
r={85}
fill="none"
stroke="rgba(71, 85, 105, 0.3)"
strokeWidth="1"
/>
{/* 내부 배경 원 */}
<circle
cx={centerX}
cy={centerY}
r={78}
fill="url(#radial-bg)"
className="opacity-40"
/>
<defs>
<radialGradient id="radial-bg">
<stop offset="0%" stopColor="rgba(30, 41, 59, 0.8)" />
<stop offset="100%" stopColor="rgba(15, 23, 42, 0.9)" />
</radialGradient>
</defs>
{/* 게이지 트랙 (배경) */}
<path
d={describeArc(centerX, centerY, trackRadius, startAngle, endAngle)}
fill="none"
stroke="rgba(71, 85, 105, 0.2)"
strokeWidth="12"
strokeLinecap="round"
/>
{/* 게이지 진행 바 */}
<path
d={describeArc(centerX, centerY, radius, startAngle, currentAngle)}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth="12"
strokeLinecap="round"
filter={`url(#${glowId}-outer)`}
className="transition-all duration-1000 ease-out"
/>
{/* 바늘 그림자 */}
<line
x1={centerX}
y1={centerY}
x2={needleEndX + 1}
y2={needleEndY + 1}
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="3"
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
{/* 바늘 */}
<line
x1={centerX}
y1={centerY}
x2={needleEndX}
y2={needleEndY}
stroke="rgba(255, 255, 255, 0.95)"
strokeWidth="2.5"
strokeLinecap="round"
filter={`url(#${glowId})`}
className="transition-all duration-1000 ease-out"
/>
{/* 바늘 중심점 외곽 그림자 */}
<circle
cx={centerX}
cy={centerY}
r="7"
fill="rgba(0, 0, 0, 0.3)"
/>
{/* 바늘 중심점 */}
<circle
cx={centerX}
cy={centerY}
r="6"
fill={color}
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth="1.5"
filter={`url(#${glowId})`}
/>
{/* 바늘 중심점 하이라이트 */}
<circle
cx={centerX - 1.5}
cy={centerY - 1.5}
r="2.5"
fill="rgba(255, 255, 255, 0.6)"
/>
{/* 내부 어두운 원 */}
<circle
cx={centerX}
cy={centerY}
r={62}
fill="rgba(15, 23, 42, 0.95)"
stroke="rgba(71, 85, 105, 0.2)"
strokeWidth="0.5"
/>
</svg>
</div>
{/* 중앙 값 표시 - SVG 위에 오버레이 */}
<div
className="absolute left-1/2 transform -translate-x-1/2 pointer-events-none z-10"
style={{ top: '80px' }}
>
<div
className="text-center"
>
<div
className="font-bold text-white tracking-tight leading-none"
style={{
fontSize: '2rem',
textShadow: `0 0 20px ${color}, 0 0 40px ${color}40`,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
{value}
</div>
<div
className="font-semibold mt-1.5"
style={{
fontSize: '0.8rem',
color: color,
textShadow: `0 0 10px ${color}`,
}}
>
{percentage.toFixed(percentage < 10 ? 2 : 1)}%
</div>
</div>
</div>
{/* 하단 라벨 */}
<div className="text-center mt-2">
<div
className="font-semibold text-white mb-1 leading-tight"
style={{ fontSize: '0.95rem' }}
>
{title}
</div>
<div
className="text-slate-400"
style={{ fontSize: '0.7rem' }}
>
{subtitle}
</div>
</div>
</div>
);
};
export default GaugeCard;
@@ -0,0 +1,317 @@
import React from 'react';
import { Box, Typography, styled } from '@mui/material';
interface GaugeCardMUIProps {
title: string;
subtitle: string;
value: string | number;
subValue: string;
percentage: number;
maxPercentage?: number;
color: string;
gradientColors: {
start: string;
middle?: string;
end: string;
};
}
const StyledCard = styled(Box)(({ theme }) => ({
position: 'relative',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
borderRadius: '20px',
padding: '24px',
border: '1px solid rgba(71, 85, 105, 0.5)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(8px)',
minWidth: '240px',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 12px 48px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.08)',
},
}));
const GaugeCardMUI: React.FC<GaugeCardMUIProps> = ({
title,
subtitle,
value,
subValue,
percentage,
maxPercentage = 100,
color,
gradientColors,
}) => {
// 게이지는 -135도에서 시작해서 135도까지 (총 270도)
const startAngle = -135;
const endAngle = 135;
const totalAngle = endAngle - startAngle;
// 현재 값에 따른 각도 계산
const currentAngle = startAngle + (percentage / maxPercentage) * totalAngle;
// SVG 경로 생성 함수
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
const angleInRadians = (angleInDegrees * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const describeArc = (x: number, y: number, radius: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
return [
'M', start.x, start.y,
'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y
].join(' ');
};
const centerX = 100;
const centerY = 100;
const radius = 75;
const trackRadius = 75;
// 바늘 끝점 계산
const needleLength = 60;
const needleAngleRad = (currentAngle * Math.PI) / 180;
const needleEndX = centerX + needleLength * Math.cos(needleAngleRad);
const needleEndY = centerY + needleLength * Math.sin(needleAngleRad);
const gradientId = `gradient-${title.replace(/\s+/g, '-')}`;
const glowId = `glow-${title.replace(/\s+/g, '-')}`;
return (
<StyledCard>
{/* SVG 게이지 */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, position: 'relative' }}>
<svg width="180" height="180" viewBox="0 0 200 200" style={{ overflow: 'visible' }}>
<defs>
{/* 게이지 그라데이션 */}
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={{ stopColor: gradientColors.start, stopOpacity: 1 }} />
{gradientColors.middle && (
<stop offset="50%" style={{ stopColor: gradientColors.middle, stopOpacity: 1 }} />
)}
<stop offset="100%" style={{ stopColor: gradientColors.end, stopOpacity: 1 }} />
</linearGradient>
{/* 글로우 효과 */}
<filter id={glowId}>
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
{/* 배경 그라데이션 */}
<radialGradient id="radial-bg">
<stop offset="0%" stopColor="rgba(30, 41, 59, 0.8)" />
<stop offset="100%" stopColor="rgba(15, 23, 42, 0.9)" />
</radialGradient>
</defs>
{/* 외부 원 테두리 */}
<circle
cx={centerX}
cy={centerY}
r={85}
fill="none"
stroke="rgba(71, 85, 105, 0.3)"
strokeWidth="1"
/>
{/* 내부 배경 원 */}
<circle
cx={centerX}
cy={centerY}
r={78}
fill="url(#radial-bg)"
opacity="0.4"
/>
{/* 게이지 트랙 (배경) */}
<path
d={describeArc(centerX, centerY, trackRadius, startAngle, endAngle)}
fill="none"
stroke="rgba(71, 85, 105, 0.2)"
strokeWidth="12"
strokeLinecap="round"
/>
{/* 게이지 진행 바 - 배경 글로우 (가장 바깥쪽) */}
<path
d={describeArc(centerX, centerY, radius, startAngle, currentAngle)}
fill="none"
stroke={color}
strokeWidth="16"
strokeLinecap="round"
opacity="0.15"
style={{
transition: 'all 1s ease-out',
filter: 'blur(8px)',
}}
/>
{/* 게이지 진행 바 - 중간 글로우 */}
<path
d={describeArc(centerX, centerY, radius, startAngle, currentAngle)}
fill="none"
stroke={color}
strokeWidth="14"
strokeLinecap="round"
opacity="0.3"
style={{
transition: 'all 1s ease-out',
filter: 'blur(4px)',
}}
/>
{/* 게이지 진행 바 - 메인 */}
<path
d={describeArc(centerX, centerY, radius, startAngle, currentAngle)}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth="12"
strokeLinecap="round"
style={{
transition: 'all 1s ease-out',
}}
/>
{/* 바늘 그림자 */}
<line
x1={centerX}
y1={centerY}
x2={needleEndX + 1}
y2={needleEndY + 1}
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="3"
strokeLinecap="round"
style={{
transition: 'all 1s ease-out',
}}
/>
{/* 바늘 */}
<line
x1={centerX}
y1={centerY}
x2={needleEndX}
y2={needleEndY}
stroke="rgba(255, 255, 255, 0.95)"
strokeWidth="2.5"
strokeLinecap="round"
filter={`url(#${glowId})`}
style={{
transition: 'all 1s ease-out',
}}
/>
{/* 바늘 중심점 외곽 그림자 */}
<circle
cx={centerX}
cy={centerY}
r="7"
fill="rgba(0, 0, 0, 0.3)"
/>
{/* 바늘 중심점 */}
<circle
cx={centerX}
cy={centerY}
r="6"
fill={color}
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth="1.5"
filter={`url(#${glowId})`}
/>
{/* 바늘 중심점 하이라이트 */}
<circle
cx={centerX - 1.5}
cy={centerY - 1.5}
r="2.5"
fill="rgba(255, 255, 255, 0.6)"
/>
{/* 내부 어두운 원 */}
<circle
cx={centerX}
cy={centerY}
r={62}
fill="rgba(15, 23, 42, 0.95)"
stroke="rgba(71, 85, 105, 0.2)"
strokeWidth="0.5"
/>
</svg>
{/* 중앙 값 표시 - SVG 위에 오버레이 */}
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
pointerEvents: 'none',
zIndex: 10,
}}
>
<Typography
sx={{
fontSize: '2rem',
fontWeight: 700,
color: '#fff',
lineHeight: 1,
letterSpacing: '-0.5px',
textShadow: `0 0 20px ${color}, 0 0 40px ${color}40`,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
{value}
</Typography>
<Typography
sx={{
fontSize: '0.8rem',
fontWeight: 600,
color: color,
mt: 0.75,
textShadow: `0 0 10px ${color}`,
}}
>
{percentage.toFixed(percentage < 10 ? 2 : 1)}%
</Typography>
</Box>
</Box>
{/* 하단 라벨 */}
<Box sx={{ textAlign: 'center', mt: 1 }}>
<Typography
sx={{
fontSize: '0.95rem',
fontWeight: 600,
color: '#fff',
mb: 0.5,
lineHeight: 1.3,
}}
>
{title}
</Typography>
<Typography
sx={{
fontSize: '0.7rem',
color: 'rgba(148, 163, 184, 0.8)',
}}
>
{subtitle}
</Typography>
</Box>
</StyledCard>
);
};
export default GaugeCardMUI;
@@ -0,0 +1,91 @@
import React from 'react';
import GaugeCard from './GaugeCard';
interface GaugeDashboardProps {
data?: {
insertSpeed?: number;
diskUsage?: number;
queryTime?: number;
};
}
const GaugeDashboard: React.FC<GaugeDashboardProps> = ({ data }) => {
const gaugeData = [
{
title: '실시간 인서트 속도',
subtitle: '(Rows/sec)',
value: data?.insertSpeed?.toLocaleString() || '12,500',
subValue: '',
percentage: 95,
maxPercentage: 100,
color: '#4ade80',
gradientColors: {
start: '#10b981',
middle: '#4ade80',
end: '#86efac',
},
},
{
title: '디스크 게이지',
subtitle: '(SSD 500GB+ 기준)',
value: `${data?.diskUsage?.toFixed(1) || '245.6'} GB`,
subValue: '',
percentage: 49.12,
maxPercentage: 100,
color: '#a3e635',
gradientColors: {
start: '#84cc16',
middle: '#a3e635',
end: '#bef264',
},
},
{
title: '평균 쿼리 응답 지연시간',
subtitle: '(ms)',
value: data?.queryTime?.toLocaleString() || '1,240',
subValue: '',
percentage: 49.6, // 1240ms / 2500ms * 100
maxPercentage: 100,
color: '#fbbf24',
gradientColors: {
start: '#f59e0b',
middle: '#fbbf24',
end: '#fcd34d',
},
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-8">
{/* 메인 컨테이너 */}
<div className="max-w-7xl mx-auto">
{/* 게이지 카드 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{gaugeData.map((gauge, index) => (
<div key={index} className="relative">
<GaugeCard
title={gauge.title}
subtitle={gauge.subtitle}
value={gauge.value}
subValue={gauge.subValue}
percentage={gauge.percentage}
maxPercentage={gauge.maxPercentage}
color={gauge.color}
gradientColors={gauge.gradientColors}
/>
</div>
))}
</div>
{/* 하단 인증 정보 */}
<div className="mt-8 text-center">
<p className="text-xs text-slate-500">
Authenticated User: test | Access Key: test
</p>
</div>
</div>
</div>
);
};
export default GaugeDashboard;
@@ -0,0 +1,200 @@
# React Gauge Component 사용 가이드
## 📦 설치된 패키지
```bash
npm install react-gauge-component
```
## 🎯 구현된 컴포넌트
### 1. RealtimeWebSocketGauges
**경로**: `/frontend/src/components/dashboard/RealtimeWebSocketGauges.tsx`
실시간 WebSocket 데이터 수신 현황을 3개의 게이지로 표시하는 컴포넌트입니다.
#### 주요 기능
- ✅ Frontend, Backend, Data Service 3개의 실시간 게이지
- ✅ 초당 메시지 수(msg/s) 표시
- ✅ 자동 색상 구간 (0-100)
- ✅ Elastic 포인터 애니메이션
- ✅ 연결 수 및 메시지 통계 표시
#### Props
```typescript
interface RealtimeWebSocketGaugesProps {
websocketStats: {
totalConnections: number;
activeConnections: number;
serviceStats: Record<string, {
serviceName: string;
connections: number;
messagesReceived: number;
messagesSent: number;
messagesPerSecond: number;
lastActivity: string | null;
}>;
};
}
```
#### 사용 예시
```tsx
import RealtimeWebSocketGauges from './components/dashboard/RealtimeWebSocketGauges';
<RealtimeWebSocketGauges websocketStats={data.websocketStats} />
```
### 2. GaugeCardMUI
**경로**: `/frontend/src/components/dashboard/GaugeCardMUI.tsx`
SVG를 사용한 커스텀 게이지 카드 컴포넌트입니다.
#### 주요 기능
- ✅ 270도 호 게이지
- ✅ 그라데이션 색상
- ✅ 네온 글로우 효과
- ✅ 애니메이션 바늘
- ✅ 중앙 값 표시
## 🎨 게이지 스타일 커스터마이징
### react-gauge-component 주요 옵션
```typescript
<GaugeComponent
type="semicircle" // 'semicircle' | 'radial' | 'grafana'
arc={{
width: 0.2, // 게이지 바 두께 (0-1)
padding: 0.005, // 서브 아크 간 간격
cornerRadius: 7, // 모서리 둥글기
subArcs: [ // 색상 구간 정의
{
limit: 20,
color: '#4ade80',
showTick: true, // 틱 마크 표시
},
// ... 추가 구간
],
}}
pointer={{
color: '#fff', // 바늘 색상
length: 0.80, // 바늘 길이 (0-1)
width: 15, // 바늘 두께
elastic: true, // 탄성 애니메이션
}}
labels={{
valueLabel: {
formatTextValue: (value) => `${value.toFixed(1)} msg/s`,
style: {
fontSize: '28px',
fill: '#fff',
textShadow: '0 0 10px rgba(74, 222, 128, 0.8)',
},
},
tickLabels: {
type: 'outer', // 'inner' | 'outer'
ticks: [
{ value: 0 },
{ value: 50 },
{ value: 100 },
],
},
}}
value={75} // 현재 값
minValue={0} // 최소값
maxValue={100} // 최대값
/>
```
## 🔧 실시간 데이터 업데이트
### 1. useEffect를 사용한 자동 업데이트
```typescript
const [animatedValues, setAnimatedValues] = useState({
frontend: 0,
backend: 0,
});
useEffect(() => {
const stats = websocketStats.serviceStats;
setAnimatedValues({
frontend: stats['frontend']?.messagesPerSecond || 0,
backend: stats['backend']?.messagesPerSecond || 0,
});
}, [websocketStats]);
```
### 2. 게이지 값 스케일링
```typescript
// 0-10 msg/s를 0-100 범위로 변환
value={Math.min(animatedValues.frontend * 10, 100)}
```
## 🎭 테스트 페이지
**경로**: `/frontend/src/pages/RealtimeGaugeTestPage.tsx`
랜덤 데이터로 실시간 게이지를 테스트할 수 있는 독립 페이지입니다.
### 실행 방법
1. App.tsx에 라우트 추가:
```tsx
import RealtimeGaugeTestPage from './pages/RealtimeGaugeTestPage';
<Route path="/gauge-test" element={<RealtimeGaugeTestPage />} />
```
2. 브라우저에서 `/gauge-test` 접속
## 📊 SystemMonitorTab 통합
실시간 WebSocket 게이지가 시스템 모니터 탭에 자동으로 추가되었습니다.
**경로**: 대시보드 → 시스템 탭
### 업데이트 주기
- 기본: 5초마다 자동 갱신
- 게이지 애니메이션: 실시간 (elastic 효과)
## 🎨 색상 스킴
### Frontend (녹색 계열)
- `#4ade80``#a3e635``#fbbf24``#fb923c``#f87171`
### Backend (주황 계열)
- `#fbbf24``#fb923c``#f87171``#ef4444``#dc2626`
### Data Service (파란 계열)
- `#6495ed``#60a5fa``#93c5fd``#dbeafe``#fff`
## 🚀 성능 최적화
1. **메모이제이션**: Props가 변경될 때만 리렌더링
2. **애니메이션**: CSS transform 사용 (GPU 가속)
3. **SVG 최적화**: 필터 재사용
## 📚 참고 자료
- [react-gauge-component 공식 문서](https://antoniolago.github.io/react-gauge-component/)
- [Material-UI 문서](https://mui.com/)
- [React Hooks 가이드](https://react.dev/reference/react)
## 🐛 트러블슈팅
### 게이지가 표시되지 않는 경우
1. `react-gauge-component` 패키지 설치 확인
2. Props 데이터 구조 확인
3. 브라우저 콘솔에서 에러 확인
### 애니메이션이 부드럽지 않은 경우
1. `elastic: true` 설정 확인
2. 값 변경 주기 조정 (1초 권장)
3. 브라우저 성능 확인
### 색상이 표시되지 않는 경우
1. `subArcs` 설정 확인
2. `limit` 값이 올바른 순서인지 확인
3. CSS 충돌 확인
@@ -0,0 +1,478 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Grid, Card, CardContent, Chip, Alert } from '@mui/material';
import GaugeComponent from 'react-gauge-component';
import { Cable, CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
import { useRealtimeWebSocketMetrics } from '../../hooks/useRealtimeWebSocketMetrics';
interface RealtimeWebSocketGaugesProps {
websocketStats: {
totalConnections: number;
activeConnections: number;
serviceStats: Record<string, {
serviceName: string;
connections: number;
messagesReceived: number;
messagesSent: number;
messagesPerSecond: number;
lastActivity: string | null;
}>;
};
}
const RealtimeWebSocketGauges: React.FC<RealtimeWebSocketGaugesProps> = ({ websocketStats }) => {
// 실시간 WebSocket 메트릭 Hook 사용
const { metrics, isConnected } = useRealtimeWebSocketMetrics();
const [animatedValues, setAnimatedValues] = useState({
frontend: 0,
backend: 0,
dataService: 0,
});
// 실시간 메트릭으로 값 업데이트
useEffect(() => {
setAnimatedValues({
frontend: metrics.frontend.messagesPerSecond,
backend: metrics.backend.messagesPerSecond,
dataService: metrics.dataService.messagesPerSecond,
});
}, [metrics]);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cable color="info" />
WebSocket
</Typography>
<Chip
icon={isConnected ? <CheckCircle /> : <ErrorIcon />}
label={isConnected ? 'WebSocket 연결됨' : 'WebSocket 연결 끊김'}
color={isConnected ? 'success' : 'error'}
size="small"
sx={{
boxShadow: isConnected
? '0 0 10px rgba(74, 222, 128, 0.4)'
: '0 0 10px rgba(248, 113, 113, 0.4)',
}}
/>
</Box>
{!isConnected && (
<Alert severity="warning" sx={{ mb: 2 }}>
WebSocket . ...
</Alert>
)}
<Card
sx={{
mb: 4,
p: 3,
bgcolor: 'rgba(10, 25, 41, 0.9)',
border: '1px solid rgba(100, 149, 237, 0.3)',
}}
>
{/* 상단 통계 - 실시간 메트릭 사용 */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6}>
<Box
sx={{
p: 2,
bgcolor: 'rgba(30, 41, 59, 0.6)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
(Frontend)
</Typography>
<Typography variant="h4" sx={{ color: '#4ade80', fontWeight: 700, mt: 1 }}>
{metrics.frontend.totalMessages.toLocaleString()}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box
sx={{
p: 2,
bgcolor: 'rgba(30, 41, 59, 0.6)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
(Backend)
</Typography>
<Typography variant="h4" sx={{ color: '#fbbf24', fontWeight: 700, mt: 1 }}>
{metrics.backend.totalMessages.toLocaleString()}
</Typography>
</Box>
</Grid>
</Grid>
{/* 게이지 차트들 */}
<Grid container spacing={4}>
{/* Frontend WebSocket */}
<Grid item xs={12} md={4}>
<Box
sx={{
p: 3,
bgcolor: 'rgba(30, 41, 59, 0.6)',
borderRadius: 3,
border: '1px solid rgba(74, 222, 128, 0.3)',
boxShadow: '0 0 20px rgba(74, 222, 128, 0.1)',
}}
>
<Typography
variant="subtitle1"
sx={{
color: '#fff',
fontWeight: 600,
mb: 2,
textAlign: 'center',
}}
>
Frontend
</Typography>
<GaugeComponent
type="semicircle"
arc={{
width: 0.2,
padding: 0.005,
cornerRadius: 7,
subArcs: [
{
limit: 20,
color: '#4ade80',
showTick: true,
},
{
limit: 40,
color: '#a3e635',
showTick: true,
},
{
limit: 60,
color: '#fbbf24',
showTick: true,
},
{
limit: 80,
color: '#fb923c',
showTick: true,
},
{
limit: 100,
color: '#f87171',
showTick: true,
},
],
}}
pointer={{
color: '#fff',
length: 0.80,
width: 15,
elastic: true,
}}
labels={{
valueLabel: {
formatTextValue: (value) => `${value.toFixed(1)} msg/s`,
style: {
fontSize: '28px',
fill: '#fff',
textShadow: '0 0 10px rgba(74, 222, 128, 0.8)',
},
},
tickLabels: {
type: 'outer',
ticks: [
{ value: 0 },
{ value: 20 },
{ value: 40 },
{ value: 60 },
{ value: 80 },
{ value: 100 },
],
defaultTickValueConfig: {
formatTextValue: (value) => `${value}`,
style: {
fontSize: '10px',
fill: 'rgba(255,255,255,0.6)',
},
},
},
}}
value={Math.min(animatedValues.frontend * 10, 100)}
minValue={0}
maxValue={100}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Chip
label={`${animatedValues.frontend.toFixed(1)} msg/s`}
size="small"
sx={{ bgcolor: 'rgba(74, 222, 128, 0.2)', color: '#4ade80', fontWeight: 700 }}
/>
<Chip
label={`총: ${metrics.frontend.totalMessages.toLocaleString()}`}
size="small"
variant="outlined"
sx={{ color: 'rgba(255,255,255,0.7)' }}
/>
</Box>
</Box>
</Grid>
{/* Backend WebSocket */}
<Grid item xs={12} md={4}>
<Box
sx={{
p: 3,
bgcolor: 'rgba(30, 41, 59, 0.6)',
borderRadius: 3,
border: '1px solid rgba(251, 191, 36, 0.3)',
boxShadow: '0 0 20px rgba(251, 191, 36, 0.1)',
}}
>
<Typography
variant="subtitle1"
sx={{
color: '#fff',
fontWeight: 600,
mb: 2,
textAlign: 'center',
}}
>
Backend (Upbit)
</Typography>
<GaugeComponent
type="semicircle"
arc={{
width: 0.2,
padding: 0.005,
cornerRadius: 7,
subArcs: [
{
limit: 20,
color: '#fbbf24',
showTick: true,
},
{
limit: 40,
color: '#fb923c',
showTick: true,
},
{
limit: 60,
color: '#f87171',
showTick: true,
},
{
limit: 80,
color: '#ef4444',
showTick: true,
},
{
limit: 100,
color: '#dc2626',
showTick: true,
},
],
}}
pointer={{
color: '#fff',
length: 0.80,
width: 15,
elastic: true,
}}
labels={{
valueLabel: {
formatTextValue: (value) => `${value.toFixed(1)} msg/s`,
style: {
fontSize: '28px',
fill: '#fff',
textShadow: '0 0 10px rgba(251, 191, 36, 0.8)',
},
},
tickLabels: {
type: 'outer',
ticks: [
{ value: 0 },
{ value: 20 },
{ value: 40 },
{ value: 60 },
{ value: 80 },
{ value: 100 },
],
defaultTickValueConfig: {
formatTextValue: (value) => `${value}`,
style: {
fontSize: '10px',
fill: 'rgba(255,255,255,0.6)',
},
},
},
}}
value={Math.min(animatedValues.backend * 10, 100)}
minValue={0}
maxValue={100}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Chip
label={`${animatedValues.backend.toFixed(1)} msg/s`}
size="small"
sx={{ bgcolor: 'rgba(251, 191, 36, 0.2)', color: '#fbbf24', fontWeight: 700 }}
/>
<Chip
label={`총: ${metrics.backend.totalMessages.toLocaleString()}`}
size="small"
variant="outlined"
sx={{ color: 'rgba(255,255,255,0.7)' }}
/>
</Box>
</Box>
</Grid>
{/* Data Service WebSocket */}
<Grid item xs={12} md={4}>
<Box
sx={{
p: 3,
bgcolor: 'rgba(30, 41, 59, 0.6)',
borderRadius: 3,
border: '1px solid rgba(100, 149, 237, 0.3)',
boxShadow: '0 0 20px rgba(100, 149, 237, 0.1)',
}}
>
<Typography
variant="subtitle1"
sx={{
color: '#fff',
fontWeight: 600,
mb: 2,
textAlign: 'center',
}}
>
Data Service
</Typography>
<GaugeComponent
type="semicircle"
arc={{
width: 0.2,
padding: 0.005,
cornerRadius: 7,
subArcs: [
{
limit: 20,
color: '#6495ed',
showTick: true,
},
{
limit: 40,
color: '#60a5fa',
showTick: true,
},
{
limit: 60,
color: '#93c5fd',
showTick: true,
},
{
limit: 80,
color: '#dbeafe',
showTick: true,
},
{
limit: 100,
color: '#fff',
showTick: true,
},
],
}}
pointer={{
color: '#fff',
length: 0.80,
width: 15,
elastic: true,
}}
labels={{
valueLabel: {
formatTextValue: (value) => `${value.toFixed(1)} msg/s`,
style: {
fontSize: '28px',
fill: '#fff',
textShadow: '0 0 10px rgba(100, 149, 237, 0.8)',
},
},
tickLabels: {
type: 'outer',
ticks: [
{ value: 0 },
{ value: 20 },
{ value: 40 },
{ value: 60 },
{ value: 80 },
{ value: 100 },
],
defaultTickValueConfig: {
formatTextValue: (value) => `${value}`,
style: {
fontSize: '10px',
fill: 'rgba(255,255,255,0.6)',
},
},
},
}}
value={Math.min(animatedValues.dataService * 10, 100)}
minValue={0}
maxValue={100}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Chip
label={`${animatedValues.dataService.toFixed(1)} msg/s`}
size="small"
sx={{ bgcolor: 'rgba(100, 149, 237, 0.2)', color: '#6495ed', fontWeight: 700 }}
/>
<Chip
label={`총: ${metrics.dataService.totalMessages.toLocaleString()}`}
size="small"
variant="outlined"
sx={{ color: 'rgba(255,255,255,0.7)' }}
/>
</Box>
</Box>
</Grid>
</Grid>
{/* 하단 상세 정보 */}
<Box sx={{ mt: 3, p: 2, bgcolor: 'rgba(15, 23, 42, 0.6)', borderRadius: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12} md={8}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
💡 WebSocket . 100ms마다 ,
1 .
</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`업데이트: ${isConnected ? '실시간' : '중단됨'}`}
size="small"
color={isConnected ? 'success' : 'default'}
variant="outlined"
/>
<Chip
label="100ms 갱신"
size="small"
variant="outlined"
sx={{ color: 'rgba(255,255,255,0.7)' }}
/>
</Box>
</Grid>
</Grid>
</Box>
</Card>
</Box>
);
};
export default RealtimeWebSocketGauges;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
/**
* 차트 섹션 컴포넌트
* - 메인 캔들 차트
* - 보조지표 차트들
* - 차트 툴바 및 설정
*
* React.memo로 최적화되어 props가 변경될 때만 재렌더링
*/
import React, { useRef, forwardRef, useImperativeHandle } from 'react';
import { Box, Paper } from '@mui/material';
import CandlestickChart, { ChartHandle } from '../CandlestickChart';
import IndicatorChart, { IndicatorChartHandle } from '../IndicatorChart';
import ChartToolbar, { ChartTool } from '../ChartToolbar';
import type { CandleData, IndicatorData } from '../../services/backtestApi';
import type { IndicatorVisibilitySettings } from '../../contexts/IndicatorVisibilityContext';
import type { ChartColorSettings } from '../../contexts/ChartColorContext';
export interface ChartSectionProps {
// 데이터
candleData: CandleData[];
indicatorData: IndicatorData[];
maCalculationData: CandleData[];
// 설정
symbol: string;
timeInterval: string;
indicatorSettings: IndicatorVisibilitySettings;
colorSettings: ChartColorSettings;
// 차트 상태
chartMode: 'basic' | 'grid' | 'clean';
activeTool: ChartTool | null;
candleChartHeight: number;
indicatorHeights: Record<string, number>;
// 이벤트 핸들러
onChartModeChange: (mode: 'basic' | 'grid' | 'clean') => void;
onToolChange: (tool: ChartTool | null) => void;
onHeightChange: (type: 'candle' | string, height: number) => void;
onVisibleRangeChange?: (from: number, to: number) => void;
// 선택적 props
loading?: boolean;
error?: string | null;
tooltipEnabled?: boolean;
realtimePrice?: number | null;
}
export interface ChartSectionHandle {
chartRef: React.RefObject<ChartHandle>;
volumeChartRef: React.RefObject<IndicatorChartHandle>;
macdChartRef: React.RefObject<IndicatorChartHandle>;
rsiChartRef: React.RefObject<IndicatorChartHandle>;
stochasticChartRef: React.RefObject<IndicatorChartHandle>;
updateLastCandle: (candle: any) => void;
updateLastIndicators: (candle: CandleData, candles: CandleData[], indicators: IndicatorData[]) => void;
}
const ChartSection = forwardRef<ChartSectionHandle, ChartSectionProps>((props, ref) => {
const {
candleData,
indicatorData,
maCalculationData,
symbol,
timeInterval,
indicatorSettings,
colorSettings,
chartMode,
activeTool,
candleChartHeight,
indicatorHeights,
onChartModeChange,
onToolChange,
onHeightChange,
onVisibleRangeChange,
loading = false,
error = null,
tooltipEnabled = false,
realtimePrice = null,
} = props;
// 차트 refs
const chartRef = useRef<ChartHandle>(null);
const volumeChartRef = useRef<IndicatorChartHandle>(null);
const macdChartRef = useRef<IndicatorChartHandle>(null);
const rsiChartRef = useRef<IndicatorChartHandle>(null);
const stochasticChartRef = useRef<IndicatorChartHandle>(null);
const cciChartRef = useRef<IndicatorChartHandle>(null);
const adxChartRef = useRef<IndicatorChartHandle>(null);
const obvChartRef = useRef<IndicatorChartHandle>(null);
// ref 노출
useImperativeHandle(ref, () => ({
chartRef,
volumeChartRef,
macdChartRef,
rsiChartRef,
stochasticChartRef,
updateLastCandle: (candle: any) => {
chartRef.current?.updateLastCandle(candle);
},
updateLastIndicators: (candle: CandleData, candles: CandleData[], indicators: IndicatorData[]) => {
chartRef.current?.updateLastIndicators(candle, candles, indicators);
},
}));
console.log(`🎨 [ChartSection] 렌더링: ${candleData.length}개 캔들`);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 차트 툴바 */}
<ChartToolbar
mode={chartMode}
activeTool={activeTool}
onModeChange={onChartModeChange}
onToolChange={onToolChange}
/>
{/* 메인 캔들 차트 */}
<Paper
elevation={2}
sx={{
mb: 1,
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
<CandlestickChart
ref={chartRef}
data={candleData}
maCalculationData={maCalculationData}
indicatorData={indicatorData}
height={candleChartHeight}
colorSettings={colorSettings}
indicatorSettings={indicatorSettings}
onVisibleRangeChange={onVisibleRangeChange}
showTooltip={tooltipEnabled}
/>
</Paper>
{/* TODO: 보조지표 차트들 - IndicatorChart props 인터페이스 수정 필요 */}
{/* 로딩/에러 상태 */}
{loading && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'rgba(0,0,0,0.7)',
color: 'white',
px: 3,
py: 2,
borderRadius: 1,
}}
>
...
</Box>
)}
{error && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'error.main',
color: 'white',
px: 3,
py: 2,
borderRadius: 1,
}}
>
{error}
</Box>
)}
</Box>
);
});
ChartSection.displayName = 'ChartSection';
export default React.memo(ChartSection, (prevProps, nextProps) => {
// 얕은 비교로 최적화
return (
prevProps.candleData === nextProps.candleData &&
prevProps.indicatorData === nextProps.indicatorData &&
prevProps.chartMode === nextProps.chartMode &&
prevProps.activeTool === nextProps.activeTool &&
prevProps.candleChartHeight === nextProps.candleChartHeight &&
prevProps.loading === nextProps.loading &&
prevProps.error === nextProps.error &&
prevProps.realtimePrice === nextProps.realtimePrice
);
});
@@ -0,0 +1,252 @@
/**
* 투자분석 컨트롤 패널
* - 종목 선택
* - 시간봉 선택
* - 전략 선택
* - 설정 버튼
*
* React.memo로 최적화
*/
import React from 'react';
import {
Box,
Paper,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Tooltip,
Button,
Chip,
Typography,
} from '@mui/material';
import {
Settings as SettingsIcon,
Refresh,
Fullscreen,
FullscreenExit,
Notifications as NotificationsIcon,
NotificationsOff as NotificationsOffIcon,
} from '@mui/icons-material';
import CryptoNameComboBox from '../CryptoNameComboBox';
export interface ControlPanelProps {
// 현재 상태
symbol: string;
timeInterval: string;
uptrendStrategyId: number | null;
downtrendStrategyId: number | null;
generalStrategyId: number | null;
alertEnabled: boolean;
isFullscreen: boolean;
realtimePrice: number | null;
// 옵션 목록
strategyRules: Array<{ id: number; name: string }>;
// 이벤트 핸들러
onSymbolChange: (symbol: string) => void;
onTimeIntervalChange: (interval: string) => void;
onUptrendStrategyChange: (id: number | null) => void;
onDowntrendStrategyChange: (id: number | null) => void;
onGeneralStrategyChange: (id: number | null) => void;
onAlertToggle: () => void;
onRefresh: () => void;
onFullscreenToggle: () => void;
onSettingsOpen: () => void;
// 로딩 상태
loading?: boolean;
alertLoading?: boolean;
}
const ControlPanel: React.FC<ControlPanelProps> = (props) => {
const {
symbol,
timeInterval,
uptrendStrategyId,
downtrendStrategyId,
generalStrategyId,
alertEnabled,
isFullscreen,
realtimePrice,
strategyRules,
onSymbolChange,
onTimeIntervalChange,
onUptrendStrategyChange,
onDowntrendStrategyChange,
onGeneralStrategyChange,
onAlertToggle,
onRefresh,
onFullscreenToggle,
onSettingsOpen,
loading = false,
alertLoading = false,
} = props;
console.log(`🎛️ [ControlPanel] 렌더링`);
return (
<Paper
elevation={2}
sx={{
p: 2,
mb: 2,
bgcolor: 'background.paper',
}}
>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
{/* 종목 선택 */}
<Box sx={{ minWidth: 200 }}>
<CryptoNameComboBox
value={symbol}
onChange={onSymbolChange}
disabled={loading}
/>
</Box>
{/* 시간봉 선택 */}
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={timeInterval}
label="시간봉"
onChange={(e) => onTimeIntervalChange(e.target.value)}
disabled={loading}
>
<MenuItem value="1m">1</MenuItem>
<MenuItem value="3m">3</MenuItem>
<MenuItem value="5m">5</MenuItem>
<MenuItem value="15m">15</MenuItem>
<MenuItem value="30m">30</MenuItem>
<MenuItem value="1h">1</MenuItem>
<MenuItem value="4h">4</MenuItem>
<MenuItem value="1d"></MenuItem>
<MenuItem value="1w"></MenuItem>
<MenuItem value="1M"></MenuItem>
</Select>
</FormControl>
{/* 상승 전략 */}
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel> </InputLabel>
<Select
value={uptrendStrategyId || ''}
label="상승 전략"
onChange={(e) => onUptrendStrategyChange(e.target.value ? Number(e.target.value) : null)}
disabled={loading}
>
<MenuItem value=""></MenuItem>
{strategyRules.map((rule) => (
<MenuItem key={rule.id} value={rule.id}>
{rule.name}
</MenuItem>
))}
</Select>
</FormControl>
{/* 하락 전략 */}
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel> </InputLabel>
<Select
value={downtrendStrategyId || ''}
label="하락 전략"
onChange={(e) => onDowntrendStrategyChange(e.target.value ? Number(e.target.value) : null)}
disabled={loading}
>
<MenuItem value=""></MenuItem>
{strategyRules.map((rule) => (
<MenuItem key={rule.id} value={rule.id}>
{rule.name}
</MenuItem>
))}
</Select>
</FormControl>
{/* 일반 전략 */}
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel> </InputLabel>
<Select
value={generalStrategyId || ''}
label="일반 전략"
onChange={(e) => onGeneralStrategyChange(e.target.value ? Number(e.target.value) : null)}
disabled={loading}
>
<MenuItem value=""></MenuItem>
{strategyRules.map((rule) => (
<MenuItem key={rule.id} value={rule.id}>
{rule.name}
</MenuItem>
))}
</Select>
</FormControl>
{/* 구분선 */}
<Box sx={{ flexGrow: 1 }} />
{/* 실시간 가격 */}
{realtimePrice && (
<Chip
label={`${realtimePrice.toLocaleString()}`}
color="primary"
size="small"
/>
)}
{/* 알림 토글 */}
<Tooltip title={alertEnabled ? '알림 끄기' : '알림 켜기'}>
<span>
<IconButton
onClick={onAlertToggle}
disabled={alertLoading}
color={alertEnabled ? 'primary' : 'default'}
>
{alertEnabled ? <NotificationsIcon /> : <NotificationsOffIcon />}
</IconButton>
</span>
</Tooltip>
{/* 새로고침 */}
<Tooltip title="새로고침">
<span>
<IconButton onClick={onRefresh} disabled={loading}>
<Refresh />
</IconButton>
</span>
</Tooltip>
{/* 전체화면 */}
<Tooltip title={isFullscreen ? '전체화면 해제' : '전체화면'}>
<IconButton onClick={onFullscreenToggle}>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{/* 설정 */}
<Tooltip title="설정">
<IconButton onClick={onSettingsOpen}>
<SettingsIcon />
</IconButton>
</Tooltip>
</Box>
</Paper>
);
};
export default React.memo(ControlPanel, (prevProps, nextProps) => {
return (
prevProps.symbol === nextProps.symbol &&
prevProps.timeInterval === nextProps.timeInterval &&
prevProps.uptrendStrategyId === nextProps.uptrendStrategyId &&
prevProps.downtrendStrategyId === nextProps.downtrendStrategyId &&
prevProps.generalStrategyId === nextProps.generalStrategyId &&
prevProps.alertEnabled === nextProps.alertEnabled &&
prevProps.isFullscreen === nextProps.isFullscreen &&
prevProps.realtimePrice === nextProps.realtimePrice &&
prevProps.loading === nextProps.loading &&
prevProps.alertLoading === nextProps.alertLoading &&
prevProps.strategyRules === nextProps.strategyRules
);
});
@@ -0,0 +1,125 @@
/**
* 매매 신호 패널
* - 백테스트 결과
* - 매매 신호 테이블
* - 수익률 요약
*
* React.memo로 최적화
*/
import React from 'react';
import { Box, Paper, Typography, Chip } from '@mui/material';
import type { BacktestResult } from '../../services/backtestApi';
// TradeSimulation 타입 정의 (SignalTable에서 가져오지 않고 여기서 정의)
export interface TradeSimulation {
date: string;
timestamp?: string;
signal: string;
price: number;
quantity: number;
tradeAmount: number;
commission: number;
netAmount: number;
holdingQty: number;
avgBuyPrice: number;
profitLoss: number;
profitLossRate: number;
totalAsset: number;
}
export interface SignalPanelProps {
backtestResult: BacktestResult | null;
tradeSimulations: TradeSimulation[];
timeInterval: string;
loading?: boolean;
onSignalClick?: (signal: any) => void;
}
const SignalPanel: React.FC<SignalPanelProps> = (props) => {
const {
backtestResult,
tradeSimulations,
timeInterval,
loading = false,
onSignalClick,
} = props;
console.log(`📊 [SignalPanel] 렌더링: ${tradeSimulations.length}개 신호`);
if (!backtestResult) {
return null;
}
const {
returnRate,
winRate,
totalTrades,
winningTrades,
losingTrades,
} = backtestResult;
return (
<Paper
elevation={2}
sx={{
p: 2,
bgcolor: 'background.paper',
}}
>
{/* 요약 정보 */}
<Box sx={{ mb: 2, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Typography variant="h6"> </Typography>
<Chip
label={`수익률: ${(returnRate * 100).toFixed(2)}%`}
color={returnRate >= 0 ? 'success' : 'error'}
size="small"
/>
<Chip
label={`승률: ${(winRate * 100).toFixed(1)}%`}
color="primary"
size="small"
/>
<Chip
label={`총 거래: ${totalTrades}`}
size="small"
/>
<Chip
label={`수익: ${winningTrades}`}
color="success"
variant="outlined"
size="small"
/>
<Chip
label={`손실: ${losingTrades}`}
color="error"
variant="outlined"
size="small"
/>
</Box>
{/* TODO: 매매 신호 테이블 - SignalTable이 MacdResult[]를 기대하지만 TradeSimulation[]를 받고 있음. 별도 컴포넌트 필요 */}
{tradeSimulations.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary">
{tradeSimulations.length}
</Typography>
</Box>
)}
</Paper>
);
};
export default React.memo(SignalPanel, (prevProps, nextProps) => {
return (
prevProps.backtestResult === nextProps.backtestResult &&
prevProps.tradeSimulations === nextProps.tradeSimulations &&
prevProps.timeInterval === nextProps.timeInterval &&
prevProps.loading === nextProps.loading
);
});
@@ -0,0 +1,74 @@
/**
* 차트 칸 타이틀 드래그 시 DragOverlay에 표시하는 미리보기 카드
*/
import React from 'react';
import { Box, Typography } from '@mui/material';
import { ShowChart as ShowChartIcon, DragIndicator as DragIndicatorIcon } from '@mui/icons-material';
import type { StockInfo } from '../../stores/useDashboardStore';
export interface ChartDragOverlayPreviewProps {
stock: StockInfo;
width: number;
height: number;
}
export const ChartDragOverlayPreview: React.FC<ChartDragOverlayPreviewProps> = ({ stock, width, height }) => {
const maxW = typeof window !== 'undefined' ? Math.min(width, window.innerWidth - 24) : width;
const maxH = typeof window !== 'undefined' ? Math.min(height, Math.round(window.innerHeight * 0.5)) : height;
const w = Math.max(200, maxW);
const h = Math.max(160, maxH);
return (
<Box
sx={{
width: w,
height: h,
borderRadius: 1,
overflow: 'hidden',
boxShadow: 12,
border: '2px solid',
borderColor: 'primary.main',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'none',
cursor: 'grabbing',
opacity: 0.97,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.5,
flexShrink: 0,
bgcolor: 'action.selected',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<DragIndicatorIcon sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }} />
<Typography variant="caption" fontWeight="bold" noWrap sx={{ flex: 1, minWidth: 0 }}>
{stock.koreanName}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{stock.symbol}
</Typography>
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(180deg, #2a2e39 0%, #131722 55%, #0c0e12 100%)',
}}
>
<ShowChartIcon sx={{ fontSize: Math.min(72, h * 0.22), color: 'grey.500', opacity: 0.45 }} />
</Box>
</Box>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
/**
* WebSocket
* -
* - MultiChartMarketDataProvider
*/
import React from 'react';
import { Box, Popover, LinearProgress, Typography, Tooltip, Button } from '@mui/material';
import { useMultiChartMarketData } from '../../contexts/MultiChartMarketDataContext';
import type { ConnectionStatus } from '../../hooks/useWebSocketConnectionStatus';
const WS_STATUS_CONFIG: Record<ConnectionStatus, { label: string; color: string; bgColor: string }> = {
connected: { label: '실시간 연결됨', color: 'success.main', bgColor: 'success.main' },
connecting: { label: '연결 중...', color: 'warning.main', bgColor: 'warning.main' },
disconnected: { label: '연결 끊김', color: 'text.secondary', bgColor: 'grey.400' },
error: { label: '연결 오류', color: 'error.main', bgColor: 'error.main' },
};
export const MultiChartWebSocketStatus: React.FC = () => {
const ctx = useMultiChartMarketData();
const status = ctx?.status ?? 'disconnected';
const connectionList = ctx?.connectionList ?? [];
const [popoverAnchor, setPopoverAnchor] = React.useState<HTMLElement | null>(null);
const [, setProgressTick] = React.useState(0);
const [dataReceivedBlink, setDataReceivedBlink] = React.useState(false); // ✅ 데이터 수신 깜빡임
const lastReceivedTimestampRef = React.useRef<number>(0);
React.useEffect(() => {
if (!popoverAnchor) return;
const id = setInterval(() => setProgressTick((t) => t + 1), 1000);
return () => clearInterval(id);
}, [popoverAnchor]);
// ✅ connectionList의 lastReceivedAt 변화 감지 → 깜빡임 트리거
React.useEffect(() => {
if (connectionList.length === 0) return;
const latestReceived = Math.max(...connectionList.map(c => c.lastReceivedAt));
if (latestReceived > 0 && latestReceived !== lastReceivedTimestampRef.current) {
lastReceivedTimestampRef.current = latestReceived;
setDataReceivedBlink(true);
setTimeout(() => setDataReceivedBlink(false), 300); // 300ms 후 자동 꺼짐
}
}, [connectionList]);
const config = WS_STATUS_CONFIG[status as ConnectionStatus] ?? WS_STATUS_CONFIG.disconnected;
const getReceiveProgress = (lastReceivedAt: number) => {
if (!lastReceivedAt) return 0;
const elapsed = (Date.now() - lastReceivedAt) / 1000;
if (elapsed <= 5) return 100;
if (elapsed >= 30) return 0;
return Math.round(100 - ((elapsed - 5) / 25) * 100);
};
return (
<>
<Tooltip title={`WebSocket: ${config.label} (클릭하여 연결 목록 보기)`}>
<Box
onClick={(e) => setPopoverAnchor(e.currentTarget)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
fontSize: '0.75rem',
color: config.color,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.selected' },
// ✅ 데이터 수신 시 연두색 형광 깜빡임
...(dataReceivedBlink && status === 'connected' ? {
boxShadow: '0 0 10px 3px rgba(204, 255, 0, 0.7), inset 0 0 8px 2px rgba(204, 255, 0, 0.3)',
transition: 'box-shadow 0.15s ease-out',
} : {}),
}}
>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: config.bgColor,
...(status === 'connecting' && {
animation: 'pulse 1.5s ease-in-out infinite',
'@keyframes pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.4 } },
}),
}}
/>
{config.label}
</Box>
</Tooltip>
<Popover
open={Boolean(popoverAnchor)}
anchorEl={popoverAnchor}
onClose={() => setPopoverAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 280 } } }}
>
<Box sx={{ p: 1.5 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
WebSocket
</Typography>
{connectionList.length === 0 ? (
<Typography variant="body2" color="text.secondary">
.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{connectionList.map((item) => {
const progress = getReceiveProgress(item.lastReceivedAt);
const hasNoData = item.candleCount === 0;
const displayName = item.koreanName || item.market;
return (
<Box key={`${item.market}:${item.timeframe}`} sx={{ minWidth: 240 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.timeframe} · {item.candleCount}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 1,
bgcolor: 'action.hover',
'& .MuiLinearProgress-bar': {
bgcolor: progress >= 80 ? 'success.main' : progress >= 40 ? 'warning.main' : 'grey.400',
},
}}
/>
{hasNoData && (
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
미수신: Redis , ,
</Typography>
)}
</Box>
);
})}
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1.5, pt: 1, borderTop: 1, borderColor: 'divider' }}>
0개: market-data , .
</Typography>
{(status === 'disconnected' || status === 'error') && ctx?.reconnect && (
<Button
size="small"
variant="outlined"
fullWidth
sx={{ mt: 1 }}
onClick={() => {
ctx.reconnect();
setPopoverAnchor(null);
}}
>
</Button>
)}
</Box>
</Popover>
</>
);
};
@@ -0,0 +1,76 @@
/**
*
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
InputAdornment,
List,
ListItemButton,
ListItemText,
} from '@mui/material';
import { Search as SearchIcon } from '@mui/icons-material';
import { MARKET_LIST } from '../../constants/marketList';
import type { StockInfo } from '../../stores/useDashboardStore';
interface StockSelectDialogProps {
open: boolean;
onClose: () => void;
onSelect: (stock: StockInfo) => void;
}
export const StockSelectDialog: React.FC<StockSelectDialogProps> = ({ open, onClose, onSelect }) => {
const [search, setSearch] = useState('');
const filtered = MARKET_LIST.filter(
(m) =>
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
m.symbol.toLowerCase().includes(search.toLowerCase())
);
const handleSelect = (item: (typeof MARKET_LIST)[0]) => {
onSelect({
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
});
onClose();
setSearch('');
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle> </DialogTitle>
<DialogContent>
<TextField
size="small"
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
sx={{ mb: 1 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
<List sx={{ maxHeight: 320, overflow: 'auto' }}>
{filtered.map((item) => (
<ListItemButton key={item.symbol} onClick={() => handleSelect(item)}>
<ListItemText
primary={item.koreanName}
secondary={item.symbol}
/>
</ListItemButton>
))}
</List>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,191 @@
/**
* - ( )
*/
import React, { useState, useEffect } from 'react';
import {
Button,
Menu,
MenuItem,
TextField,
InputAdornment,
List,
ListItem,
ListItemButton,
Box,
Typography,
} from '@mui/material';
import { Add as AddIcon, Search as SearchIcon, DragIndicator } from '@mui/icons-material';
import { useDraggable } from '@dnd-kit/core';
import { MARKET_LIST, type MarketItem } from '../../constants/marketList';
import type { StockInfo } from '../../stores/useDashboardStore';
interface DraggableStockItemProps {
item: MarketItem;
onClick?: (item: MarketItem) => void;
}
const DraggableStockItem: React.FC<DraggableStockItemProps> = ({ item, onClick }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `stock-${item.symbol}`,
data: {
type: 'stock',
stock: {
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
} as StockInfo,
},
});
const handleClick = (e: React.MouseEvent) => {
// 드래그 중이 아닐 때만 클릭 이벤트 처리
if (!isDragging && onClick) {
e.stopPropagation();
onClick(item);
}
};
return (
<ListItem disablePadding ref={setNodeRef} {...attributes} {...listeners}>
<ListItemButton
onClick={handleClick}
sx={{
py: 0.75,
opacity: isDragging ? 0.5 : 1,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<DragIndicator fontSize="small" sx={{ mr: 1, color: 'text.disabled' }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" noWrap>
{item.koreanName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.symbol}
</Typography>
</Box>
</ListItemButton>
</ListItem>
);
};
interface StockSelectDropdownProps {
open: boolean;
onOpen: () => void;
onClose: () => void;
/** 종목 선택 시 콜백 (클릭으로 선택) */
onSelect?: (stock: StockInfo) => void;
/** 드래그 중에는 메뉴 닫지 않음 */
isDraggingFromMenu?: boolean;
}
export const StockSelectDropdown: React.FC<StockSelectDropdownProps> = ({
open,
onOpen,
onClose,
onSelect,
isDraggingFromMenu = false,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [search, setSearch] = useState('');
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget);
onOpen();
};
const menuOpen = open && Boolean(anchorEl);
const handleClose = () => {
if (!isDraggingFromMenu) {
setAnchorEl(null);
setSearch('');
onClose();
}
};
const handleItemClick = (item: MarketItem) => {
if (onSelect) {
onSelect({
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
});
handleClose();
}
};
useEffect(() => {
if (!open) {
setAnchorEl(null);
setSearch('');
}
}, [open]);
const filtered = MARKET_LIST.filter(
(m) =>
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
m.symbol.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={handleOpen}
sx={{ minWidth: 140, justifyContent: 'flex-start' }}
>
</Button>
<Menu
anchorEl={anchorEl}
open={menuOpen}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
PaperProps={{
sx: {
maxHeight: 400,
width: 280,
mt: 1.5,
},
}}
disableAutoFocusItem
disableScrollLock
>
<Box sx={{ px: 1.5, pt: 1, pb: 0.5 }}>
<TextField
size="small"
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'action.hover' } }}
/>
</Box>
<List sx={{ maxHeight: 320, overflow: 'auto', py: 0 }}>
{filtered.map((item) => (
<DraggableStockItem key={item.symbol} item={item} onClick={handleItemClick} />
))}
</List>
{filtered.length === 0 && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
)}
</Menu>
</>
);
};
@@ -0,0 +1,121 @@
/**
* -
*/
import React, { useState } from 'react';
import {
Box,
Typography,
TextField,
InputAdornment,
List,
ListItem,
ListItemButton,
Paper,
} from '@mui/material';
import { Search as SearchIcon, DragIndicator } from '@mui/icons-material';
import { useDraggable } from '@dnd-kit/core';
import { MARKET_LIST, type MarketItem } from '../../constants/marketList';
import type { StockInfo } from '../../stores/useDashboardStore';
interface DraggableStockProps {
item: MarketItem;
}
const DraggableStock: React.FC<DraggableStockProps> = ({ item }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `stock-${item.symbol}`,
data: {
type: 'stock',
stock: {
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
} as StockInfo,
},
});
return (
<ListItem disablePadding ref={setNodeRef} {...attributes} {...listeners}>
<ListItemButton
sx={{
py: 0.75,
opacity: isDragging ? 0.5 : 1,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<DragIndicator fontSize="small" sx={{ mr: 1, color: 'text.disabled' }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" noWrap>
{item.koreanName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.symbol}
</Typography>
</Box>
</ListItemButton>
</ListItem>
);
};
interface StockSidebarProps {
onSelectStock?: (stock: StockInfo) => void;
}
export const StockSidebar: React.FC<StockSidebarProps> = ({ onSelectStock }) => {
const [search, setSearch] = useState('');
const filtered = MARKET_LIST.filter(
(m) =>
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
m.symbol.toLowerCase().includes(search.toLowerCase())
);
return (
<Paper
elevation={0}
sx={{
width: 240,
flexShrink: 0,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
}}
>
<Box sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>
</Typography>
<TextField
size="small"
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'action.hover' } }}
/>
</Box>
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
{filtered.map((item) => (
<DraggableStock key={item.symbol} item={item} />
))}
</List>
{filtered.length === 0 && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
)}
</Paper>
);
};
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import {
Box,
Grid,
TextField,
Typography,
Divider,
Button,
Alert,
} from '@mui/material';
import { RestartAlt } from '@mui/icons-material';
import { useChartColors, ChartColorSettings } from '../../contexts/ChartColorContext';
const ChartColorSettingsTab: React.FC = () => {
const { colors, updateColors, resetColors } = useChartColors();
const [tempColors, setTempColors] = useState<ChartColorSettings>(colors);
const [success, setSuccess] = useState(false);
React.useEffect(() => {
setTempColors(colors);
}, [colors]);
const handleColorChange = (key: keyof ChartColorSettings, value: string | number) => {
setTempColors(prev => ({ ...prev, [key]: value }));
};
const handleSave = () => {
updateColors(tempColors);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
};
const handleReset = () => {
if (window.confirm('모든 색상 설정을 기본값으로 초기화하시겠습니까?')) {
resetColors();
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
}
};
const ColorInput = ({ label, colorKey }: { label: string; colorKey: keyof ChartColorSettings }) => {
const colorValue = String(tempColors[colorKey]);
return (
<Grid container spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Grid item xs={6}>
<Typography variant="body2">{label}</Typography>
</Grid>
<Grid item xs={4}>
<TextField
fullWidth
size="small"
type="text"
value={colorValue}
onChange={(e) => handleColorChange(colorKey, e.target.value)}
/>
</Grid>
<Grid item xs={2}>
<Box
sx={{
width: '100%',
height: 36,
backgroundColor: colorValue,
border: '1px solid #ccc',
borderRadius: 1,
cursor: 'pointer',
}}
onClick={() => {
const input = document.createElement('input');
input.type = 'color';
input.value = colorValue;
input.onchange = (e) => {
handleColorChange(colorKey, (e.target as HTMLInputElement).value);
};
input.click();
}}
/>
</Grid>
</Grid>
);
};
const MASettingInput = ({
label,
colorKey,
widthKey
}: {
label: string;
colorKey: keyof ChartColorSettings;
widthKey: keyof ChartColorSettings;
}) => {
const colorValue = String(tempColors[colorKey]);
const widthValue = Number(tempColors[widthKey]);
return (
<Grid container spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Grid item xs={3}>
<Typography variant="body2">{label}</Typography>
</Grid>
<Grid item xs={3}>
<TextField
fullWidth
size="small"
type="text"
value={colorValue}
onChange={(e) => handleColorChange(colorKey, e.target.value)}
placeholder="색상"
/>
</Grid>
<Grid item xs={2}>
<Box
sx={{
width: '100%',
height: 36,
backgroundColor: colorValue,
border: '1px solid #ccc',
borderRadius: 1,
cursor: 'pointer',
}}
onClick={() => {
const input = document.createElement('input');
input.type = 'color';
input.value = colorValue;
input.onchange = (e) => {
handleColorChange(colorKey, (e.target as HTMLInputElement).value);
};
input.click();
}}
/>
</Grid>
<Grid item xs={2}>
<TextField
fullWidth
size="small"
type="number"
value={widthValue}
onChange={(e) => handleColorChange(widthKey, Number(e.target.value))}
inputProps={{ min: 0.5, max: 5, step: 0.5 }}
placeholder="굵기"
/>
</Grid>
<Grid item xs={2}>
<Box
sx={{
width: '100%',
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: '100%',
height: `${widthValue * 2}px`,
backgroundColor: colorValue,
borderRadius: 1,
}}
/>
</Box>
</Grid>
</Grid>
);
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<RestartAlt />}
onClick={handleReset}
color="secondary"
>
</Button>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
.
</Alert>
)}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
🕯
</Typography>
<ColorInput label="상승 캔들" colorKey="candleRising" />
<ColorInput label="하락 캔들" colorKey="candleFalling" />
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📈 ( )
</Typography>
<Grid container spacing={1} sx={{ mb: 1 }}>
<Grid item xs={3}><Typography variant="caption" color="text.secondary"></Typography></Grid>
<Grid item xs={3}><Typography variant="caption" color="text.secondary"> </Typography></Grid>
<Grid item xs={2}><Typography variant="caption" color="text.secondary"></Typography></Grid>
<Grid item xs={2}><Typography variant="caption" color="text.secondary"></Typography></Grid>
<Grid item xs={2}><Typography variant="caption" color="text.secondary"></Typography></Grid>
</Grid>
<MASettingInput label="MA10 (10일)" colorKey="ma10Color" widthKey="ma10Width" />
<MASettingInput label="MA20 (20일)" colorKey="ma20Color" widthKey="ma20Width" />
<MASettingInput label="MA60 (60일)" colorKey="ma60Color" widthKey="ma60Width" />
<MASettingInput label="MA120 (120일)" colorKey="ma120Color" widthKey="ma120Width" />
<MASettingInput label="MA200 (200일)" colorKey="ma200Color" widthKey="ma200Width" />
<MASettingInput label="MA300 (300일)" colorKey="ma300Color" widthKey="ma300Width" />
<MASettingInput label="MA480 (480일)" colorKey="ma480Color" widthKey="ma480Width" />
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📊 MACD (12/26/60)
</Typography>
<ColorInput label="12일 이동평균선" colorKey="ma12" />
<ColorInput label="26일 이동평균선" colorKey="ma26" />
<ColorInput label="60일 이동평균선" colorKey="ma60" />
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📊
</Typography>
<ColorInput label="상승 거래량" colorKey="volumeRising" />
<ColorInput label="하락 거래량" colorKey="volumeFalling" />
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📉 MACD
</Typography>
<ColorInput label="MACD 라인" colorKey="macdLine" />
<ColorInput label="Signal 라인" colorKey="signalLine" />
<ColorInput label="히스토그램 (양수)" colorKey="histogramPositive" />
<ColorInput label="히스토그램 (음수)" colorKey="histogramNegative" />
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📊 Stochastic Slow
</Typography>
<ColorInput label="%K 라인" colorKey="stochasticK" />
<ColorInput label="%D 라인" colorKey="stochasticD" />
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
( )
</Typography>
<Grid container spacing={1} sx={{ mb: 1 }}>
<Grid item xs={3}><Typography variant="caption" color="text.secondary"></Typography></Grid>
<Grid item xs={3}><Typography variant="caption" color="text.secondary"> </Typography></Grid>
<Grid item xs={2}><Typography variant="caption" color="text.secondary"></Typography></Grid>
<Grid item xs={2}><Typography variant="caption" color="text.secondary"></Typography></Grid>
<Grid item xs={2}><Typography variant="caption" color="text.secondary"></Typography></Grid>
</Grid>
<MASettingInput label="전환선 (9일)" colorKey="ichimokuTenkan" widthKey="ichimokuTenkanWidth" />
<MASettingInput label="기준선 (26일)" colorKey="ichimokuKijun" widthKey="ichimokuKijunWidth" />
<MASettingInput label="후행스팬" colorKey="ichimokuChikou" widthKey="ichimokuChikouWidth" />
<MASettingInput label="선행스팬1" colorKey="ichimokuSenkouA" widthKey="ichimokuSenkouAWidth" />
<MASettingInput label="선행스팬2" colorKey="ichimokuSenkouB" widthKey="ichimokuSenkouBWidth" />
<Divider sx={{ my: 2 }} />
<Typography variant="body2" sx={{ mb: 1, fontWeight: 'bold' }}> </Typography>
<ColorInput label="상승 구름 (녹색)" colorKey="ichimokuCloudBullish" />
<ColorInput label="하락 구름 (빨간색)" colorKey="ichimokuCloudBearish" />
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="contained" onClick={handleSave}>
</Button>
</Box>
</Box>
);
};
export default ChartColorSettingsTab;
@@ -0,0 +1,948 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Typography,
Switch,
FormControlLabel,
TextField,
InputAdornment,
Divider,
Alert,
Chip,
Button,
CircularProgress,
ToggleButton,
ToggleButtonGroup,
Paper,
FormControl,
InputLabel,
Select,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from '@mui/material';
import {
Notifications as NotificationsIcon,
NotificationsOff as NotificationsOffIcon,
LightMode as LightModeIcon,
DarkMode as DarkModeIcon,
Waves as WavesIcon,
TrendingUp as TrendingUpIcon,
FileDownload as FileDownloadIcon,
FileUpload as FileUploadIcon,
Settings as SettingsIcon,
Folder as FolderIcon,
Storage as StorageIcon,
} from '@mui/icons-material';
import { getUserSettings, updateUserSettings, UserSettings, settingsApi } from '../../services/settingsApi';
import { useAppTheme, ThemeMode } from '../../contexts/ThemeContext';
import {
subscribeToPush,
unsubscribeFromPush,
sendSubscriptionToServer,
deleteSubscriptionFromServer,
getSubscription,
isPWAPushSupported,
isSecureContext,
} from '../../utils/pushNotifications';
import { getStrategies, TradingStrategy, getStrategyTypeName } from '../../services/tradingStrategyApi';
import { getStorageMode, setStorageMode, type InvestmentAnalysisStorageMode } from '../../services/investmentAnalysisSettingsStorage';
// 기본 투자 전략 설정 키
const DEFAULT_STRATEGY_KEY = 'defaultTradingStrategy';
// 기본 전략 설정 로드
const loadDefaultStrategy = (): string => {
try {
const saved = localStorage.getItem(DEFAULT_STRATEGY_KEY);
return saved || 'TREND_STOCHASTIC'; // 기본값: MACD + Stochastic Slow
} catch {
return 'TREND_STOCHASTIC';
}
};
// 기본 전략 설정 저장
const saveDefaultStrategy = (strategyType: string): void => {
try {
localStorage.setItem(DEFAULT_STRATEGY_KEY, strategyType);
} catch (e) {
console.error('기본 전략 저장 실패:', e);
}
};
const GeneralSettingsTab: React.FC = () => {
const [settings, setSettings] = useState<UserSettings | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [pushSubscribed, setPushSubscribed] = useState(false);
const [checkingPush, setCheckingPush] = useState(false);
// 테마 설정
const { themeMode, setThemeMode } = useAppTheme();
// 기본 투자 전략 설정
const [strategies, setStrategies] = useState<TradingStrategy[]>([]);
const [defaultStrategy, setDefaultStrategy] = useState<string>(loadDefaultStrategy());
const [loadingStrategies, setLoadingStrategies] = useState(false);
// 투자분석 설정 저장 방식 (파일/DB)
const [storageMode, setStorageModeState] = useState<InvestmentAnalysisStorageMode>(getStorageMode());
// 설정 Export/Import 상태
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [importConfirmOpen, setImportConfirmOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadSettings();
checkPushSubscription();
loadStrategies();
}, []);
// 전략 목록 로드
const loadStrategies = async () => {
try {
setLoadingStrategies(true);
const data = await getStrategies(false); // 모든 전략 가져오기
setStrategies(data);
} catch (err) {
console.error('전략 목록 로드 실패:', err);
} finally {
setLoadingStrategies(false);
}
};
// 기본 전략 변경 핸들러
const handleDefaultStrategyChange = (strategyType: string) => {
setDefaultStrategy(strategyType);
saveDefaultStrategy(strategyType);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
};
const loadSettings = async () => {
try {
setLoading(true);
setError(null);
const data = await getUserSettings();
setSettings(data);
} catch (err: any) {
setError(err.response?.status === 401
? '로그인이 필요합니다.'
: '설정을 불러오는데 실패했습니다.');
setSettings({
userId: 0,
username: '',
buyAlertEnabled: false,
dataUpdateIntervalMinutes: 1
});
} finally {
setLoading(false);
}
};
const checkPushSubscription = async () => {
setCheckingPush(true);
try {
const subscription = await getSubscription();
setPushSubscribed(!!subscription);
} catch (error) {
setPushSubscribed(false);
} finally {
setCheckingPush(false);
}
};
const handlePushToggle = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.checked;
try {
setSaving(true);
setError(null);
if (newValue) {
const subscription = await subscribeToPush();
if (!subscription) {
throw new Error('Push 구독에 실패했습니다.');
}
const sent = await sendSubscriptionToServer(subscription);
if (!sent) {
throw new Error('구독 정보 저장에 실패했습니다.');
}
setPushSubscribed(true);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} else {
await unsubscribeFromPush();
await deleteSubscriptionFromServer();
setPushSubscribed(false);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
}
} catch (err: any) {
setError(err.message || 'Push 알림 설정에 실패했습니다.');
setPushSubscribed(!newValue);
} finally {
setSaving(false);
}
};
const handleBuyAlertToggle = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.checked;
try {
setSaving(true);
setError(null);
setSuccess(false);
const updatedSettings = await updateUserSettings({
buyAlertEnabled: newValue,
});
setSettings(updatedSettings);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err: any) {
setError('설정 저장에 실패했습니다.');
if (settings) {
setSettings({ ...settings });
}
} finally {
setSaving(false);
}
};
const handleDataUpdateIntervalChange = async (event: React.FocusEvent<HTMLInputElement>) => {
const newValue = parseInt(event.target.value, 10);
if (isNaN(newValue) || newValue < 1) {
setError('업데이트 간격은 1분 이상이어야 합니다.');
if (settings) {
setSettings({ ...settings });
}
return;
}
if (settings && newValue === settings.dataUpdateIntervalMinutes) {
return;
}
try {
setSaving(true);
setError(null);
setSuccess(false);
const updatedSettings = await updateUserSettings({
dataUpdateIntervalMinutes: newValue,
});
setSettings(updatedSettings);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err: any) {
setError('설정 저장에 실패했습니다.');
if (settings) {
setSettings({ ...settings });
}
} finally {
setSaving(false);
}
};
const requestNotificationPermission = async () => {
if ('Notification' in window) {
try {
const permission = await Notification.requestPermission();
alert(`알림 권한: ${permission === 'granted' ? '허용됨 ✅' : permission === 'denied' ? '거부됨 ❌' : '기본값'}`);
} catch (error) {
alert('알림 권한 요청에 실패했습니다.');
}
} else {
alert('이 브라우저는 알림을 지원하지 않습니다.');
}
};
const handleTestNotification = async () => {
try {
if ('Notification' in window) {
if (Notification.permission === 'granted') {
new Notification('🧪 테스트 알림', {
body: '브라우저 알림이 정상적으로 작동합니다!',
icon: '/favicon.ico',
});
} else if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
new Notification('🧪 테스트 알림', {
body: '브라우저 알림이 정상적으로 작동합니다!',
icon: '/favicon.ico',
});
}
} else {
alert('알림 권한이 차단되었습니다.');
}
}
} catch (error) {
alert('테스트 알림에 실패했습니다.');
}
};
// ==================== 설정 Export/Import 핸들러 ====================
const handleExport = async () => {
try {
setExporting(true);
setError(null);
await settingsApi.downloadExportFile();
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err: any) {
setError('설정 내보내기에 실패했습니다: ' + (err.message || '알 수 없는 오류'));
} finally {
setExporting(false);
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.name.endsWith('.json')) {
setError('JSON 파일만 업로드할 수 있습니다.');
return;
}
setSelectedFile(file);
setImportConfirmOpen(true);
}
// 같은 파일 재선택을 위해 value 초기화
event.target.value = '';
};
const handleImportConfirm = async () => {
if (!selectedFile) return;
try {
setImporting(true);
setError(null);
setImportConfirmOpen(false);
const result = await settingsApi.importFromFile(selectedFile);
if (result.success) {
setSuccess(true);
// ✅ 보조지표 설정이 포함되어 있으므로 페이지 새로고침
console.log('✅ 설정 Import 완료 - 페이지를 새로고침합니다...');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
setError(result.message || '설정 가져오기에 실패했습니다.');
setImporting(false);
setSelectedFile(null);
}
} catch (err: any) {
setError('설정 가져오기에 실패했습니다: ' + (err.message || '알 수 없는 오류'));
setImporting(false);
setSelectedFile(null);
}
};
const handleImportCancel = () => {
setImportConfirmOpen(false);
setSelectedFile(null);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Typography variant="h6" gutterBottom>
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
.
</Alert>
)}
{/* 사용자 정보 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1">
<strong>:</strong> {settings?.username || '알 수 없음'}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
{/* 색상 테마 설정 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
🎨
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
.
</Typography>
<ToggleButtonGroup
value={themeMode}
exclusive
onChange={(_, newMode) => newMode && setThemeMode(newMode as ThemeMode)}
aria-label="테마 선택"
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
'& .MuiToggleButton-root': {
flex: '1 1 auto',
minWidth: '100px',
py: 2,
border: '2px solid',
borderRadius: '12px !important',
'&.Mui-selected': {
borderWidth: '2px',
},
},
}}
>
<ToggleButton
value="light"
aria-label="화이트 모드"
sx={{
borderColor: themeMode === 'light' ? 'primary.main' : 'divider',
backgroundColor: themeMode === 'light' ? 'primary.light' : 'transparent',
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
},
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Paper
elevation={2}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #f5f5f5 0%, #ffffff 100%)',
border: '2px solid #e0e0e0',
}}
>
<LightModeIcon sx={{ color: '#ff9800' }} />
</Paper>
<Typography variant="body2" fontWeight={themeMode === 'light' ? 'bold' : 'normal'}>
</Typography>
</Box>
</ToggleButton>
<ToggleButton
value="dark"
aria-label="다크 모드"
sx={{
borderColor: themeMode === 'dark' ? '#667eea' : 'divider',
backgroundColor: themeMode === 'dark' ? 'rgba(102, 126, 234, 0.2)' : 'transparent',
'&.Mui-selected': {
backgroundColor: 'rgba(102, 126, 234, 0.3)',
color: 'white',
'&:hover': {
backgroundColor: 'rgba(102, 126, 234, 0.4)',
},
},
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Paper
elevation={2}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
border: '2px solid #667eea',
}}
>
<DarkModeIcon sx={{ color: '#a78bfa' }} />
</Paper>
<Typography variant="body2" fontWeight={themeMode === 'dark' ? 'bold' : 'normal'}>
</Typography>
</Box>
</ToggleButton>
<ToggleButton
value="blue"
aria-label="블루 모드"
sx={{
borderColor: themeMode === 'blue' ? '#2196f3' : 'divider',
backgroundColor: themeMode === 'blue' ? 'rgba(33, 150, 243, 0.2)' : 'transparent',
'&.Mui-selected': {
backgroundColor: 'rgba(33, 150, 243, 0.3)',
color: 'white',
'&:hover': {
backgroundColor: 'rgba(33, 150, 243, 0.4)',
},
},
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Paper
elevation={2}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #0a1929 0%, #0d2137 50%, #0d47a1 100%)',
border: '2px solid #2196f3',
}}
>
<WavesIcon sx={{ color: '#64b5f6' }} />
</Paper>
<Typography variant="body2" fontWeight={themeMode === 'blue' ? 'bold' : 'normal'}>
</Typography>
</Box>
</ToggleButton>
</ToggleButtonGroup>
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
{themeMode === 'light' && '☀️ 화이트 모드: 밝은 배경에 편안한 시인성을 제공합니다.'}
{themeMode === 'dark' && '🌙 다크 모드: 눈의 피로를 줄이고 배터리를 절약합니다.'}
{themeMode === 'blue' && '🌊 블루 모드: 차분한 블루 톤으로 집중력을 높여줍니다.'}
</Typography>
</Alert>
</Box>
<Divider sx={{ my: 3 }} />
{/* 기본 투자 전략 설정 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<TrendingUpIcon color="primary" />
<Typography variant="subtitle2" color="text.secondary">
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
.
</Typography>
<FormControl fullWidth size="small">
<InputLabel> </InputLabel>
<Select
value={defaultStrategy}
label="기본 전략"
onChange={(e) => handleDefaultStrategyChange(e.target.value)}
disabled={loadingStrategies}
>
{loadingStrategies ? (
<MenuItem value="">
<CircularProgress size={20} sx={{ mr: 1 }} />
...
</MenuItem>
) : strategies.length > 0 ? (
strategies.map((strategy) => (
<MenuItem key={strategy.id} value={strategy.strategyType}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography>{strategy.name}</Typography>
{strategy.strategyType === 'TREND_STOCHASTIC' && (
<Chip label="추천" size="small" color="primary" sx={{ height: 20 }} />
)}
</Box>
</MenuItem>
))
) : (
// 기본 전략 타입 목록 (DB 연결 실패 시)
<>
<MenuItem value="STRATEGY_1">MACD </MenuItem>
<MenuItem value="STRATEGY_2">MACD + Stochastic ()</MenuItem>
<MenuItem value="STRATEGY_3">MACD + Stochastic + ADX</MenuItem>
<MenuItem value="STRATEGY_4"> ()</MenuItem>
<MenuItem value="STRATEGY_5">MACD Zero Cross</MenuItem>
<MenuItem value="TREND_STOCHASTIC">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography>MACD + Stochastic Slow ( )</Typography>
<Chip label="추천" size="small" color="primary" sx={{ height: 20 }} />
</Box>
</MenuItem>
<MenuItem value="TREND_STOCHASTIC_CCI">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography>CCI + Stochastic Slow (CCI )</Typography>
<Chip label="신규" size="small" color="secondary" sx={{ height: 20 }} />
</Box>
</MenuItem>
<MenuItem value="STOCHASTIC_ONLY">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography>Stochastic Slow Only</Typography>
</Box>
</MenuItem>
</>
)}
</Select>
</FormControl>
{defaultStrategy === 'TREND_STOCHASTIC' && (
<Alert severity="success" sx={{ mt: 2 }}>
<Typography variant="caption">
📊 <strong>MACD + Stochastic Slow </strong>: / Stochastic
</Typography>
</Alert>
)}
{defaultStrategy === 'TREND_STOCHASTIC_CCI' && (
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
📈 <strong>CCI + Stochastic Slow </strong>: CCI (CCI {'>'} 0: 상승, CCI {'<'} 0: 하락) Stochastic
</Typography>
</Alert>
)}
{defaultStrategy && defaultStrategy !== 'TREND_STOCHASTIC' && defaultStrategy !== 'TREND_STOCHASTIC_CCI' && (
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
📌 : <strong>{getStrategyTypeName(defaultStrategy)}</strong>
</Typography>
</Alert>
)}
</Box>
<Divider sx={{ my: 3 }} />
{/* 투자분석 설정 저장 방식 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<FolderIcon color="primary" />
<Typography variant="subtitle2" color="text.secondary">
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
(, , ) DB에 . .
</Typography>
<ToggleButtonGroup
value={storageMode}
exclusive
onChange={(_, newMode) => {
if (newMode) {
setStorageMode(newMode as InvestmentAnalysisStorageMode);
setStorageModeState(newMode as InvestmentAnalysisStorageMode);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
}
}}
aria-label="저장 방식 선택"
sx={{ display: 'flex', gap: 1 }}
>
<ToggleButton value="file" aria-label="파일">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1, px: 2 }}>
<FolderIcon fontSize="small" />
<Typography variant="body2"> ()</Typography>
</Box>
</ToggleButton>
<ToggleButton value="db" aria-label="DB">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1, px: 2 }}>
<StorageIcon fontSize="small" />
<Typography variant="body2">DB</Typography>
</Box>
</ToggleButton>
</ToggleButtonGroup>
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
{storageMode === 'file' && '📁 파일: 설정을 브라우저 로컬에 저장하며, 설정파일 내보내기/가져오기로 백업·복원할 수 있습니다.'}
{storageMode === 'db' && '🗄️ DB: 설정을 서버 DB에 저장하며, 로그인 시 동기화됩니다.'}
</Typography>
</Alert>
</Box>
<Divider sx={{ my: 3 }} />
{/* 알림 설정 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<FormControlLabel
control={
<Switch
checked={settings?.buyAlertEnabled || false}
onChange={handleBuyAlertToggle}
disabled={saving || !settings}
/>
}
label={
<Box>
<Typography variant="body1"> </Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
}
/>
</Box>
<Divider sx={{ my: 3 }} />
{/* 데이터 업데이트 설정 */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<TextField
fullWidth
type="number"
label="업데이트 간격"
value={settings?.dataUpdateIntervalMinutes || 1}
onChange={(e) => {
if (settings) {
setSettings({ ...settings, dataUpdateIntervalMinutes: parseInt(e.target.value, 10) || 1 });
}
}}
onBlur={handleDataUpdateIntervalChange}
disabled={saving || !settings}
InputProps={{
endAdornment: <InputAdornment position="end"></InputAdornment>,
inputProps: { min: 1, max: 60 }
}}
helperText="즐겨찾기 종목의 데이터를 자동으로 업데이트하는 간격입니다."
size="small"
sx={{ mt: 1 }}
/>
</Box>
<Divider sx={{ my: 3 }} />
{/* 브라우저 알림 권한 */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
</Typography>
{'Notification' in window && (
<Chip
label={
Notification.permission === 'granted' ? '허용됨 ✅' :
Notification.permission === 'denied' ? '거부됨 ❌' :
'설정 안됨 ⚠️'
}
size="small"
color={
Notification.permission === 'granted' ? 'success' :
Notification.permission === 'denied' ? 'error' :
'warning'
}
/>
)}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{'Notification' in window && Notification.permission !== 'granted' && (
<Button
variant="outlined"
size="small"
onClick={requestNotificationPermission}
disabled={Notification.permission === 'denied'}
>
</Button>
)}
<Button
variant="outlined"
size="small"
onClick={handleTestNotification}
>
</Button>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Push 알림 설정 */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
Push (PWA)
</Typography>
{isPWAPushSupported() ? (
pushSubscribed ? (
<Chip
label="활성화"
size="small"
color="success"
icon={<NotificationsIcon />}
/>
) : (
<Chip
label="비활성화"
size="small"
color="default"
icon={<NotificationsOffIcon />}
/>
)
) : (
<Chip
label="사용 불가"
size="small"
color="warning"
/>
)}
</Box>
{!isSecureContext() && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="caption">
HTTP PWA Web Push를 .
</Typography>
</Alert>
)}
<FormControlLabel
control={
<Switch
checked={pushSubscribed}
onChange={handlePushToggle}
disabled={!isPWAPushSupported() || saving || checkingPush}
/>
}
label={
<Box>
<Typography variant="body1"> </Typography>
<Typography variant="caption" color="text.secondary">
{isPWAPushSupported()
? '브라우저를 닫아도 알림을 받을 수 있습니다'
: 'HTTPS 환경에서만 사용 가능'}
</Typography>
</Box>
}
/>
</Box>
<Divider sx={{ my: 3 }} />
{/* 설정 Export/Import */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<SettingsIcon color="primary" />
<Typography variant="subtitle2" color="text.secondary">
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
JSON , .
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="outlined"
startIcon={exporting ? <CircularProgress size={20} /> : <FileDownloadIcon />}
onClick={handleExport}
disabled={exporting || importing}
sx={{ minWidth: 140 }}
>
{exporting ? '내보내는 중...' : '설정 내보내기'}
</Button>
<Button
variant="outlined"
startIcon={importing ? <CircularProgress size={20} /> : <FileUploadIcon />}
onClick={handleImportClick}
disabled={exporting || importing}
sx={{ minWidth: 140 }}
>
{importing ? '가져오는 중...' : '설정 가져오기'}
</Button>
{/* 숨겨진 파일 입력 */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".json"
style={{ display: 'none' }}
/>
</Box>
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
📦 <strong> :</strong> , , ,
</Typography>
</Alert>
</Box>
{/* Import 확인 다이얼로그 */}
<Dialog
open={importConfirmOpen}
onClose={handleImportCancel}
>
<DialogTitle> </DialogTitle>
<DialogContent>
<DialogContentText>
<strong>{selectedFile?.name}</strong> .
<br /><br />
. ?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleImportCancel} color="inherit">
</Button>
<Button onClick={handleImportConfirm} color="primary" variant="contained">
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default GeneralSettingsTab;
@@ -0,0 +1,376 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Select,
MenuItem,
FormControl,
Switch,
Button,
Chip,
Alert,
AlertTitle,
CircularProgress,
Tooltip,
IconButton,
} from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import RefreshIcon from '@mui/icons-material/Refresh';
import RecommendIcon from '@mui/icons-material/Recommend';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import {
IndicatorCalculationSetting,
MAPriceType,
getAllSettings,
getPriceTypes,
updateSetting,
resetToDefaults,
applyRecommended,
} from '../../services/indicatorCalculationSettingService';
/**
*
*/
const IndicatorCalculationSettingsTab: React.FC = () => {
const [settings, setSettings] = useState<IndicatorCalculationSetting[]>([]);
const [priceTypes, setPriceTypes] = useState<MAPriceType[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [saving, setSaving] = useState<Record<number, boolean>>({});
const [successMessage, setSuccessMessage] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
// 초기 데이터 로드
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
setErrorMessage('');
const [settingsData, priceTypesData] = await Promise.all([
getAllSettings(),
getPriceTypes(),
]);
setSettings(settingsData);
setPriceTypes(priceTypesData);
} catch (error) {
console.error('설정 로드 실패:', error);
setErrorMessage('설정을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 가격 타입 변경
const handlePriceTypeChange = async (id: number, priceType: string) => {
try {
setSaving({ ...saving, [id]: true });
setSuccessMessage('');
setErrorMessage('');
// 현재 설정 찾기
const currentSetting = settings.find((s) => s.id === id);
if (!currentSetting) {
setErrorMessage('설정을 찾을 수 없습니다.');
return;
}
// enabled 값을 포함하여 업데이트
const updatedSetting = await updateSetting(id, {
priceType,
enabled: currentSetting.enabled,
} as any);
setSettings((prev) =>
prev.map((s) => (s.id === id ? updatedSetting : s))
);
setSuccessMessage('설정이 저장되었습니다.');
setTimeout(() => setSuccessMessage(''), 3000);
} catch (error) {
console.error('설정 저장 실패:', error);
setErrorMessage('설정 저장에 실패했습니다.');
} finally {
setSaving({ ...saving, [id]: false });
}
};
// 활성화/비활성화 토글
const handleEnabledToggle = async (id: number, enabled: boolean) => {
try {
setSaving({ ...saving, [id]: true });
setSuccessMessage('');
setErrorMessage('');
// 현재 설정 찾기
const currentSetting = settings.find((s) => s.id === id);
if (!currentSetting) {
setErrorMessage('설정을 찾을 수 없습니다.');
return;
}
// priceType 값을 포함하여 업데이트
const updatedSetting = await updateSetting(id, {
priceType: currentSetting.priceType,
enabled,
} as any);
setSettings((prev) =>
prev.map((s) => (s.id === id ? updatedSetting : s))
);
setSuccessMessage('설정이 저장되었습니다.');
setTimeout(() => setSuccessMessage(''), 3000);
} catch (error) {
console.error('설정 저장 실패:', error);
setErrorMessage('설정 저장에 실패했습니다.');
} finally {
setSaving({ ...saving, [id]: false });
}
};
// 기본값으로 초기화
const handleReset = async () => {
if (!window.confirm('모든 설정을 기본값으로 초기화하시겠습니까?')) {
return;
}
try {
setLoading(true);
setSuccessMessage('');
setErrorMessage('');
const message = await resetToDefaults();
await loadData();
setSuccessMessage(message);
} catch (error) {
console.error('초기화 실패:', error);
setErrorMessage('초기화에 실패했습니다.');
} finally {
setLoading(false);
}
};
// 권장 설정 적용
const handleApplyRecommended = async () => {
if (
!window.confirm(
'골든/데드크로스 감지 최적화를 위한 권장 설정을 적용하시겠습니까?\n(모든 지표 → Typical Price 방식)'
)
) {
return;
}
try {
setLoading(true);
setSuccessMessage('');
setErrorMessage('');
const message = await applyRecommended();
await loadData();
setSuccessMessage(message);
} catch (error) {
console.error('권장 설정 적용 실패:', error);
setErrorMessage('권장 설정 적용에 실패했습니다.');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={400}>
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h6"> </Typography>
<Box display="flex" gap={1}>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={loadData}
>
</Button>
<Button
variant="outlined"
size="small"
color="warning"
startIcon={<SettingsBackupRestoreIcon />}
onClick={handleReset}
>
</Button>
<Button
variant="contained"
size="small"
color="success"
startIcon={<RecommendIcon />}
onClick={handleApplyRecommended}
>
</Button>
</Box>
</Box>
{/* 설명 */}
<Alert severity="info" sx={{ mb: 3 }}>
<AlertTitle> </AlertTitle>
<Typography variant="body2">
.
<br />
<strong>:</strong> / {' '}
<strong>Typical Price (HLC/3)</strong> .
<br />
.
</Typography>
</Alert>
{/* 성공 메시지 */}
{successMessage && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessMessage('')}>
{successMessage}
</Alert>
)}
{/* 에러 메시지 */}
{errorMessage && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setErrorMessage('')}>
{errorMessage}
</Alert>
)}
{/* 설정 테이블 */}
<TableContainer component={Paper} elevation={2}>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell> </TableCell>
<TableCell width="40%"></TableCell>
<TableCell align="center"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{settings.map((setting) => (
<TableRow
key={setting.id}
sx={{
opacity: setting.enabled ? 1 : 0.5,
backgroundColor: setting.enabled ? 'inherit' : 'action.hover',
}}
>
<TableCell>
<Typography variant="body1" fontWeight="bold">
{setting.indicatorName}
</Typography>
</TableCell>
<TableCell>
<Chip label={setting.indicatorType} size="small" color="primary" />
</TableCell>
<TableCell>
<FormControl size="small" fullWidth disabled={!setting.enabled || saving[setting.id]}>
<Select
value={setting.priceType}
onChange={(e) =>
handlePriceTypeChange(setting.id, e.target.value as string)
}
>
{priceTypes.map((type) => (
<MenuItem key={type.value} value={type.value}>
<Box>
<Typography variant="body2">{type.koreanName}</Typography>
<Typography variant="caption" color="text.secondary">
{type.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<Box mt={0.5}>
<Typography variant="caption" color="text.secondary">
: {setting.priceTypeName}
</Typography>
</Box>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{setting.description}
</Typography>
</TableCell>
<TableCell align="center">
<Switch
checked={setting.enabled}
onChange={(e) =>
handleEnabledToggle(setting.id, e.target.checked)
}
disabled={saving[setting.id]}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 가격 타입 설명 */}
<Paper elevation={1} sx={{ p: 2, mt: 3 }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<InfoIcon color="primary" />
<Typography variant="h6"> </Typography>
</Box>
<Box component="ul" sx={{ pl: 2 }}>
<li>
<Typography variant="body2">
<strong> (Close):</strong> . .
</Typography>
</li>
<li>
<Typography variant="body2">
<strong> (Typical Price, HLC/3):</strong> ( + + ) / 3.
. <strong>!</strong>
</Typography>
</li>
<li>
<Typography variant="body2">
<strong> (Weighted Close, HLCC/4):</strong> ( + + *2) / 4.
.
</Typography>
</li>
<li>
<Typography variant="body2">
<strong> (Median Price, HL/2):</strong> ( + ) / 2.
.
</Typography>
</li>
<li>
<Typography variant="body2">
<strong>OHLC평균 (OHLC Average):</strong> ( + + + ) / 4.
.
</Typography>
</li>
</Box>
</Paper>
</Box>
);
};
export default IndicatorCalculationSettingsTab;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Switch,
Divider,
Alert,
Paper,
Button,
Chip,
TextField,
Slider,
FormControlLabel,
} from '@mui/material';
import { DragIndicator, RestartAlt, Settings } from '@mui/icons-material';
import { useChartColors, ChartColorSettings } from '../../contexts/ChartColorContext';
import { useIndicatorSettings, IndicatorSettings } from '../../contexts/IndicatorSettingsContext';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useIndicatorVisibility, IndicatorVisibilitySettings, IndicatorOrder } from '../../contexts/IndicatorVisibilityContext';
import { useAuth } from '../../contexts/AuthContext';
import MASettingsDialog from '../MASettingsDialog';
import IchimokuSettingsDialog from '../IchimokuSettingsDialog';
// 지표별 색상/굵기 설정 키 매핑 (여러 색상 지원)
interface ColorConfig {
label: string;
colorKey: keyof ChartColorSettings;
widthKey?: keyof ChartColorSettings;
}
const indicatorColorConfigs: Record<string, ColorConfig[]> = {
macdLine: [
{ label: 'MACD', colorKey: 'macdLine', widthKey: 'macdLineWidth' },
{ label: '시그널', colorKey: 'signalLine', widthKey: 'signalLineWidth' },
],
rsi: [{ label: '', colorKey: 'rsiColor', widthKey: 'rsiWidth' }],
stochasticK: [
{ label: '%K', colorKey: 'stochasticK', widthKey: 'stochasticKWidth' },
{ label: '%D', colorKey: 'stochasticD', widthKey: 'stochasticDWidth' },
],
cci: [{ label: '', colorKey: 'cciColor', widthKey: 'cciWidth' }],
adx: [{ label: '', colorKey: 'adxColor', widthKey: 'adxWidth' }],
plusDi: [
{ label: '+DI', colorKey: 'plusDiColor', widthKey: 'plusDiWidth' },
{ label: '-DI', colorKey: 'minusDiColor', widthKey: 'minusDiWidth' },
],
minusDi: [],
williamsR: [{ label: '', colorKey: 'williamsRColor', widthKey: 'williamsRWidth' }],
trix: [
{ label: 'TRIX', colorKey: 'trixColor', widthKey: 'trixWidth' },
{ label: '시그널', colorKey: 'trixSignalColor', widthKey: 'trixSignalWidth' },
],
newPsychological: [{ label: '', colorKey: 'newPsychologicalColor', widthKey: 'newPsychologicalWidth' }],
investPsychological: [{ label: '', colorKey: 'investPsychologicalColor', widthKey: 'investPsychologicalWidth' }],
bwi: [{ label: '', colorKey: 'bwiColor', widthKey: 'bwiWidth' }],
obv: [{ label: '', colorKey: 'obvColor', widthKey: 'obvWidth' }],
volumeOsc: [{ label: '', colorKey: 'volumeOscColor', widthKey: 'volumeOscWidth' }],
vr: [{ label: '', colorKey: 'vrColor', widthKey: 'vrWidth' }],
disparity: [
{ label: '초단기', colorKey: 'disparityUltraShortColor', widthKey: 'disparityUltraShortWidth' },
{ label: '단기', colorKey: 'disparityShortColor', widthKey: 'disparityShortWidth' },
{ label: '중기', colorKey: 'disparityMidColor', widthKey: 'disparityMidWidth' },
{ label: '장기', colorKey: 'disparityLongColor', widthKey: 'disparityLongWidth' },
],
};
// 지표별 파라미터 키 매핑
const indicatorParamKeys: Record<string, string[]> = {
macdLine: ['macdFastPeriod', 'macdSlowPeriod', 'macdSignalPeriod'],
rsi: ['rsiPeriod'],
stochasticK: ['stochasticKPeriod', 'stochasticDPeriod', 'stochasticSlowing'],
cci: ['cciPeriod', 'cciSignalPeriod'],
adx: ['adxPeriod'],
williamsR: ['williamsRPeriod'],
trix: ['trixPeriod', 'trixSignalPeriod'],
newPsychological: ['newPsychologicalPeriod'],
investPsychological: ['investPsychologicalPeriod'],
bwi: ['bwiPeriod'],
obv: ['obvPeriod'],
volumeOsc: ['volumeOscShortPeriod', 'volumeOscLongPeriod'],
vr: ['vrPeriod'],
disparity: ['disparityUltraShortPeriod', 'disparityShortPeriod', 'disparityMidPeriod', 'disparityLongPeriod'],
plusDi: ['dmiPeriod'],
minusDi: ['dmiPeriod'],
};
// Sortable 아이템 컴포넌트
interface SortableItemProps {
indicator: IndicatorOrder;
isDisabled: boolean;
checked: boolean;
onToggle: () => void;
colors: ChartColorSettings;
updateColors: (newColors: Partial<ChartColorSettings>) => void;
indicatorSettings: IndicatorSettings;
updateIndicatorSettings: (newSettings: Partial<IndicatorSettings>) => void;
}
const SortableItem: React.FC<SortableItemProps> = ({
indicator,
isDisabled,
checked,
onToggle,
colors,
updateColors,
indicatorSettings,
updateIndicatorSettings,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: indicator.id, disabled: isDisabled || indicator.settingKey === 'candleChart' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const canDrag = !isDisabled && indicator.settingKey !== 'candleChart';
const colorConfigs = indicatorColorConfigs[indicator.id] || [];
const paramKeys = indicatorParamKeys[indicator.id];
const hasSettings = indicator.settingKey !== 'candleChart' && indicator.settingKey !== 'volume';
const primaryColor = colorConfigs[0];
const secondaryColors = colorConfigs.slice(1);
return (
<Paper
ref={setNodeRef}
style={style}
sx={{
mb: 1,
border: '1px solid',
borderColor: checked ? 'primary.main' : 'divider',
borderRadius: 1,
bgcolor: isDragging ? 'action.hover' : checked ? 'action.selected' : 'background.paper',
opacity: checked ? 1 : 0.7,
}}
>
<Box sx={{
px: 1.5,
py: 1.2,
display: 'flex',
alignItems: 'center',
gap: 1.5,
minHeight: 48,
}}>
{/* 사용 스위치 */}
<Box sx={{ width: 50, display: 'flex', justifyContent: 'center' }}>
<Switch
checked={checked}
onChange={onToggle}
disabled={isDisabled}
size="small"
/>
</Box>
{/* 드래그 핸들 + 지표명 */}
<Box sx={{ width: 120, display: 'flex', alignItems: 'center', gap: 0.5 }}>
{canDrag && (
<Box
{...attributes}
{...listeners}
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' }, display: 'flex' }}
>
<DragIndicator color="action" sx={{ fontSize: 18 }} />
</Box>
)}
<Typography variant="body2" sx={{ fontWeight: checked ? 'bold' : 'normal', fontSize: '13px' }}>
{indicator.label}
</Typography>
</Box>
{/* 파라미터 영역 */}
<Box sx={{ width: 200, display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasSettings && paramKeys && paramKeys.map((paramKey) => (
<TextField
key={paramKey}
type="number"
size="small"
value={(indicatorSettings as any)[paramKey] || ''}
onChange={(e) => updateIndicatorSettings({ [paramKey]: Number(e.target.value) })}
disabled={!checked}
inputProps={{ min: 1, max: 999, style: { fontSize: 12, padding: '6px 8px', textAlign: 'center' } }}
sx={{
width: paramKeys.length > 2 ? 55 : 65,
'& .MuiOutlinedInput-root': { bgcolor: 'background.default' },
}}
/>
))}
</Box>
{/* 색상 영역 */}
<Box sx={{ width: 120, display: 'flex', alignItems: 'center', gap: 1 }}>
{hasSettings && primaryColor && (
<Box sx={{ position: 'relative' }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
bgcolor: colors[primaryColor.colorKey] as string,
border: '2px solid',
borderColor: 'divider',
cursor: checked ? 'pointer' : 'default',
opacity: checked ? 1 : 0.5,
}}
/>
<input
type="color"
value={colors[primaryColor.colorKey] as string || '#666666'}
onChange={(e) => updateColors({ [primaryColor.colorKey]: e.target.value })}
disabled={!checked}
style={{ position: 'absolute', top: 0, left: 0, width: 32, height: 32, opacity: 0, cursor: 'pointer' }}
/>
</Box>
)}
{/* 추가 색상 (시그널 등) */}
{hasSettings && secondaryColors.map((cfg) => (
<Box key={cfg.colorKey} sx={{ position: 'relative' }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 0.5,
bgcolor: colors[cfg.colorKey] as string,
border: '1px solid',
borderColor: 'divider',
cursor: checked ? 'pointer' : 'default',
opacity: checked ? 1 : 0.5,
}}
/>
<input
type="color"
value={colors[cfg.colorKey] as string || '#666666'}
onChange={(e) => updateColors({ [cfg.colorKey]: e.target.value })}
disabled={!checked}
style={{ position: 'absolute', top: 0, left: 0, width: 24, height: 24, opacity: 0, cursor: 'pointer' }}
/>
</Box>
))}
</Box>
{/* 선 굵기 영역 */}
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
{hasSettings && primaryColor?.widthKey && (
<>
<Slider
value={colors[primaryColor.widthKey] as number || 1}
onChange={(_, value) => updateColors({ [primaryColor.widthKey!]: value as number })}
disabled={!checked}
min={0.5}
max={5}
step={0.5}
sx={{ flex: 1, maxWidth: 150 }}
size="small"
/>
<Typography variant="caption" sx={{ minWidth: 30, fontSize: '11px', color: 'text.secondary' }}>
{colors[primaryColor.widthKey]}px
</Typography>
{/* 선 미리보기 */}
<Box
sx={{
width: 40,
height: Math.max((colors[primaryColor.widthKey] as number || 1) * 2, 2),
bgcolor: checked ? colors[primaryColor.colorKey] as string : 'grey.400',
borderRadius: 0.5,
}}
/>
</>
)}
</Box>
</Box>
</Paper>
);
};
const IndicatorVisibilityTab: React.FC = () => {
const { isAuthenticated } = useAuth();
const { settings, order, updateSettings, updateOrder, saveSettings, resetSettings } = useIndicatorVisibility();
const { colors, updateColors } = useChartColors();
const { indicatorSettings, updateIndicatorSettings } = useIndicatorSettings();
const [tempSettings, setTempSettings] = useState<IndicatorVisibilitySettings>(settings);
const [tempOrder, setTempOrder] = useState<IndicatorOrder[]>(order);
const [success, setSuccess] = useState(false);
const [maSettingsDialogOpen, setMaSettingsDialogOpen] = useState(false);
const [ichimokuSettingsDialogOpen, setIchimokuSettingsDialogOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = tempOrder.findIndex((item) => item.id === active.id);
const newIndex = tempOrder.findIndex((item) => item.id === over.id);
const newOrder = arrayMove(tempOrder, oldIndex, newIndex);
setTempOrder(newOrder);
updateOrder(newOrder);
}
};
const handleToggle = (settingKey: string) => {
if (settingKey === 'candleChart' || settingKey === 'volume') {
return;
}
const newSettings = {
...tempSettings,
[settingKey]: !tempSettings[settingKey as keyof IndicatorVisibilitySettings],
};
setTempSettings(newSettings);
updateSettings(newSettings);
};
const handleSave = async () => {
await saveSettings(isAuthenticated);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
};
const handleReset = () => {
if (window.confirm('모든 보조지표 설정을 초기화하시겠습니까?')) {
resetSettings();
setTempSettings(settings);
setTempOrder(order);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
(15)
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<RestartAlt />}
onClick={handleReset}
color="secondary"
>
</Button>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
.
</Alert>
)}
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
.
</Typography>
<Typography variant="body2">
.
</Typography>
<Typography variant="body2">
15 보조지표: 신심리도, , MACD, CCI, ADX, BWI, DMI, OBV, RSI, Stochastic Slow, Williams %R, TRIX, VolumeOSC, VR,
</Typography>
</Alert>
{/* 헤더 행 - 이동평균선 설정과 동일한 스타일 */}
<Paper sx={{ p: 1.5, mb: 2, bgcolor: 'primary.main', opacity: 0.9 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 50, textAlign: 'center' }}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Box>
<Box sx={{ width: 120 }}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Box>
<Box sx={{ width: 200 }}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Box>
<Box sx={{ width: 120 }}>
<Typography variant="caption" fontWeight="bold" color="white"></Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" fontWeight="bold" color="white"> </Typography>
</Box>
</Box>
</Paper>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={tempOrder.map((item) => item.id)} strategy={verticalListSortingStrategy}>
{tempOrder.map((indicator) => {
const settingKey = indicator.settingKey as keyof IndicatorVisibilitySettings;
const isDisabled = settingKey === 'candleChart' || settingKey === 'volume';
const value = tempSettings[settingKey];
const checked = typeof value === 'boolean' ? value : false;
return (
<SortableItem
key={indicator.id}
indicator={indicator}
isDisabled={isDisabled}
checked={checked}
onToggle={() => handleToggle(settingKey)}
colors={colors}
updateColors={updateColors}
indicatorSettings={indicatorSettings}
updateIndicatorSettings={updateIndicatorSettings}
/>
);
})}
</SortableContext>
</DndContext>
<Divider sx={{ my: 3 }} />
{/* 이동평균선 설정 (캔들차트 오버레이) */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
📈 ( )
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
. , , .
</Typography>
</Alert>
{/* 현재 활성화된 MA 요약 */}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
{settings.maLines?.map((ma) => (
<Chip
key={ma.id}
label={`${ma.id}(${ma.period})`}
size="small"
variant={ma.enabled ? "filled" : "outlined"}
sx={{
backgroundColor: ma.enabled ? ma.color : 'transparent',
color: ma.enabled ? '#fff' : 'text.secondary',
borderColor: ma.color,
fontWeight: ma.enabled ? 'bold' : 'normal',
'& .MuiChip-label': {
textShadow: ma.enabled ? '0 1px 2px rgba(0,0,0,0.5)' : 'none',
},
}}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary">
{settings.maLines?.filter(ma => ma.enabled).length || 0}
</Typography>
</Paper>
{/* 설정 버튼 */}
<Button
variant="outlined"
startIcon={<Settings />}
onClick={() => setMaSettingsDialogOpen(true)}
fullWidth
sx={{ py: 1.5 }}
>
</Button>
</Box>
{/* 이동평균선 설정 다이얼로그 */}
<MASettingsDialog
open={maSettingsDialogOpen}
onClose={() => setMaSettingsDialogOpen(false)}
/>
<Divider sx={{ my: 3 }} />
{/* 일목균형표 설정 (캔들차트 오버레이) */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2 }}>
( )
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
.
</Typography>
</Alert>
<Paper sx={{ p: 1.5, mb: 1 }}>
<FormControlLabel
control={
<Switch
checked={tempSettings.ichimokuTenkan || false}
onChange={() => handleToggle('ichimokuTenkan')}
/>
}
label={
<Box>
<Typography variant="body1" sx={{ fontWeight: tempSettings.ichimokuTenkan ? 600 : 400 }}>
(9)
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
}
/>
</Paper>
<Paper sx={{ p: 1.5, mb: 1 }}>
<FormControlLabel
control={
<Switch
checked={tempSettings.ichimokuKijun || false}
onChange={() => handleToggle('ichimokuKijun')}
/>
}
label={
<Box>
<Typography variant="body1" sx={{ fontWeight: tempSettings.ichimokuKijun ? 600 : 400 }}>
(26)
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
}
/>
</Paper>
<Paper sx={{ p: 1.5, mb: 1 }}>
<FormControlLabel
control={
<Switch
checked={tempSettings.ichimokuChikou || false}
onChange={() => handleToggle('ichimokuChikou')}
/>
}
label={
<Box>
<Typography variant="body1" sx={{ fontWeight: tempSettings.ichimokuChikou ? 600 : 400 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
26
</Typography>
</Box>
}
/>
</Paper>
<Paper sx={{ p: 1.5, mb: 1 }}>
<FormControlLabel
control={
<Switch
checked={tempSettings.ichimokuSenkouA || false}
onChange={() => handleToggle('ichimokuSenkouA')}
/>
}
label={
<Box>
<Typography variant="body1" sx={{ fontWeight: tempSettings.ichimokuSenkouA ? 600 : 400 }}>
1 +
</Typography>
<Typography variant="caption" color="text.secondary">
(+)/2 26
</Typography>
</Box>
}
/>
</Paper>
<Paper sx={{ p: 1.5 }}>
<FormControlLabel
control={
<Switch
checked={tempSettings.ichimokuSenkouB || false}
onChange={() => handleToggle('ichimokuSenkouB')}
/>
}
label={
<Box>
<Typography variant="body1" sx={{ fontWeight: tempSettings.ichimokuSenkouB ? 600 : 400 }}>
2 +
</Typography>
<Typography variant="caption" color="text.secondary">
52 26
</Typography>
</Box>
}
/>
</Paper>
<Button
variant="outlined"
startIcon={<Settings />}
onClick={() => setIchimokuSettingsDialogOpen(true)}
sx={{ width: '100%', mt: 2 }}
>
(/)
</Button>
</Box>
{/* 일목균형표 설정 다이얼로그 */}
<IchimokuSettingsDialog
open={ichimokuSettingsDialogOpen}
onClose={() => setIchimokuSettingsDialogOpen(false)}
/>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="contained" onClick={handleSave}>
</Button>
</Box>
</Box>
);
};
export default IndicatorVisibilityTab;
@@ -0,0 +1,638 @@
import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
Typography,
Switch,
FormControlLabel,
Grid,
Alert,
Button,
Divider,
Paper,
Chip,
CircularProgress,
} from '@mui/material';
import { RestartAlt, Schedule as ScheduleIcon, TrendingUp } from '@mui/icons-material';
interface SchedulerSettings {
marketSyncCron: string;
marketSyncEnabled: boolean;
popularSyncInterval: number;
popularSyncEnabled: boolean;
allMarketsSyncCron: string;
allMarketsSyncEnabled: boolean;
cleanupCron: string;
cleanupEnabled: boolean;
// 추세 강도 갱신 설정
trendRefreshInterval: number;
trendRefreshEnabled: boolean;
// 상승 종목 스캔 설정
risingStockScanEnabled: boolean;
risingStockScanInterval: number;
}
const defaultSettings: SchedulerSettings = {
marketSyncCron: '0 0 9 * * *',
marketSyncEnabled: true,
popularSyncInterval: 300000,
popularSyncEnabled: true,
allMarketsSyncCron: '0 0 10 * * *',
allMarketsSyncEnabled: true,
cleanupCron: '0 0 3 * * *',
cleanupEnabled: true,
// 추세 강도 갱신: 기본 3분
trendRefreshInterval: 180000,
trendRefreshEnabled: true,
// 상승 종목 스캔: 기본 30초
risingStockScanEnabled: true,
risingStockScanInterval: 30000,
};
// 추세 갱신 설정 localStorage 키
const TREND_REFRESH_STORAGE_KEY = 'trendRefreshSettings';
// 추세 갱신 설정 로드 (외부에서 사용)
export const loadTrendRefreshSettings = (): { interval: number; enabled: boolean } => {
try {
const saved = localStorage.getItem(TREND_REFRESH_STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (e) {
console.error('추세 갱신 설정 로드 실패:', e);
}
return { interval: defaultSettings.trendRefreshInterval, enabled: defaultSettings.trendRefreshEnabled };
};
// 추세 갱신 설정 저장
const saveTrendRefreshSettings = (interval: number, enabled: boolean) => {
try {
localStorage.setItem(TREND_REFRESH_STORAGE_KEY, JSON.stringify({ interval, enabled }));
} catch (e) {
console.error('추세 갱신 설정 저장 실패:', e);
}
};
const SchedulerSettingsTab: React.FC = () => {
const [settings, setSettings] = useState<SchedulerSettings>(defaultSettings);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const response = await fetch('/api/settings/scheduler', {
credentials: 'include',
});
// localStorage에서 추세 갱신 설정 로드
const trendSettings = loadTrendRefreshSettings();
// 상승 종목 스캔 설정 로드
const risingStockResponse = await fetch('/api/rising-stock-scheduler/config', {
credentials: 'include',
});
let risingStockConfig = {
enabled: defaultSettings.risingStockScanEnabled,
scanInterval: defaultSettings.risingStockScanInterval,
};
if (risingStockResponse.ok) {
const risingStockData = await risingStockResponse.json();
risingStockConfig = {
enabled: risingStockData.enabled,
scanInterval: risingStockData.scanInterval,
};
}
if (response.ok) {
const data = await response.json();
console.log('스케줄러 설정 로드:', data);
// 서버 설정과 localStorage 추세 설정, 상승 종목 스캔 설정 병합
setSettings({
...data,
trendRefreshInterval: trendSettings.interval,
trendRefreshEnabled: trendSettings.enabled,
risingStockScanEnabled: risingStockConfig.enabled,
risingStockScanInterval: risingStockConfig.scanInterval,
});
} else {
console.warn('설정 로드 실패, 기본값 사용');
setSettings({
...defaultSettings,
trendRefreshInterval: trendSettings.interval,
trendRefreshEnabled: trendSettings.enabled,
risingStockScanEnabled: risingStockConfig.enabled,
risingStockScanInterval: risingStockConfig.scanInterval,
});
}
} catch (err) {
console.error('설정 로드 실패:', err);
setError('설정을 불러오는데 실패했습니다.');
// 에러 시 기본값 사용
const trendSettings = loadTrendRefreshSettings();
setSettings({
...defaultSettings,
trendRefreshInterval: trendSettings.interval,
trendRefreshEnabled: trendSettings.enabled,
});
}
};
const handleSave = async (): Promise<boolean> => {
try {
setError(null);
// 추세 갱신 설정을 localStorage에 저장 (프론트엔드에서 사용)
saveTrendRefreshSettings(settings.trendRefreshInterval, settings.trendRefreshEnabled);
// 상승 종목 스캔 설정 저장
const risingStockResponse = await fetch('/api/rising-stock-scheduler/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
enabled: settings.risingStockScanEnabled,
scanInterval: settings.risingStockScanInterval,
maxResults: 20,
}),
});
if (!risingStockResponse.ok) {
throw new Error('상승 종목 스캔 설정 저장 실패');
}
const response = await fetch('/api/settings/scheduler', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(settings),
});
if (response.ok) {
console.log('스케줄러 설정 저장 성공');
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
// 설정 저장 후 재로드하여 확인
await loadSettings();
return true;
} else {
const errorText = await response.text();
throw new Error(errorText || '설정 저장 실패');
}
} catch (err: any) {
console.error('설정 저장 실패:', err);
setError(err.message || '설정 저장에 실패했습니다.');
return false;
}
};
const handleReset = async () => {
if (window.confirm('스케줄러 설정을 기본값으로 초기화하시겠습니까?')) {
try {
setSettings(defaultSettings);
// 기본값을 서버에 저장
const response = await fetch('/api/settings/scheduler', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(defaultSettings),
});
if (response.ok) {
console.log('스케줄러 설정 초기화 성공');
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
await loadSettings();
} else {
throw new Error('초기화 실패');
}
} catch (err: any) {
console.error('설정 초기화 실패:', err);
setError(err.message || '설정 초기화에 실패했습니다.');
}
}
};
const handleChange = (field: keyof SchedulerSettings, value: string | number | boolean) => {
setSettings(prev => ({ ...prev, [field]: value }));
};
const handleSaveAndReload = async () => {
if (loading) return; // 중복 클릭 방지
try {
setLoading(true);
setError(null);
console.log('스케줄러 설정 저장 시작...', settings);
// 1. 설정 저장
const saveSuccess = await handleSave();
if (!saveSuccess) {
console.error('설정 저장 실패, 스케줄러 재시작 중단');
setLoading(false);
return;
}
console.log('설정 저장 완료, 스케줄러 재시작 시작...');
// 2. 스케줄러 재시작
const reloadResponse = await fetch('/api/settings/scheduler/reload', {
method: 'POST',
credentials: 'include',
});
if (reloadResponse.ok) {
const message = await reloadResponse.text();
console.log('스케줄러 재시작 성공:', message);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} else {
const errorText = await reloadResponse.text();
throw new Error(errorText || '스케줄러 재시작 실패');
}
} catch (err: any) {
console.error('설정 저장 및 적용 실패:', err);
setError(err.message || '설정 저장 및 적용에 실패했습니다.');
} finally {
setLoading(false);
}
};
const parseCronDescription = (cron: string): string => {
// 간단한 cron 표현식 해석
const parts = cron.split(' ');
if (parts.length !== 6) return '잘못된 형식';
const [sec, min, hour, day, month, weekday] = parts;
if (hour !== '*' && min !== '*') {
return `매일 ${hour}${min}`;
}
return cron;
};
const convertIntervalToMinutes = (ms: number): number => {
return Math.floor(ms / 60000);
};
const convertMinutesToInterval = (minutes: number): number => {
return minutes * 60000;
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<RestartAlt />}
onClick={handleReset}
color="secondary"
>
</Button>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
. Cron 형식:
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
: "0 0 9 * * *" = 9 0 0
</Typography>
</Alert>
{/* 마켓 목록 동기화 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ScheduleIcon color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
</Typography>
<Chip
label={settings.marketSyncEnabled ? '활성화' : '비활성화'}
size="small"
color={settings.marketSyncEnabled ? 'success' : 'default'}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.marketSyncEnabled}
onChange={(e) => handleChange('marketSyncEnabled', e.target.checked)}
/>
}
label="마켓 목록 동기화 활성화"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="스케줄 (Cron 표현식)"
value={settings.marketSyncCron}
onChange={(e) => handleChange('marketSyncCron', e.target.value)}
disabled={!settings.marketSyncEnabled}
helperText={`해석: ${parseCronDescription(settings.marketSyncCron)}`}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary">
.
</Typography>
</Paper>
{/* 인기 종목 동기화 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ScheduleIcon color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
</Typography>
<Chip
label={settings.popularSyncEnabled ? '활성화' : '비활성화'}
size="small"
color={settings.popularSyncEnabled ? 'success' : 'default'}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.popularSyncEnabled}
onChange={(e) => handleChange('popularSyncEnabled', e.target.checked)}
/>
}
label="인기 종목 동기화 활성화"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="동기화 간격 (분)"
type="number"
value={convertIntervalToMinutes(settings.popularSyncInterval)}
onChange={(e) => handleChange('popularSyncInterval', convertMinutesToInterval(Number(e.target.value)))}
disabled={!settings.popularSyncEnabled}
inputProps={{ min: 1, max: 60 }}
helperText={`${convertIntervalToMinutes(settings.popularSyncInterval)}분마다 동기화`}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary">
BTC, ETH, XRP 10 MACD .
</Typography>
</Paper>
{/* 전체 마켓 동기화 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ScheduleIcon color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
</Typography>
<Chip
label={settings.allMarketsSyncEnabled ? '활성화' : '비활성화'}
size="small"
color={settings.allMarketsSyncEnabled ? 'success' : 'default'}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.allMarketsSyncEnabled}
onChange={(e) => handleChange('allMarketsSyncEnabled', e.target.checked)}
/>
}
label="전체 마켓 동기화 활성화"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="스케줄 (Cron 표현식)"
value={settings.allMarketsSyncCron}
onChange={(e) => handleChange('allMarketsSyncCron', e.target.value)}
disabled={!settings.allMarketsSyncEnabled}
helperText={`해석: ${parseCronDescription(settings.allMarketsSyncCron)}`}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary">
KRW MACD . ( )
</Typography>
</Paper>
{/* 추세 강도 갱신 (정배열/역배열) */}
<Paper sx={{ p: 3, mb: 3, border: '2px solid', borderColor: 'primary.main' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TrendingUp color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
(/)
</Typography>
<Chip
label={settings.trendRefreshEnabled ? '활성화' : '비활성화'}
size="small"
color={settings.trendRefreshEnabled ? 'success' : 'default'}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.trendRefreshEnabled}
onChange={(e) => handleChange('trendRefreshEnabled', e.target.checked)}
/>
}
label="추세 강도 자동 갱신 활성화"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="갱신 간격 (분)"
type="number"
value={convertIntervalToMinutes(settings.trendRefreshInterval)}
onChange={(e) => handleChange('trendRefreshInterval', convertMinutesToInterval(Number(e.target.value)))}
disabled={!settings.trendRefreshEnabled}
inputProps={{ min: 1, max: 60 }}
helperText={`${convertIntervalToMinutes(settings.trendRefreshInterval)}분마다 정배열/역배열 목록 갱신`}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary">
/ .
MA10, MA20, MA60, ATR(14) .
</Typography>
</Paper>
{/* 상승 종목 자동 스캔 */}
<Paper sx={{ p: 3, mb: 3, border: '2px solid', borderColor: 'success.main' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TrendingUp color="success" />
<Typography variant="subtitle1" fontWeight="bold">
</Typography>
<Chip
label={settings.risingStockScanEnabled ? '활성화' : '비활성화'}
size="small"
color={settings.risingStockScanEnabled ? 'success' : 'default'}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.risingStockScanEnabled}
onChange={(e) => handleChange('risingStockScanEnabled', e.target.checked)}
/>
}
label="상승 종목 자동 스캔 활성화"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="스캔 간격 (초)"
type="number"
value={Math.floor(settings.risingStockScanInterval / 1000)}
onChange={(e) => handleChange('risingStockScanInterval', Number(e.target.value) * 1000)}
disabled={!settings.risingStockScanEnabled}
inputProps={{ min: 10, max: 300 }}
helperText={`${Math.floor(settings.risingStockScanInterval / 1000)}초마다 TOP 20 상승 종목 스캔`}
sx={{ mb: 1 }}
/>
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption">
<strong>6 :</strong><br />
1단계: 하락추세 ( )<br />
2단계: 상승추세 ()<br />
3단계: 모멘텀 ( )<br />
4단계: 추가 ()<br />
5단계: 종합 <br />
6단계: 시장
</Typography>
</Alert>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
( 237) TOP 20 .
MA, MACD, .
</Typography>
</Paper>
{/* 오래된 데이터 정리 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ScheduleIcon color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
</Typography>
<Chip
label={settings.cleanupEnabled ? '활성화' : '비활성화'}
size="small"
color={settings.cleanupEnabled ? 'success' : 'default'}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.cleanupEnabled}
onChange={(e) => handleChange('cleanupEnabled', e.target.checked)}
/>
}
label="데이터 정리 활성화"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="스케줄 (Cron 표현식)"
value={settings.cleanupCron}
onChange={(e) => handleChange('cleanupCron', e.target.value)}
disabled={!settings.cleanupEnabled}
helperText={`해석: ${parseCronDescription(settings.cleanupCron)}`}
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary">
6 1 MACD .
</Typography>
</Paper>
<Divider sx={{ my: 3 }} />
<Box sx={{ bgcolor: '#f5f5f5', p: 2, borderRadius: 1, mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
📌 Cron
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
형식: (0-59) (0-59) (0-23) (1-31) (1-12) (0-7)
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
9: <code>0 0 9 * * *</code>
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
3: <code>0 0 3 * * *</code>
</Typography>
<Typography variant="body2" color="text.secondary">
: <code>0 0 * * * *</code>
</Typography>
</Box>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
"저장 및 적용" .
</Typography>
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button
variant="contained"
onClick={handleSaveAndReload}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : null}
>
{loading ? '저장 중...' : '저장 및 적용'}
</Button>
</Box>
</Box>
);
};
export default SchedulerSettingsTab;
@@ -0,0 +1,473 @@
import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Grid,
Alert,
Button,
Divider,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { RestartAlt } from '@mui/icons-material';
import {
SignalWeightSettings,
getSignalWeights,
saveSignalWeights,
resetSignalWeights,
getDefaultWeights,
} from '../../services/signalWeightApi';
const SignalWeightSettingsTab: React.FC = () => {
const [weights, setWeights] = useState<SignalWeightSettings>(getDefaultWeights());
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const loadedWeights = await getSignalWeights();
setWeights(loadedWeights);
} catch (err) {
console.error('설정 로드 실패:', err);
setError('설정을 불러오는데 실패했습니다.');
}
};
const handleSave = async () => {
try {
setError(null);
await saveSignalWeights(weights);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
console.error('설정 저장 실패:', err);
setError('설정 저장에 실패했습니다.');
}
};
const handleReset = async () => {
if (window.confirm('모든 설정을 기본값으로 초기화하시겠습니까?')) {
try {
setError(null);
await resetSignalWeights();
const defaultWeights = getDefaultWeights();
setWeights(defaultWeights);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
console.error('설정 초기화 실패:', err);
setError('설정 초기화에 실패했습니다.');
}
}
};
const handleChange = (field: keyof SignalWeightSettings, value: number) => {
setWeights(prev => ({ ...prev, [field]: value }));
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<RestartAlt />}
onClick={handleReset}
color="secondary"
>
</Button>
</Box>
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2">
.
</Typography>
</Alert>
{/* MACD */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
📈 MACD
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="골든크로스"
type="number"
value={weights.macdGoldenCross}
onChange={(e) => handleChange('macdGoldenCross', Number(e.target.value))}
helperText="MACD가 Signal을 상향 돌파"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="데드크로스"
type="number"
value={weights.macdDeadCross}
onChange={(e) => handleChange('macdDeadCross', Number(e.target.value))}
helperText="MACD가 Signal을 하향 돌파"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="히스토그램 상승 전환"
type="number"
value={weights.macdHistogramUp}
onChange={(e) => handleChange('macdHistogramUp', Number(e.target.value))}
helperText="히스토그램이 음수→양수"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="히스토그램 하락 전환"
type="number"
value={weights.macdHistogramDown}
onChange={(e) => handleChange('macdHistogramDown', Number(e.target.value))}
helperText="히스토그램이 양수→음수"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="제로라인 상향 돌파"
type="number"
value={weights.macdZeroCrossUp}
onChange={(e) => handleChange('macdZeroCrossUp', Number(e.target.value))}
helperText="MACD가 0선 상향 돌파"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="제로라인 하향 돌파"
type="number"
value={weights.macdZeroCrossDown}
onChange={(e) => handleChange('macdZeroCrossDown', Number(e.target.value))}
helperText="MACD가 0선 하향 돌파"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Stochastic Slow */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
📊 Stochastic Slow
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="과매도 구간 상향 돌파"
type="number"
value={weights.stochOversoldCrossUp}
onChange={(e) => handleChange('stochOversoldCrossUp', Number(e.target.value))}
helperText="%K가 %D를 상향 돌파 (과매도)"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="과매수 구간 하향 돌파"
type="number"
value={weights.stochOverboughtCrossDown}
onChange={(e) => handleChange('stochOverboughtCrossDown', Number(e.target.value))}
helperText="%K가 %D를 하향 돌파 (과매수)"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="과매도 기준값"
type="number"
value={weights.stochOversoldThreshold}
onChange={(e) => handleChange('stochOversoldThreshold', Number(e.target.value))}
helperText="기본값: 20"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="과매수 기준값"
type="number"
value={weights.stochOverboughtThreshold}
onChange={(e) => handleChange('stochOverboughtThreshold', Number(e.target.value))}
helperText="기본값: 80"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* ADX */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
📉 ADX
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="강한 상승 추세"
type="number"
value={weights.adxStrongUptrend}
onChange={(e) => handleChange('adxStrongUptrend', Number(e.target.value))}
helperText="ADX > 기준값 & +DI > -DI"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="강한 하락 추세"
type="number"
value={weights.adxStrongDowntrend}
onChange={(e) => handleChange('adxStrongDowntrend', Number(e.target.value))}
helperText="ADX > 기준값 & -DI > +DI"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="추세 강도 기준값"
type="number"
value={weights.adxThreshold}
onChange={(e) => handleChange('adxThreshold', Number(e.target.value))}
helperText="기본값: 25"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* ADX11 */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
📉 ADX11
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="강한 상승 추세 (ADX11)"
type="number"
value={weights.adx11StrongUptrend}
onChange={(e) => handleChange('adx11StrongUptrend', Number(e.target.value))}
helperText="ADX11 > 기준값 & +DI > -DI"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="강한 하락 추세 (ADX11)"
type="number"
value={weights.adx11StrongDowntrend}
onChange={(e) => handleChange('adx11StrongDowntrend', Number(e.target.value))}
helperText="ADX11 > 기준값 & -DI > +DI"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="추세 강도 기준값 (ADX11)"
type="number"
value={weights.adx11Threshold}
onChange={(e) => handleChange('adx11Threshold', Number(e.target.value))}
helperText="기본값: 25"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* CCI13 */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
📊 CCI13
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="강한 상승 모멘텀"
type="number"
value={weights.cci13StrongUp}
onChange={(e) => handleChange('cci13StrongUp', Number(e.target.value))}
helperText="CCI13 > 과매수 기준값"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="강한 하락 모멘텀"
type="number"
value={weights.cci13StrongDown}
onChange={(e) => handleChange('cci13StrongDown', Number(e.target.value))}
helperText="CCI13 < 과매도 기준값"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="과매수 기준값"
type="number"
value={weights.cci13OverboughtThreshold}
onChange={(e) => handleChange('cci13OverboughtThreshold', Number(e.target.value))}
helperText="기본값: 100"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="과매도 기준값"
type="number"
value={weights.cci13OversoldThreshold}
onChange={(e) => handleChange('cci13OversoldThreshold', Number(e.target.value))}
helperText="기본값: -100"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 거래량 */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
📊
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="거래량 급증"
type="number"
value={weights.volumeIncrease}
onChange={(e) => handleChange('volumeIncrease', Number(e.target.value))}
helperText="거래량 증가율 > 기준값"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="거래량 급감"
type="number"
value={weights.volumeDecrease}
onChange={(e) => handleChange('volumeDecrease', Number(e.target.value))}
helperText="거래량 감소율 < 기준값"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="거래량 증가율 기준 (%)"
type="number"
value={weights.volumeIncreaseThreshold}
onChange={(e) => handleChange('volumeIncreaseThreshold', Number(e.target.value))}
helperText="기본값: 30%"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="거래량 감소율 기준 (%)"
type="number"
value={weights.volumeDecreaseThreshold}
onChange={(e) => handleChange('volumeDecreaseThreshold', Number(e.target.value))}
helperText="기본값: -30%"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 종합 신호 기준 */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
🎯
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="매수 신호 기준 점수"
type="number"
value={weights.buySignalThreshold}
onChange={(e) => handleChange('buySignalThreshold', Number(e.target.value))}
helperText="종합 점수가 이 값보다 크면 매수"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="매도 신호 기준 점수"
type="number"
value={weights.sellSignalThreshold}
onChange={(e) => handleChange('sellSignalThreshold', Number(e.target.value))}
helperText="종합 점수가 이 값보다 작으면 매도"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="contained" onClick={handleSave}>
</Button>
</Box>
</Box>
);
};
export default SignalWeightSettingsTab;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,744 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Card,
CardContent,
CardActionArea,
Grid,
Chip,
useTheme,
alpha,
Divider,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Alert,
Snackbar,
} from '@mui/material';
import {
Computer as ComputerIcon,
Tablet as TabletIcon,
PhoneAndroid as PhoneIcon,
Check as CheckIcon,
Close as CloseIcon,
Visibility as VisibilityIcon,
OpenInNew as OpenInNewIcon,
Person as PersonIcon,
PersonOutline as PersonOutlineIcon,
Save as SaveIcon,
Undo as UndoIcon,
} from '@mui/icons-material';
import { useUITheme, UIThemeId, DeviceType, UIThemeInfo } from '../../contexts/UIThemeContext';
import { useAuth } from '../../contexts/AuthContext';
// 디바이스 아이콘 매핑
const getDeviceIcon = (device: DeviceType) => {
switch (device) {
case 'pc':
return <ComputerIcon />;
case 'tablet':
return <TabletIcon />;
case 'mobile':
return <PhoneIcon />;
default:
return <ComputerIcon />;
}
};
// 디바이스 이름 매핑
const getDeviceName = (device: DeviceType) => {
switch (device) {
case 'pc':
return 'PC (데스크톱)';
case 'tablet':
return '태블릿';
case 'mobile':
return '모바일';
default:
return device;
}
};
// 테마 미리보기 모달 컴포넌트
interface ThemePreviewModalProps {
open: boolean;
onClose: () => void;
theme: UIThemeInfo | null;
}
interface ThemePreviewModalProps {
open: boolean;
onClose: () => void;
theme: UIThemeInfo | null;
onApply?: () => void;
isApplied?: boolean;
}
const ThemePreviewModal: React.FC<ThemePreviewModalProps> = ({ open, onClose, theme, onApply, isApplied }) => {
const muiTheme = useTheme();
const isDark = muiTheme.palette.mode === 'dark';
const [loading, setLoading] = useState(true);
if (!theme) return null;
const badgeColor = theme.deviceType === 'pc'
? { bg: 'rgba(59, 130, 246, 0.2)', color: '#60a5fa' }
: theme.deviceType === 'tablet'
? { bg: 'rgba(139, 92, 246, 0.2)', color: '#a78bfa' }
: { bg: 'rgba(34, 197, 94, 0.2)', color: '#4ade80' };
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
bgcolor: isDark ? '#1a1a2e' : '#fff',
height: '85vh',
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Chip
label={theme.badgeCode}
size="small"
sx={{ bgcolor: 'rgba(255,255,255,0.2)', color: '#fff' }}
/>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
{theme.name}
</Typography>
{isApplied && (
<Chip label="현재 적용중" size="small" sx={{ bgcolor: 'rgba(255,255,255,0.2)', color: '#fff' }} />
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{theme.mockupUrl && (
<IconButton
onClick={() => window.open(theme.mockupUrl, '_blank')}
title="새 창에서 열기"
size="small"
sx={{ color: '#fff' }}
>
<OpenInNewIcon />
</IconButton>
)}
<IconButton onClick={onClose} size="small" sx={{ color: '#fff' }}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0, overflow: 'hidden', position: 'relative' }}>
{loading && theme.mockupUrl && (
<Box sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: isDark ? '#0f0f23' : '#f5f5f5',
zIndex: 1,
}}>
<Typography color="text.secondary"> ...</Typography>
</Box>
)}
{theme.mockupUrl ? (
<iframe
src={theme.mockupUrl}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title={`${theme.name} 미리보기`}
onLoad={() => setLoading(false)}
/>
) : (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
flexDirection: 'column',
gap: 2,
bgcolor: isDark ? '#0f0f23' : '#f5f5f5',
}}>
<Box sx={{
p: 4,
borderRadius: 2,
bgcolor: theme.style.backgroundColor,
border: `1px solid ${theme.style.borderColor}`,
textAlign: 'center',
minWidth: 300,
}}>
<Typography sx={{ color: theme.style.textColor, fontWeight: 600, mb: 1 }}>
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', mb: 2 }}>
<Box sx={{ width: 40, height: 40, borderRadius: 1, bgcolor: theme.style.primaryColor }} />
<Box sx={{ width: 40, height: 40, borderRadius: 1, bgcolor: theme.style.accentColor }} />
<Box sx={{ width: 40, height: 40, borderRadius: 1, bgcolor: theme.style.surfaceColor, border: `1px solid ${theme.style.borderColor}` }} />
</Box>
<Typography variant="caption" sx={{ color: theme.style.textSecondaryColor }}>
Primary / Accent / Surface
</Typography>
</Box>
</Box>
)}
</DialogContent>
<DialogActions sx={{
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,
p: 2,
gap: 1,
}}>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>
{theme.description}
</Typography>
{onApply && !isApplied && (
<Button
variant="contained"
startIcon={<CheckIcon />}
onClick={() => {
onApply();
onClose();
}}
>
</Button>
)}
<Button onClick={onClose} variant="outlined"></Button>
</DialogActions>
</Dialog>
);
};
// 테마 카드 컴포넌트
interface ThemeCardProps {
theme: UIThemeInfo;
isSelected: boolean;
isCurrentApplied: boolean;
onSelect: () => void;
onPreview: () => void;
}
const ThemeCard: React.FC<ThemeCardProps> = ({
theme,
isSelected,
isCurrentApplied,
onSelect,
onPreview
}) => {
const muiTheme = useTheme();
const isDark = muiTheme.palette.mode === 'dark';
const badgeColor = theme.deviceType === 'pc'
? { bg: 'rgba(59, 130, 246, 0.2)', color: '#60a5fa' }
: theme.deviceType === 'tablet'
? { bg: 'rgba(139, 92, 246, 0.2)', color: '#a78bfa' }
: { bg: 'rgba(34, 197, 94, 0.2)', color: '#4ade80' };
return (
<Card
sx={{
border: isSelected
? `2px solid ${muiTheme.palette.primary.main}`
: isCurrentApplied
? `2px solid ${muiTheme.palette.success.main}`
: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,
bgcolor: isSelected
? alpha(muiTheme.palette.primary.main, 0.05)
: isCurrentApplied
? alpha(muiTheme.palette.success.main, 0.03)
: 'background.paper',
transition: 'all 0.2s ease',
position: 'relative',
'&:hover': {
borderColor: muiTheme.palette.primary.main,
boxShadow: `0 4px 12px ${alpha(muiTheme.palette.primary.main, 0.2)}`,
},
}}
>
{/* 현재 적용 중 표시 */}
{isCurrentApplied && !isSelected && (
<Chip
label="현재 적용"
size="small"
color="success"
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
fontSize: '10px',
height: 20,
}}
/>
)}
{/* 미리보기 영역 */}
<Box
sx={{
height: 140,
position: 'relative',
overflow: 'hidden',
bgcolor: theme.style.backgroundColor,
cursor: 'pointer',
'&:hover .preview-overlay': {
opacity: 1,
},
}}
onClick={(e) => {
e.stopPropagation();
onPreview();
}}
>
{theme.mockupUrl ? (
<>
<iframe
src={theme.mockupUrl}
style={{
width: '300%',
height: '300%',
border: 'none',
transform: 'scale(0.333)',
transformOrigin: 'top left',
pointerEvents: 'none',
}}
title={theme.name}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, transparent 40%, rgba(15, 15, 35, 0.95) 100%)',
}}
/>
</>
) : (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
flexDirection: 'column',
gap: 1,
}}>
{/* 색상 미리보기 */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: theme.style.primaryColor }} />
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: theme.style.accentColor }} />
<Box sx={{ width: 24, height: 24, borderRadius: '50%', bgcolor: theme.style.surfaceColor, border: `1px solid ${theme.style.borderColor}` }} />
</Box>
<Typography sx={{ color: theme.style.textSecondaryColor, fontSize: '11px' }}>
</Typography>
</Box>
)}
{/* 미리보기 오버레이 */}
<Box
className="preview-overlay"
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.6)',
opacity: 0,
transition: 'opacity 0.2s ease',
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: '#fff',
bgcolor: 'rgba(0,0,0,0.5)',
px: 2,
py: 1,
borderRadius: 2,
}}>
<VisibilityIcon fontSize="small" />
<Typography fontSize="13px" fontWeight={500}></Typography>
</Box>
</Box>
</Box>
<CardActionArea onClick={onSelect}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={theme.badgeCode}
size="small"
sx={{
bgcolor: badgeColor.bg,
color: badgeColor.color,
fontSize: '10px',
height: 20,
}}
/>
<Typography variant="subtitle2" fontWeight={600}>
{theme.name}
</Typography>
</Box>
{isSelected && (
<CheckIcon sx={{ color: 'primary.main', fontSize: 18 }} />
)}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5, minHeight: 36, fontSize: '12px' }}>
{theme.description}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{theme.tags.map((tag, i) => (
<Chip
key={i}
label={tag}
size="small"
sx={{
fontSize: '10px',
height: 18,
bgcolor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)',
}}
/>
))}
</Box>
</CardContent>
</CardActionArea>
</Card>
);
};
// 디바이스별 테마 설정 섹션
interface DeviceThemeSectionProps {
device: DeviceType;
selectedThemeId: UIThemeId;
currentAppliedThemeId: UIThemeId;
availableThemes: UIThemeInfo[];
onThemeSelect: (themeId: UIThemeId) => void;
onThemePreview: (theme: UIThemeInfo, device: DeviceType) => void;
}
const DeviceThemeSection: React.FC<DeviceThemeSectionProps> = ({
device,
selectedThemeId,
currentAppliedThemeId,
availableThemes,
onThemeSelect,
onThemePreview,
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
const { currentDevice } = useUITheme();
const isCurrentDevice = currentDevice === device;
return (
<Box sx={{ mb: 4 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
pb: 1,
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,
}}>
<Box sx={{
display: 'flex',
alignItems: 'center',
color: isCurrentDevice ? 'primary.main' : 'text.secondary',
}}>
{getDeviceIcon(device)}
</Box>
<Typography variant="h6" fontWeight={600}>
{getDeviceName(device)}
</Typography>
{isCurrentDevice && (
<Chip
label="현재 디바이스"
size="small"
color="primary"
variant="outlined"
sx={{ height: 24, fontSize: '11px' }}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{availableThemes.length}
</Typography>
</Box>
<Grid container spacing={2}>
{availableThemes.map((themeInfo) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={themeInfo.id}>
<ThemeCard
theme={themeInfo}
isSelected={selectedThemeId === themeInfo.id}
isCurrentApplied={currentAppliedThemeId === themeInfo.id}
onSelect={() => onThemeSelect(themeInfo.id)}
onPreview={() => onThemePreview(themeInfo, device)}
/>
</Grid>
))}
</Grid>
</Box>
);
};
// 메인 컴포넌트
const UIThemeSettingsTab: React.FC = () => {
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
const { isAuthenticated, user } = useAuth();
const {
themeSettings,
currentDevice,
currentThemeId,
previewThemeForDevice,
applyTheme,
cancelPreview,
isPreviewing,
previewSettings,
getAvailableThemes,
getThemeInfo,
currentUserId,
} = useUITheme();
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewModalTheme, setPreviewModalTheme] = useState<UIThemeInfo | null>(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const devices: DeviceType[] = ['pc', 'tablet', 'mobile'];
// 원본 설정 (적용 버튼 전의 저장된 설정)
const getOriginalSettings = () => {
if (isPreviewing && previewSettings) {
// 미리보기 중이면 previewSettings에서 현재 미리보기 중인 값을 가져옴
// themeSettings는 이미 effectiveSettings이므로 원본을 따로 가져와야 함
}
return themeSettings;
};
const handleThemeSelect = (device: DeviceType, themeId: UIThemeId) => {
console.log('🎨 테마 선택:', { device, themeId, currentDevice, currentThemeId });
previewThemeForDevice(device, themeId);
};
const handleThemePreview = (themeInfo: UIThemeInfo, device: DeviceType) => {
setPreviewModalTheme(themeInfo);
setPreviewModalOpen(true);
// 현재 미리보기 중인 디바이스 저장
setPreviewDevice(device);
};
const [previewDevice, setPreviewDevice] = useState<DeviceType>('pc');
const handleApply = () => {
applyTheme();
setSnackbarMessage('테마 설정이 저장되었습니다.');
setSnackbarOpen(true);
};
const handleCancel = () => {
cancelPreview();
setSnackbarMessage('변경 사항이 취소되었습니다.');
setSnackbarOpen(true);
};
// 현재 저장된 설정과 미리보기 설정 비교
const hasChanges = isPreviewing;
return (
<Box>
{/* 사용자 상태 표시 */}
<Box sx={{
mb: 3,
p: 2,
borderRadius: 2,
bgcolor: isDark ? 'rgba(139, 92, 246, 0.1)' : 'rgba(139, 92, 246, 0.05)',
border: `1px solid ${isDark ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.2)'}`,
display: 'flex',
alignItems: 'center',
gap: 2,
}}>
{isAuthenticated ? (
<>
<PersonIcon sx={{ color: 'success.main' }} />
<Box>
<Typography variant="subtitle2" fontWeight={600}>
{user?.name || user?.username}
</Typography>
<Typography variant="caption" color="text.secondary">
-
</Typography>
</Box>
</>
) : (
<>
<PersonOutlineIcon sx={{ color: 'warning.main' }} />
<Box>
<Typography variant="subtitle2" fontWeight={600}>
</Typography>
<Typography variant="caption" color="text.secondary">
-
</Typography>
</Box>
</>
)}
</Box>
{/* 현재 적용 상태 표시 */}
<Box sx={{
mb: 3,
p: 2.5,
borderRadius: 2,
bgcolor: isDark ? 'rgba(100, 149, 237, 0.1)' : 'rgba(100, 149, 237, 0.05)',
border: `1px solid ${isDark ? 'rgba(100, 149, 237, 0.3)' : 'rgba(100, 149, 237, 0.2)'}`,
}}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getDeviceIcon(currentDevice)}
<Typography variant="body1" fontWeight={500}>
{getDeviceName(currentDevice)}
</Typography>
</Box>
<Typography color="text.secondary"></Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={getThemeInfo(currentThemeId)?.badgeCode || 'Default'}
size="small"
color="primary"
/>
<Typography variant="body1" fontWeight={600} color="primary.main">
{getThemeInfo(currentThemeId)?.name || '기본'}
</Typography>
</Box>
</Box>
</Box>
{/* 변경사항 알림 및 적용 버튼 */}
{hasChanges && (
<Alert
severity="info"
sx={{ mb: 3 }}
action={
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<UndoIcon />}
onClick={handleCancel}
color="inherit"
>
</Button>
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
onClick={handleApply}
>
</Button>
</Box>
}
>
. .
</Alert>
)}
<Divider sx={{ mb: 3 }} />
{/* 디바이스별 테마 설정 */}
<Typography variant="h6" gutterBottom sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
📱 UI
</Typography>
{devices.map((device) => {
// 현재 저장된 설정 (previewSettings가 없으면 themeSettings)
const savedThemeId = isPreviewing
? (previewSettings?.[device] || themeSettings[device])
: themeSettings[device];
// 원본 저장된 설정 (비교용)
const originalThemeId = themeSettings[device];
return (
<DeviceThemeSection
key={device}
device={device}
selectedThemeId={savedThemeId}
currentAppliedThemeId={originalThemeId}
availableThemes={getAvailableThemes(device)}
onThemeSelect={(themeId) => handleThemeSelect(device, themeId)}
onThemePreview={(theme, dev) => handleThemePreview(theme, dev)}
/>
);
})}
{/* 안내 메시지 */}
<Box sx={{
mt: 2,
p: 2,
borderRadius: 1,
bgcolor: isDark ? 'rgba(255, 193, 7, 0.1)' : 'rgba(255, 193, 7, 0.05)',
border: `1px solid ${isDark ? 'rgba(255, 193, 7, 0.3)' : 'rgba(255, 193, 7, 0.2)'}`,
}}>
<Typography variant="body2" color="text.secondary">
💡 <strong> :</strong>
<br />
1. 👁
<br />
2.
<br />
3. "적용"
<br />
4.
</Typography>
</Box>
{/* 미리보기 모달 */}
<ThemePreviewModal
open={previewModalOpen}
onClose={() => setPreviewModalOpen(false)}
theme={previewModalTheme}
onApply={() => {
if (previewModalTheme) {
handleThemeSelect(previewDevice, previewModalTheme.id);
setSnackbarMessage(`${previewModalTheme.name} 테마가 선택되었습니다. 적용 버튼을 눌러 저장하세요.`);
setSnackbarOpen(true);
}
}}
isApplied={previewModalTheme?.id === themeSettings[previewDevice]}
/>
{/* 스낵바 */}
<Snackbar
open={snackbarOpen}
autoHideDuration={2000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
/>
</Box>
);
};
export default UIThemeSettingsTab;
@@ -0,0 +1,320 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
Typography,
Divider,
Alert,
useTheme,
} from '@mui/material';
import { Save as SaveIcon, Close as CloseIcon } from '@mui/icons-material';
import { ConditionDSL } from '../../services/strategyDSLApi';
// ============================================================
// 타입 정의
// ============================================================
export interface ConditionEditorProps {
open: boolean;
condition: ConditionDSL | null;
onSave: (condition: ConditionDSL) => void;
onClose: () => void;
}
// ============================================================
// 지표 및 조건 타입 옵션
// ============================================================
const INDICATOR_OPTIONS = [
{ value: 'CCI', label: 'CCI (Commodity Channel Index)' },
{ value: 'MACD', label: 'MACD (Moving Average Convergence Divergence)' },
{ value: 'RSI', label: 'RSI (Relative Strength Index)' },
{ value: 'STOCHASTIC', label: 'Stochastic Oscillator' },
{ value: 'ADX', label: 'ADX (Average Directional Index)' },
{ value: 'TRIX', label: 'TRIX' },
{ value: 'DMI', label: 'DMI (Directional Movement Index)' },
{ value: 'OBV', label: 'OBV (On Balance Volume)' },
{ value: 'BWI', label: '볼린저 밴드 %B' },
{ value: 'VOLUME', label: '거래량' },
{ value: 'DISPARITY', label: '이격도' },
{ value: 'WILLIAMS_R', label: 'Williams %R' },
];
const CONDITION_OPTIONS = [
{ value: 'CROSS_UP', label: '상향 돌파 (Cross Up)', requiresTarget: true },
{ value: 'CROSS_DOWN', label: '하향 돌파 (Cross Down)', requiresTarget: true },
{ value: 'GOLDEN_CROSS', label: '골든 크로스', requiresTarget: false },
{ value: 'DEAD_CROSS', label: '데드 크로스', requiresTarget: false },
{ value: 'ABOVE', label: '이상 (≥)', requiresTarget: true },
{ value: 'BELOW', label: '이하 (≤)', requiresTarget: true },
{ value: 'ABOVE_STRICT', label: '초과 (>)', requiresTarget: true },
{ value: 'BELOW_STRICT', label: '미만 (<)', requiresTarget: true },
{ value: 'BETWEEN', label: '범위 내', requiresTarget: true, requiresSecondary: true },
{ value: 'OVERBOUGHT', label: '과매수', requiresTarget: false },
{ value: 'OVERSOLD', label: '과매도', requiresTarget: false },
{ value: 'INCREASING', label: '상승 중', requiresTarget: false },
{ value: 'DECREASING', label: '하락 중', requiresTarget: false },
// 구름 조건 - 일목균형도의 구름 신호 (기본값으로 고정)
{ value: 'CLOUD_TOP', label: '구름위 (Cloud Top)', requiresTarget: false, disabledSecondary: true },
];
// ============================================================
// ConditionEditor 컴포넌트
// ============================================================
const ConditionEditor: React.FC<ConditionEditorProps> = ({
open,
condition,
onSave,
onClose,
}) => {
const theme = useTheme();
// 편집 상태
const [indicatorType, setIndicatorType] = useState('CCI');
const [conditionType, setConditionType] = useState('CROSS_UP');
const [targetValue, setTargetValue] = useState<number>(-100);
const [secondaryValue, setSecondaryValue] = useState<number | undefined>(undefined);
const [period, setPeriod] = useState<number>(20);
const [comparePeriod, setComparePeriod] = useState<number | undefined>(undefined);
const [description, setDescription] = useState('');
// 초기 데이터 로드
useEffect(() => {
if (condition) {
setIndicatorType(condition.indicatorType || 'CCI');
setConditionType(condition.conditionType || 'CROSS_UP');
setTargetValue(condition.targetValue ?? -100);
setSecondaryValue(condition.secondaryValue);
setPeriod(condition.period ?? 20);
setComparePeriod(condition.comparePeriod);
setDescription('');
} else {
// 기본값
setIndicatorType('CCI');
setConditionType('CROSS_UP');
setTargetValue(-100);
setSecondaryValue(undefined);
setPeriod(20);
setComparePeriod(undefined);
setDescription('');
}
}, [condition]);
// 선택된 조건 타입 정보
const selectedCondition = CONDITION_OPTIONS.find(opt => opt.value === conditionType);
// 저장 핸들러
const handleSave = () => {
const newCondition: ConditionDSL = {
indicatorType,
conditionType,
targetValue: selectedCondition?.requiresTarget ? targetValue : undefined,
secondaryValue: selectedCondition?.requiresSecondary ? secondaryValue : undefined,
period,
comparePeriod,
};
onSave(newCondition);
onClose();
};
// 유효성 검증
const isValid = () => {
if (!indicatorType || !conditionType) return false;
if (selectedCondition?.requiresTarget && targetValue === undefined) return false;
if (selectedCondition?.requiresSecondary && secondaryValue === undefined) return false;
return true;
};
// 기본 기간값 추천
const getRecommendedPeriod = (indicator: string): number => {
const recommendations: Record<string, number> = {
CCI: 20,
MACD: 12,
RSI: 14,
STOCHASTIC: 14,
ADX: 14,
TRIX: 12,
DMI: 14,
OBV: 20,
BWI: 20,
VOLUME: 20,
DISPARITY: 20,
WILLIAMS_R: 14,
};
return recommendations[indicator] || 20;
};
// 지표 변경 시 기간 자동 설정
const handleIndicatorChange = (value: string) => {
setIndicatorType(value);
setPeriod(getRecommendedPeriod(value));
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: theme.palette.background.paper,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" component="span">
</Typography>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* 지표 선택 */}
<FormControl fullWidth>
<InputLabel> </InputLabel>
<Select
value={indicatorType}
onChange={(e) => handleIndicatorChange(e.target.value)}
label="지표 타입"
>
{INDICATOR_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* 조건 타입 선택 */}
<FormControl fullWidth>
<InputLabel> </InputLabel>
<Select
value={conditionType}
onChange={(e) => setConditionType(e.target.value)}
label="조건 타입"
>
{CONDITION_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* 기준값 입력 (조건에 따라 표시) */}
{selectedCondition?.requiresTarget && (
<TextField
label="기준값"
type="number"
value={targetValue}
onChange={(e) => setTargetValue(Number(e.target.value))}
fullWidth
disabled={selectedCondition?.disabledSecondary ? true : false}
helperText={selectedCondition?.disabledSecondary ? "구름 조건은 읽기 전용입니다." : "예: CCI -100, RSI 30"}
/>
)}
{/* 보조 기준값 입력 (BETWEEN 조건) */}
{selectedCondition?.requiresSecondary && !selectedCondition?.disabledSecondary && (
<TextField
label="보조 기준값"
type="number"
value={secondaryValue ?? ''}
onChange={(e) => setSecondaryValue(Number(e.target.value))}
fullWidth
helperText="범위의 상한값"
/>
)}
{selectedCondition?.disabledSecondary && (
<Alert severity="info" sx={{ m: 1 }}>
/ .
</Alert>
)}
<Divider />
{/* 기간 설정 */}
<TextField
label="기간"
type="number"
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
fullWidth
helperText={`권장: ${getRecommendedPeriod(indicatorType)}`}
/>
{/* 비교 기간 (골든/데드 크로스용) */}
{(conditionType === 'GOLDEN_CROSS' || conditionType === 'DEAD_CROSS') && (
<TextField
label="비교 기간 (장기)"
type="number"
value={comparePeriod ?? ''}
onChange={(e) => setComparePeriod(Number(e.target.value))}
fullWidth
helperText="예: MACD 골든 크로스의 경우 26"
/>
)}
{/* 미리보기 */}
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="body2" fontWeight="bold" gutterBottom>
</Typography>
<Typography variant="body2">
{indicatorType}
{period && `(${period})`}
{conditionType === 'CROSS_UP' && `${targetValue}을 상향 돌파`}
{conditionType === 'CROSS_DOWN' && `${targetValue}을 하향 돌파`}
{conditionType === 'GOLDEN_CROSS' && ' 골든 크로스'}
{conditionType === 'DEAD_CROSS' && ' 데드 크로스'}
{conditionType === 'ABOVE' && `${targetValue}`}
{conditionType === 'BELOW' && `${targetValue}`}
{conditionType === 'ABOVE_STRICT' && ` > ${targetValue}`}
{conditionType === 'BELOW_STRICT' && ` < ${targetValue}`}
{conditionType === 'BETWEEN' && ` ${targetValue} ~ ${secondaryValue} 사이`}
{conditionType === 'OVERBOUGHT' && ' 과매수'}
{conditionType === 'OVERSOLD' && ' 과매도'}
{conditionType === 'INCREASING' && ' 상승 중'}
{conditionType === 'DECREASING' && ' 하락 중'}
</Typography>
</Alert>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
startIcon={<CloseIcon />}
color="inherit"
>
</Button>
<Button
onClick={handleSave}
startIcon={<SaveIcon />}
variant="contained"
disabled={!isValid()}
>
</Button>
</DialogActions>
</Dialog>
);
};
export default ConditionEditor;
@@ -0,0 +1,254 @@
import React from 'react';
import {
Box,
Paper,
Typography,
Divider,
Chip,
useTheme,
Alert,
} from '@mui/material';
import {
Description as DescriptionIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
import { LogicNode, nodeToNaturalLanguage, countNodes, countConditions, getMaxDepth } from '../../services/strategyDSLApi';
// ============================================================
// Props 정의
// ============================================================
export interface NaturalLanguagePreviewProps {
rootNode: LogicNode | null;
strategyName?: string;
}
// ============================================================
// NaturalLanguagePreview 컴포넌트
// ============================================================
const NaturalLanguagePreview: React.FC<NaturalLanguagePreviewProps> = ({
rootNode,
strategyName,
}) => {
const theme = useTheme();
// 통계 계산
const stats = rootNode ? {
totalNodes: countNodes(rootNode),
conditionCount: countConditions(rootNode),
maxDepth: getMaxDepth(rootNode),
} : null;
// 복잡도 점수 계산 (1-10)
const calculateComplexity = (): number => {
if (!stats) return 0;
const score = (stats.totalNodes * 2) + (stats.maxDepth * 3) + stats.conditionCount;
return Math.min(10, Math.max(1, Math.floor(score / 5)));
};
const complexity = calculateComplexity();
// 복잡도에 따른 색상
const getComplexityColor = () => {
if (complexity <= 3) return theme.palette.success.main;
if (complexity <= 6) return theme.palette.warning.main;
return theme.palette.error.main;
};
// 복잡도에 따른 라벨
const getComplexityLabel = () => {
if (complexity <= 3) return '간단함';
if (complexity <= 6) return '보통';
return '복잡함';
};
// 자연어 변환
const naturalText = rootNode ? nodeToNaturalLanguage(rootNode) : '';
return (
<Paper
elevation={3}
sx={{
p: 2,
height: '100%',
overflow: 'auto',
backgroundColor: theme.palette.mode === 'dark' ?
theme.palette.grey[900] :
theme.palette.grey[50],
}}
>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon color="primary" />
</Typography>
{strategyName && (
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ mb: 2 }}>
{strategyName}
</Typography>
)}
<Divider sx={{ mb: 2 }} />
{/* 통계 정보 */}
{stats && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
📊
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={`노드: ${stats.totalNodes}`}
size="small"
color="default"
/>
<Chip
label={`조건: ${stats.conditionCount}`}
size="small"
color="primary"
/>
<Chip
label={`깊이: ${stats.maxDepth}`}
size="small"
color="secondary"
/>
<Chip
label={`복잡도: ${getComplexityLabel()} (${complexity}/10)`}
size="small"
sx={{
backgroundColor: getComplexityColor(),
color: theme.palette.getContrastText(getComplexityColor()),
}}
/>
</Box>
</Box>
)}
<Divider sx={{ mb: 2 }} />
{/* 자연어 설명 */}
{rootNode ? (
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
📝
</Typography>
<Paper
sx={{
p: 2,
backgroundColor: theme.palette.mode === 'dark' ?
theme.palette.grey[800] :
theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
fontFamily: 'monospace',
fontSize: '0.9rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
color: theme.palette.text.primary,
}}
>
{naturalText || '조건이 없습니다'}
</Paper>
{/* 유효성 알림 */}
{stats && stats.conditionCount > 0 && (
<Alert
severity="success"
icon={<CheckIcon />}
sx={{ mt: 2 }}
>
! .
</Alert>
)}
{stats && stats.conditionCount === 0 && (
<Alert
severity="warning"
icon={<ErrorIcon />}
sx={{ mt: 2 }}
>
1 .
</Alert>
)}
{/* 복잡도 경고 */}
{complexity > 7 && (
<Alert
severity="warning"
sx={{ mt: 2 }}
>
. .
</Alert>
)}
</Box>
) : (
<Box
sx={{
p: 4,
textAlign: 'center',
color: theme.palette.text.secondary,
}}
>
<DescriptionIcon sx={{ fontSize: 60, mb: 2, opacity: 0.3 }} />
<Typography variant="body2">
</Typography>
</Box>
)}
{/* 가이드 */}
<Box
sx={{
mt: 3,
p: 2,
backgroundColor: theme.palette.mode === 'dark' ?
theme.palette.grey[800] :
theme.palette.info.light + '20',
borderRadius: 1,
border: `1px solid ${theme.palette.info.main}`,
}}
>
<Typography variant="subtitle2" color="info.main" gutterBottom fontWeight="bold">
💡 ?
</Typography>
<Typography variant="caption" component="div" sx={{ mb: 0.5 }}>
</Typography>
<Typography variant="caption" component="div" sx={{ mb: 0.5 }}>
AND/OR/NOT
</Typography>
<Typography variant="caption" component="div">
</Typography>
</Box>
{/* 예시 */}
{!rootNode && (
<Box
sx={{
mt: 2,
p: 2,
backgroundColor: theme.palette.mode === 'dark' ?
theme.palette.grey[800] :
theme.palette.success.light + '20',
borderRadius: 1,
border: `1px solid ${theme.palette.success.main}`,
}}
>
<Typography variant="subtitle2" color="success.main" gutterBottom fontWeight="bold">
📖
</Typography>
<Typography variant="caption" component="div" sx={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
{`다음 조건을 모두 만족:
CCI(20) -100
RSI(14) 30 `}
</Typography>
</Box>
)}
</Paper>
);
};
export default NaturalLanguagePreview;
@@ -0,0 +1,641 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Chip,
useTheme,
Tabs,
Tab,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemButton,
ListItemText,
Divider,
Badge,
} from '@mui/material';
import {
AccountTree as TreeIcon,
ShowChart as ChartIcon,
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
ExpandMore as ExpandMoreIcon,
TouchApp as TouchAppIcon,
AutoAwesome as AutoAwesomeIcon,
} from '@mui/icons-material';
import { useDraggable } from '@dnd-kit/core';
import {
TEMPLATE_CATEGORIES,
StrategyTemplate,
loadCustomTemplates,
TemplateCategory,
getDynamicTemplateName,
getDynamicTemplateDescription,
MASettings
} from './strategyTemplates';
import { useIndicatorVisibility } from '../../contexts/IndicatorVisibilityContext';
// ============================================================
// 타입 정의
// ============================================================
interface PaletteItem {
id: string;
type: 'operator' | 'indicator';
label: string;
value: string;
icon?: React.ReactNode;
description?: string;
color?: string;
}
// ============================================================
// 드래그 가능한 팔레트 아이템 컴포넌트
// ============================================================
interface DraggableItemProps {
item: PaletteItem;
}
const DraggableItem: React.FC<DraggableItemProps> = ({ item }) => {
const theme = useTheme();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: item.id,
data: {
type: item.type,
value: item.value,
label: item.label,
},
});
return (
<Box
ref={setNodeRef}
{...listeners}
{...attributes}
sx={{ display: 'inline-block' }}
>
<Chip
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{item.icon}
{item.label}
</Box>
}
sx={{
m: 0.5,
cursor: isDragging ? 'grabbing' : 'grab',
opacity: isDragging ? 0.5 : 1,
backgroundColor: item.color || theme.palette.primary.main,
color: theme.palette.primary.contrastText,
'&:hover': {
backgroundColor: item.color ?
theme.palette.mode === 'dark' ?
theme.palette.grey[700] :
theme.palette.grey[300] :
theme.palette.primary.dark,
},
fontWeight: 'bold',
fontSize: '0.9rem',
}}
/>
</Box>
);
};
// ============================================================
// NodePalette 메인 컴포넌트
// ============================================================
interface NodePaletteProps {
disabled?: boolean;
onTemplateSelect?: (template: StrategyTemplate) => void;
}
const NodePalette: React.FC<NodePaletteProps> = ({ disabled = false, onTemplateSelect }) => {
const theme = useTheme();
const { settings: visibilitySettings } = useIndicatorVisibility();
const [currentTab, setCurrentTab] = useState<'manual' | 'auto'>('manual');
const [customTemplates, setCustomTemplates] = useState<StrategyTemplate[]>([]);
const [allCategories, setAllCategories] = useState<TemplateCategory[]>([]);
// MA 설정 정보 (동적 라벨 생성용)
const maSettings: MASettings = {
maLines: visibilitySettings.maLines || []
};
// 사용자 정의 템플릿 로드
useEffect(() => {
loadTemplates();
// 템플릿 저장 이벤트 리스너 등록
const handleTemplateSaved = () => {
console.log('📥 템플릿 저장 이벤트 감지 - 목록 새로고침');
loadTemplates();
};
window.addEventListener('templateSaved', handleTemplateSaved);
return () => {
window.removeEventListener('templateSaved', handleTemplateSaved);
};
}, []);
// 템플릿 로드 함수
const loadTemplates = () => {
const customs = loadCustomTemplates();
setCustomTemplates(customs);
// 기본 카테고리와 사용자 정의 템플릿 합치기
const categoriesMap = new Map<string, TemplateCategory>();
// 기본 카테고리 추가
TEMPLATE_CATEGORIES.forEach(cat => {
categoriesMap.set(cat.id, { ...cat });
});
// 사용자 정의 템플릿을 해당 카테고리에 추가
customs.forEach(template => {
const categoryId = template.category;
if (categoriesMap.has(categoryId)) {
const category = categoriesMap.get(categoryId)!;
category.templates = [...category.templates, template];
} else {
// 새로운 카테고리 생성 (사용자 정의)
categoriesMap.set(categoryId, {
id: categoryId,
label: template.categoryLabel,
icon: '⭐',
templates: [template],
});
}
});
setAllCategories(Array.from(categoriesMap.values()));
};
// 자동설정 탭이 열릴 때마다 템플릿 새로고침
useEffect(() => {
if (currentTab === 'auto') {
loadTemplates();
}
}, [currentTab]);
// 논리 연산자
const operators: PaletteItem[] = [
{
id: 'operator-and',
type: 'operator',
label: 'AND (그리고)',
value: 'AND',
icon: <TreeIcon fontSize="small" />,
description: '모든 자식 조건을 만족해야 함',
color: theme.palette.success.main,
},
{
id: 'operator-or',
type: 'operator',
label: 'OR (또는)',
value: 'OR',
icon: <TreeIcon fontSize="small" />,
description: '자식 조건 중 하나 이상 만족하면 됨',
color: theme.palette.info.main,
},
{
id: 'operator-not',
type: 'operator',
label: 'NOT (반대)',
value: 'NOT',
icon: <TreeIcon fontSize="small" />,
description: '자식 조건의 반대',
color: theme.palette.warning.main,
},
];
// 이동평균 지표들
const movingAverages: PaletteItem[] = [
{
id: 'indicator-ma',
type: 'indicator',
label: 'MA (이동평균)',
value: 'MA',
icon: <TrendingUpIcon fontSize="small" />,
description: 'Moving Average',
color: theme.palette.primary.main,
},
{
id: 'indicator-ema',
type: 'indicator',
label: 'EMA (지수이동평균)',
value: 'EMA',
icon: <TrendingUpIcon fontSize="small" />,
description: 'Exponential Moving Average',
color: theme.palette.primary.main,
},
{
id: 'indicator-bollinger',
type: 'indicator',
label: '볼린저밴드',
value: 'BOLLINGER',
icon: <ChartIcon fontSize="small" />,
description: 'Bollinger Bands',
color: theme.palette.primary.main,
},
{
id: 'indicator-ichimoku',
type: 'indicator',
label: '일목균형표',
value: 'ICHIMOKU',
icon: <ChartIcon fontSize="small" />,
description: 'Ichimoku Cloud',
color: theme.palette.primary.main,
},
];
// 보조지표들 (16가지)
const indicators: PaletteItem[] = [
{
id: 'indicator-new-psychological',
type: 'indicator',
label: '신심리도',
value: 'NEW_PSYCHOLOGICAL',
icon: <ChartIcon fontSize="small" />,
description: 'New Psychological Line',
color: theme.palette.secondary.main,
},
{
id: 'indicator-invest-psychological',
type: 'indicator',
label: '투자심리도',
value: 'INVEST_PSYCHOLOGICAL',
icon: <ChartIcon fontSize="small" />,
description: 'Investor Psychological Line',
color: theme.palette.secondary.main,
},
{
id: 'indicator-macd',
type: 'indicator',
label: 'MACD',
value: 'MACD',
icon: <ChartIcon fontSize="small" />,
description: 'Moving Average Convergence Divergence',
color: theme.palette.secondary.main,
},
{
id: 'indicator-cci',
type: 'indicator',
label: 'CCI',
value: 'CCI',
icon: <ChartIcon fontSize="small" />,
description: 'Commodity Channel Index',
color: theme.palette.secondary.main,
},
{
id: 'indicator-adx',
type: 'indicator',
label: 'ADX',
value: 'ADX',
icon: <TrendingUpIcon fontSize="small" />,
description: 'Average Directional Index',
color: theme.palette.secondary.main,
},
{
id: 'indicator-bwi',
type: 'indicator',
label: 'BWI',
value: 'BWI',
icon: <ChartIcon fontSize="small" />,
description: 'Bollinger Band Width Index',
color: theme.palette.secondary.main,
},
{
id: 'indicator-dmi',
type: 'indicator',
label: 'DMI',
value: 'DMI',
icon: <ChartIcon fontSize="small" />,
description: 'Directional Movement Index',
color: theme.palette.secondary.main,
},
{
id: 'indicator-obv',
type: 'indicator',
label: 'OBV',
value: 'OBV',
icon: <ChartIcon fontSize="small" />,
description: 'On Balance Volume',
color: theme.palette.secondary.main,
},
{
id: 'indicator-rsi',
type: 'indicator',
label: 'RSI',
value: 'RSI',
icon: <TrendingUpIcon fontSize="small" />,
description: 'Relative Strength Index',
color: theme.palette.secondary.main,
},
{
id: 'indicator-stochastic',
type: 'indicator',
label: 'Stochastic',
value: 'STOCHASTIC',
icon: <ChartIcon fontSize="small" />,
description: 'Stochastic Slow',
color: theme.palette.secondary.main,
},
{
id: 'indicator-williams-r',
type: 'indicator',
label: 'Williams %R',
value: 'WILLIAMS_R',
icon: <ChartIcon fontSize="small" />,
description: 'Williams Percent Range',
color: theme.palette.secondary.main,
},
{
id: 'indicator-trix',
type: 'indicator',
label: 'TRIX',
value: 'TRIX',
icon: <ChartIcon fontSize="small" />,
description: 'Triple Exponential Moving Average',
color: theme.palette.secondary.main,
},
{
id: 'indicator-volume-osc',
type: 'indicator',
label: 'VolumeOSC',
value: 'VOLUME_OSC',
icon: <ChartIcon fontSize="small" />,
description: 'Volume Oscillator',
color: theme.palette.secondary.main,
},
{
id: 'indicator-vr',
type: 'indicator',
label: 'VR',
value: 'VR',
icon: <ChartIcon fontSize="small" />,
description: 'Volume Ratio',
color: theme.palette.secondary.main,
},
{
id: 'indicator-disparity',
type: 'indicator',
label: '이격도',
value: 'DISPARITY',
icon: <ChartIcon fontSize="small" />,
description: 'Disparity Index',
color: theme.palette.secondary.main,
},
{
id: 'indicator-volume',
type: 'indicator',
label: '거래량',
value: 'VOLUME',
icon: <ChartIcon fontSize="small" />,
description: 'Trading Volume',
color: theme.palette.secondary.main,
},
];
return (
<Box
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
opacity: disabled ? 0.5 : 1,
pointerEvents: disabled ? 'none' : 'auto',
}}
>
{/* 헤더 */}
<Typography sx={{
p: 1.5,
borderBottom: `1px solid ${theme.palette.divider}`,
fontSize: '14px',
fontWeight: 600,
bgcolor: theme.palette.mode === 'dark' ? theme.palette.grey[900] : theme.palette.grey[50],
}}>
🎨
</Typography>
{/* 탭 */}
<Tabs
value={currentTab}
onChange={(_, newValue) => setCurrentTab(newValue)}
sx={{
borderBottom: `1px solid ${theme.palette.divider}`,
minHeight: '40px',
'& .MuiTab-root': {
minHeight: '40px',
fontSize: '13px',
fontWeight: 600,
}
}}
>
<Tab
value="manual"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TouchAppIcon fontSize="small" />
</Box>
}
/>
<Tab
value="auto"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<AutoAwesomeIcon fontSize="small" />
</Box>
}
/>
</Tabs>
{/* 탭 내용 */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
{/* 수동설정 탭 */}
{currentTab === 'manual' && (
<Box sx={{ p: 1.5 }}>
{/* 논리 연산자 섹션 */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" fontWeight="bold" fontSize="13px" sx={{ mb: 1 }}>
🔗
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{operators.map((op) => (
<DraggableItem key={op.id} item={op} />
))}
</Box>
</Box>
{/* 이동평균 섹션 */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" fontWeight="bold" fontSize="13px" sx={{ mb: 1 }}>
📈 &
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{movingAverages.map((indicator) => (
<DraggableItem key={indicator.id} item={indicator} />
))}
</Box>
</Box>
{/* 보조 지표 섹션 */}
<Box>
<Typography variant="subtitle2" fontWeight="bold" fontSize="13px" sx={{ mb: 1 }}>
📊 (16)
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{indicators.map((indicator) => (
<DraggableItem key={indicator.id} item={indicator} />
))}
</Box>
</Box>
</Box>
)}
{/* 자동설정 탭 */}
{currentTab === 'auto' && (
<Box>
<Box sx={{ p: 1.5, bgcolor: theme.palette.info.main, color: theme.palette.info.contrastText }}>
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<AutoAwesomeIcon fontSize="small" />
릿
</Typography>
</Box>
{allCategories.map((category) => {
const buyTemplates = category.templates.filter(t => t.signal === 'buy');
const sellTemplates = category.templates.filter(t => t.signal === 'sell');
return (
<Accordion
key={category.id}
sx={{
'&:before': { display: 'none' },
boxShadow: 'none',
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600, fontSize: '13px' }}>
{category.icon} {category.label}
<Chip
label={`${category.templates.length}`}
size="small"
sx={{ ml: 1, height: '18px', fontSize: '11px' }}
/>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
{/* 매수 전략 */}
{buyTemplates.length > 0 && (
<Box>
<Box sx={{
px: 2,
py: 0.5,
bgcolor: theme.palette.success.main + '20',
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}>
<TrendingUpIcon fontSize="small" sx={{ color: theme.palette.success.main }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: theme.palette.success.main }}>
({buyTemplates.length})
</Typography>
</Box>
<List dense disablePadding>
{buyTemplates.map((template) => (
<ListItem key={template.id} disablePadding>
<ListItemButton
onClick={() => onTemplateSelect?.(template)}
sx={{
py: 1,
px: 2,
'&:hover': {
bgcolor: theme.palette.success.main + '10',
},
}}
>
<ListItemText
primary={getDynamicTemplateName(template, maSettings)}
secondary={getDynamicTemplateDescription(template, maSettings)}
primaryTypographyProps={{
fontSize: '12px',
fontWeight: 600,
}}
secondaryTypographyProps={{
fontSize: '11px',
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
)}
{/* 매도 전략 */}
{sellTemplates.length > 0 && (
<Box>
<Box sx={{
px: 2,
py: 0.5,
bgcolor: theme.palette.error.main + '20',
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}>
<TrendingDownIcon fontSize="small" sx={{ color: theme.palette.error.main }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: theme.palette.error.main }}>
({sellTemplates.length})
</Typography>
</Box>
<List dense disablePadding>
{sellTemplates.map((template) => (
<ListItem key={template.id} disablePadding>
<ListItemButton
onClick={() => onTemplateSelect?.(template)}
sx={{
py: 1,
px: 2,
'&:hover': {
bgcolor: theme.palette.error.main + '10',
},
}}
>
<ListItemText
primary={getDynamicTemplateName(template, maSettings)}
secondary={getDynamicTemplateDescription(template, maSettings)}
primaryTypographyProps={{
fontSize: '12px',
fontWeight: 600,
}}
secondaryTypographyProps={{
fontSize: '11px',
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
)}
</AccordionDetails>
</Accordion>
);
})}
</Box>
)}
</Box>
</Box>
);
};
export default NodePalette;
@@ -0,0 +1,669 @@
import React, { useState, useEffect } from 'react';
import { Box, Alert, useTheme } from '@mui/material';
import { useDndMonitor, useDroppable, DragEndEvent } from '@dnd-kit/core';
import TreeNode from './TreeNode';
import ConditionEditor from './ConditionEditor';
import {
LogicNode,
LogicNodeType,
ConditionDSL,
createEmptyLogicNode,
generateNodeId,
} from '../../services/strategyDSLApi';
import { useIndicatorSettings } from '../../contexts/IndicatorSettingsContext';
import { useIndicatorVisibility } from '../../contexts/IndicatorVisibilityContext';
// ============================================================
// Props 정의
// ============================================================
export interface StrategyTreeBuilderProps {
rootNode: LogicNode | null;
onChange: (newRoot: LogicNode | null) => void;
disabled?: boolean;
onDropSuccess?: () => void;
signalType?: 'buy' | 'sell'; // 매수/매도 탭 구분
}
// ============================================================
// StrategyTreeBuilder 컴포넌트
// ============================================================
const StrategyTreeBuilder: React.FC<StrategyTreeBuilderProps> = ({
rootNode,
onChange,
disabled = false,
onDropSuccess,
signalType = 'buy', // 기본값: 매수
}) => {
const theme = useTheme();
const { indicatorSettings } = useIndicatorSettings();
const { settings: visibilitySettings } = useIndicatorVisibility();
// 조건 편집 다이얼로그 상태
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [editingCondition, setEditingCondition] = useState<ConditionDSL | null>(null);
// 빈 공간에 드롭 가능하도록 설정
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: 'empty-canvas',
disabled: disabled,
});
// ============================================================
// 노드 조작 함수들
// ============================================================
/**
* ()
*/
const findNode = (node: LogicNode, targetId: string): LogicNode | null => {
if (node.id === targetId) return node;
if (node.children) {
for (const child of node.children) {
const found = findNode(child, targetId);
if (found) return found;
}
}
return null;
};
/**
* ()
*/
const updateNode = (node: LogicNode, targetId: string, updater: (n: LogicNode) => LogicNode): LogicNode => {
if (node.id === targetId) {
return updater(node);
}
if (node.children) {
return {
...node,
children: node.children.map(child => updateNode(child, targetId, updater)),
};
}
return node;
};
/**
* ()
*/
const deleteNode = (node: LogicNode, targetId: string): LogicNode | null => {
if (node.id === targetId) {
return null;
}
if (node.children) {
const newChildren = node.children
.map(child => deleteNode(child, targetId))
.filter((child): child is LogicNode => child !== null);
return {
...node,
children: newChildren,
};
}
return node;
};
/**
*
*/
const addChild = (node: LogicNode, parentId: string, newChild: LogicNode): LogicNode => {
if (node.id === parentId) {
// NOT 노드는 자식이 1개만 허용
if (node.type === 'NOT' && node.children && node.children.length >= 1) {
alert('NOT 노드는 자식을 1개만 가질 수 있습니다.');
return node;
}
return {
...node,
children: [...(node.children || []), newChild],
};
}
if (node.children) {
return {
...node,
children: node.children.map(child => addChild(child, parentId, newChild)),
};
}
return node;
};
// ============================================================
// 이벤트 핸들러
// ============================================================
/**
*
*/
useDndMonitor({
onDragEnd: (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const draggedData = active.data.current;
const droppedData = over.data.current;
if (!draggedData) return;
// 팔레트에서 드래그한 경우
if (draggedData.type === 'operator' || draggedData.type === 'indicator') {
handlePaletteDrop(draggedData, droppedData);
}
},
});
/**
*
*/
const getDefaultPeriod = (indicatorType: string): number => {
switch (indicatorType) {
case 'RSI': return indicatorSettings.rsiPeriod;
case 'MACD': return indicatorSettings.macdSignalPeriod;
case 'CCI': return indicatorSettings.cciPeriod;
case 'STOCHASTIC': return indicatorSettings.stochasticKPeriod;
case 'ADX': return indicatorSettings.adxPeriod;
case 'OBV': return indicatorSettings.obvPeriod;
case 'BWI': return indicatorSettings.bwiPeriod;
case 'DMI': return indicatorSettings.dmiPeriod;
case 'WILLIAMS_R': return indicatorSettings.williamsRPeriod;
case 'TRIX': return indicatorSettings.trixPeriod;
case 'NEW_PSYCHOLOGICAL': return indicatorSettings.newPsychologicalPeriod;
case 'INVEST_PSYCHOLOGICAL': return indicatorSettings.investPsychologicalPeriod;
case 'VR': return indicatorSettings.vrPeriod;
case 'VOLUME_OSC': return indicatorSettings.volumeOscShortPeriod;
case 'DISPARITY': return indicatorSettings.disparityShortPeriod;
case 'MA':
case 'EMA':
// MA/EMA는 단기선 기간 (MA1 기본값)
const ma1 = visibilitySettings.maLines.find(line => line.id === 'MA1');
return ma1?.period || 5;
case 'BOLLINGER': return 20;
case 'ICHIMOKU': return 9;
default: return 20;
}
};
/**
*
*/
const getDefaultTargetValue = (indicatorType: string): number => {
switch (indicatorType) {
case 'RSI': return indicatorSettings.rsiMiddle;
case 'CCI': return indicatorSettings.cciMiddle;
case 'STOCHASTIC': return indicatorSettings.stochasticNeutral;
case 'WILLIAMS_R': return indicatorSettings.williamsRMiddle;
case 'BWI': return indicatorSettings.bwiMiddle;
case 'ADX': return indicatorSettings.adxStrongThreshold;
case 'NEW_PSYCHOLOGICAL': return indicatorSettings.newPsychologicalMiddle;
case 'INVEST_PSYCHOLOGICAL': return indicatorSettings.investPsychologicalMiddle;
case 'TRIX': return indicatorSettings.trixMiddle;
case 'VR': return indicatorSettings.vrBaseLine;
case 'DMI': return indicatorSettings.dmiOverbought;
case 'DISPARITY': return 100;
case 'MACD':
case 'VOLUME_OSC': return 0;
case 'MA':
case 'EMA':
// MA/EMA는 장기선 기간 (MA3 기본값)
const ma3 = visibilitySettings.maLines.find(line => line.id === 'MA3');
return ma3?.period || 20;
default: return 0;
}
};
/**
*
*/
const handlePaletteDrop = (draggedData: any, droppedData: any) => {
console.log('🎯 드롭 이벤트:', { draggedData, droppedData });
let newNode: LogicNode;
if (draggedData.type === 'operator') {
// 논리 연산자 노드 생성
newNode = createEmptyLogicNode(draggedData.value as LogicNodeType);
} else if (draggedData.type === 'indicator') {
// 지표 조건 노드 생성 (설정값 기반 기본값)
const indicatorType = draggedData.value;
// 일목균형표는 특별한 기본값 설정
if (indicatorType === 'ICHIMOKU') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'ICHIMOKU',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'CONVERSION_LINE',
rightField: 'BASE_LINE',
},
};
}
// 볼린저밴드는 특별한 기본값 설정
else if (indicatorType === 'BOLLINGER') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'BOLLINGER',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'CLOSE_PRICE',
rightField: 'UPPER_BAND',
},
};
}
// MACD는 특별한 기본값 설정
else if (indicatorType === 'MACD') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'MACD',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'MACD_LINE',
rightField: 'SIGNAL_LINE',
},
};
}
// RSI
else if (indicatorType === 'RSI') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'RSI',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'RSI_VALUE',
rightField: 'OVERBOUGHT_70',
},
};
}
// STOCHASTIC
else if (indicatorType === 'STOCHASTIC') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'STOCHASTIC',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'STOCH_K',
rightField: 'STOCH_D',
},
};
}
// CCI
else if (indicatorType === 'CCI') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'CCI',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'CCI_VALUE',
rightField: 'OVERBOUGHT_100',
},
};
}
// ADX
else if (indicatorType === 'ADX') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'ADX',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'ADX_VALUE',
rightField: 'ADX_25',
},
};
}
// TRIX
else if (indicatorType === 'TRIX') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'TRIX',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'TRIX_VALUE',
rightField: 'TRIX_SIGNAL',
},
};
}
// DMI
else if (indicatorType === 'DMI') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'DMI',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'PDI',
rightField: 'MDI',
},
};
}
// OBV
else if (indicatorType === 'OBV') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'OBV',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'OBV_LINE',
rightField: 'OBV_SIGNAL',
},
};
}
// VOLUME
else if (indicatorType === 'VOLUME') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'VOLUME',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'VOLUME_VALUE',
rightField: 'VOLUME_MA',
},
};
}
// MA (CommonOperator 기반)
else if (indicatorType === 'MA') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'MA',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'MA5',
rightField: 'MA20',
},
};
}
// DISPARITY
else if (indicatorType === 'DISPARITY') {
const ultraShortPeriod = indicatorSettings.disparityUltraShortPeriod || 5;
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'DISPARITY',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: `DISPARITY${ultraShortPeriod}`,
rightField: 'NEUTRAL_100',
},
};
}
// NEW_PSYCHOLOGICAL
else if (indicatorType === 'NEW_PSYCHOLOGICAL') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'NEW_PSYCHOLOGICAL',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'NEW_PSY_VALUE',
rightField: 'OVERBOUGHT_75',
},
};
}
// INVEST_PSYCHOLOGICAL
else if (indicatorType === 'INVEST_PSYCHOLOGICAL') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'INVEST_PSYCHOLOGICAL',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'INVEST_PSY_VALUE',
rightField: 'OVERBOUGHT_75',
},
};
}
// WILLIAMS_R
else if (indicatorType === 'WILLIAMS_R') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'WILLIAMS_R',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'WILLIAMS_R_VALUE',
rightField: 'OVERBOUGHT_NEG20',
},
};
}
// BWI
else if (indicatorType === 'BWI') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'BWI',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'BWI_VALUE',
rightField: 'OVERBOUGHT_80',
},
};
}
// VR
else if (indicatorType === 'VR') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'VR',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'VR_VALUE',
rightField: 'OVERBOUGHT_200',
},
};
}
// VOLUME_OSC
else if (indicatorType === 'VOLUME_OSC') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType: 'VOLUME_OSC',
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN', // 매수: 상향돌파, 매도: 하향돌파
leftField: 'VOLUME_OSC_VALUE',
rightField: 'ZERO_LINE',
},
};
}
// EMA는 기본 조건을 GOLDEN_CROSS로 설정
else if (indicatorType === 'EMA') {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType,
conditionType: 'GOLDEN_CROSS',
targetValue: getDefaultTargetValue(indicatorType),
period: getDefaultPeriod(indicatorType),
},
};
}
// 기타 지표
else {
newNode = {
id: generateNodeId(),
type: 'CONDITION',
condition: {
indicatorType,
conditionType: 'CROSS_UP',
targetValue: getDefaultTargetValue(indicatorType),
period: getDefaultPeriod(indicatorType),
},
};
}
} else {
console.warn('⚠️ 알 수 없는 타입:', draggedData.type);
return;
}
console.log('✅ 새 노드 생성:', newNode);
if (!rootNode) {
// 루트 노드가 없으면 새 노드를 루트로 설정
console.log('📌 루트 노드로 설정');
onChange(newNode);
onDropSuccess?.();
} else if (droppedData && droppedData.node) {
// 기존 노드에 자식으로 추가
const parentNode = droppedData.node;
if (parentNode.type === 'CONDITION') {
alert('조건 노드는 자식을 가질 수 없습니다.');
return;
}
console.log(' 자식 노드로 추가:', parentNode.id);
const updated = addChild(rootNode, parentNode.id, newNode);
onChange(updated);
onDropSuccess?.();
} else if (!droppedData || !droppedData.node) {
// 빈 공간에 드롭한 경우
if (draggedData.type === 'operator') {
// 논리 연산자를 빈 공간에 드롭: 기존 루트를 자식으로 포함
console.log('🔄 기존 루트를 자식으로 포함하는 새 루트 생성');
newNode.children = [rootNode];
onChange(newNode);
onDropSuccess?.();
} else {
// 지표를 빈 공간에 드롭: 형제 노드를 추가하려면 논리 연산자 필요
alert('조건을 추가하려면 논리 연산자 노드(AND/OR)에 드롭하세요.');
console.log('❌ 유효하지 않은 드롭 - 조건은 논리 연산자 노드에만 추가 가능');
}
}
};
/**
*
*/
const handleEditNode = (nodeId: string) => {
if (!rootNode) return;
const node = findNode(rootNode, nodeId);
if (node && node.type === 'CONDITION' && node.condition) {
setEditingNodeId(nodeId);
setEditingCondition(node.condition);
}
};
/**
*
*/
const handleSaveCondition = (newCondition: ConditionDSL) => {
if (!rootNode || !editingNodeId) return;
const updated = updateNode(rootNode, editingNodeId, (node) => ({
...node,
condition: newCondition,
}));
onChange(updated);
setEditingNodeId(null);
setEditingCondition(null);
};
/**
*
*/
const handleDeleteNode = (nodeId: string) => {
if (!rootNode) return;
if (rootNode.id === nodeId) {
// 루트 노드 삭제
onChange(null);
} else {
// 자식 노드 삭제
const updated = deleteNode(rootNode, nodeId);
onChange(updated);
}
};
/**
* (+ )
*/
const handleAddChild = (parentId: string) => {
if (!rootNode) return;
// 기본 AND 노드 추가
const newChild = createEmptyLogicNode('CONDITION');
const updated = addChild(rootNode, parentId, newChild);
onChange(updated);
};
// ============================================================
// 렌더링
// ============================================================
return (
<Box
ref={setDropRef}
sx={{
height: '100%',
overflow: 'auto',
p: 1,
backgroundColor: isOver ?
theme.palette.action.hover :
'transparent',
border: isOver ? `2px dashed ${theme.palette.primary.main}` : 'none',
transition: 'all 0.2s ease',
}}
>
{rootNode ? (
<TreeNode
node={rootNode}
onChange={(updatedNode) => onChange(updatedNode)}
onEdit={handleEditNode}
onDelete={handleDeleteNode}
onAddChild={handleAddChild}
disabled={disabled}
signalType={signalType}
/>
) : (
<Alert
severity="info"
sx={{
mt: 2,
backgroundColor: isOver ? theme.palette.primary.light + '30' : undefined,
border: isOver ? `2px dashed ${theme.palette.primary.main}` : undefined,
}}
>
{isOver ?
'여기에 드롭하세요!' :
'왼쪽 팔레트에서 논리 연산자나 지표를 드래그하여 전략을 시작하세요.'
}
</Alert>
)}
{/* 조건 편집 다이얼로그 */}
<ConditionEditor
open={editingNodeId !== null}
condition={editingCondition}
onSave={handleSaveCondition}
onClose={() => {
setEditingNodeId(null);
setEditingCondition(null);
}}
/>
</Box>
);
};
export default StrategyTreeBuilder;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff