import React, { useState, useEffect, useRef } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography, Box, Slider, Divider, useTheme, useMediaQuery, IconButton, Grid, Select, MenuItem, FormControl, InputLabel, Switch, Paper, PaperProps, Autocomplete, } from '@mui/material'; import Draggable from 'react-draggable'; import { Close as CloseIcon, Settings as SettingsIcon, Add as AddIcon, Delete as DeleteIcon, HelpOutline as HelpOutlineIcon, } from '@mui/icons-material'; import { useChartColors, ChartColorSettings } from '../contexts/ChartColorContext'; import { useIndicatorSettings, IndicatorSettings } from '../contexts/IndicatorSettingsContext'; // 보조지표 타입 정의 export type IndicatorType = | 'volume' | 'macd' | 'stochastic' | 'rsi' | 'momentum' | 'adx' | 'dmi' | 'cci' | 'trix' | 'disparity' | 'newPsychological' | 'investPsychological' | 'williamsR' | 'bwi' | 'obv' | 'volumeOsc' | 'vr' | 'vr2' | 'dmi' | 'ma' | 'ichimoku'; interface IndicatorSettingsPopupProps { open: boolean; onClose: () => void; indicatorType: IndicatorType | null; } // 보조지표별 정보 const indicatorInfo: Record = { volume: { name: '거래량', description: '매수/매도 거래량을 막대 그래프로 표시' }, macd: { name: 'MACD', description: 'Moving Average Convergence Divergence - 이동평균 수렴·발산 지표' }, stochastic: { name: 'Stochastic Slow', description: '현재 가격이 일정 기간의 가격 범위에서 어디에 위치하는지 표시' }, rsi: { name: 'RSI', description: 'Relative Strength Index - 상대강도지수' }, momentum: { name: 'Momentum', description: 'Momentum - 현재 가격과 n기간 전 가격의 차이를 나타내는 모멘텀 지표' }, adx: { name: 'ADX', description: 'Average Directional Index - 추세 강도 지표' }, dmi: { name: 'DMI', description: 'Directional Movement Index - 방향성 지표 (ADX + +DI + -DI)' }, cci: { name: 'CCI', description: 'Commodity Channel Index - 상품 채널 지수' }, williamsR: { name: 'Williams %R', description: '과매수/과매도 상태를 나타내는 모멘텀 지표' }, obv: { name: 'OBV', description: 'On Balance Volume - 거래량 누적 지표' }, trix: { name: 'TRIX', description: 'Triple Exponential Average - 삼중지수이동평균' }, newPsychological: { name: '신심리도', description: '일정 기간 동안 상승일 비율' }, investPsychological: { name: '투자심리도', description: '투자자들의 심리 상태를 나타내는 지표' }, disparity: { name: '이격도', description: '현재 가격과 이동평균선 간의 괴리 정도' }, bwi: { name: 'BWI', description: 'Bollinger Band Width Index - 볼린저밴드 %B' }, volumeOsc: { name: 'VolumeOSC', description: 'Volume Oscillator - 거래량 오실레이터' }, vr: { name: 'VR', description: 'Volume Ratio - 거래량 비율' }, vr2: { name: 'VR2', description: 'Volume Ratio (추가) - 거래량 비율 (별도 설정)' }, ma: { name: '이동평균선', description: 'Moving Average - 일정 기간 가격의 평균을 선으로 표시' }, ichimoku: { name: '일목균형표', description: 'Ichimoku Cloud - 추세, 지지/저항, 모멘텀을 한눈에 표시' }, }; // 보조지표별 상세 설명 const getIndicatorDetailedHelp = (type: IndicatorType): React.ReactNode => { switch (type) { case 'macd': return ( 1️⃣ MACD란? MACD는 두 개의 이동평균선의 차이를 이용해 ✔ 추세 방향 ✔ 매수·매도 시점 ✔ 힘의 강도(모멘텀) 를 판단하는 지표입니다. "주가의 흐름이 강해지고 있는지, 약해지고 있는지"를 보여줍니다. 2️⃣ MACD의 구성 요소 (3가지) ① MACD 선 • 12일 지수이동평균(EMA) – 26일 EMA
• 단기 추세와 중기 추세의 차이
② 시그널 선 • MACD 선의 9일 EMA
• 매매 신호를 보조하는 기준선
③ 히스토그램 • MACD 선 – 시그널 선
• 두 선의 간격을 막대그래프로 표시
• 힘이 커지는지 / 약해지는지 시각적으로 보여줌
3️⃣ MACD 해석 방법 (핵심) 🔹 1. 골든크로스 / 데드크로스 가장 많이 쓰이는 신호입니다. ✔ 매수 신호 • MACD 선이 시그널 선을 아래에서 위로 돌파
• 상승 모멘텀 시작 가능성
✔ 매도 신호 • MACD 선이 시그널 선을 위에서 아래로 이탈
• 하락 모멘텀 시작 가능성
🔹 2. 0선 기준 해석 MACD가 0선 위/아래에 있는지도 중요합니다. • 0선 위 → 상승 추세 우위 • 0선 아래 → 하락 추세 우위 📌 0선 위에서 골든크로스 = 신뢰도 ↑
📌 0선 아래 데드크로스 = 하락 지속 가능성 ↑
🔹 3. 히스토그램 변화 히스토그램은 추세의 힘을 보여줍니다. • 막대가 점점 커짐 → 추세 강화 • 막대가 줄어듦 → 추세 약화 • 방향 전환 → 추세 전환 가능성 🔹 4. 다이버전스 (중요) 주가와 MACD의 방향이 다를 때 나타납니다. ✔ 강세 다이버전스 • 주가는 하락하는데 MACD는 저점 상승
• → 반등 가능성
✔ 약세 다이버전스 • 주가는 상승하는데 MACD는 고점 하락
• → 조정 가능성
4️⃣ 실전 활용 팁 ✅ MACD는 추세장에서 강력 횡보장에서는 잦은 헛신호 발생 ✅ 다른 지표와 함께 사용 RSI, 이동평균선, 거래량과 병행 추천 ✅ 시간봉에 따라 성격이 다름 • 일봉 → 중기 추세
• 분봉 → 단기 매매 (노이즈 많음)
5️⃣ MACD의 한계 (중요) ❌ 후행성 지표 이동평균 기반 → 신호가 늦을 수 있음 ❌ 단독 사용은 위험 반드시 차트 패턴·지지저항·거래량과 함께 확인
); case 'rsi': return ( 1️⃣ RSI란? RSI는 주가의 상승·하락 강도를 수치화한 모멘텀 지표입니다. • 값의 범위: 0 ~ 100 • "최근 일정 기간 동안 매수세와 매도세 중 누가 더 강한가"를 보여줌 📌 과매수·과매도 판단에 가장 많이 쓰이는 지표 중 하나 2️⃣ RSI 기본 설정 • 기간: 14 (표준) • 거의 모든 HTS/MTS에서 기본값 3️⃣ 핵심 해석 방법 🔹 ① 과매수 / 과매도 구간 가장 기본적인 사용법입니다. • RSI ≥ 70 → 과매수 (조정 가능성) • RSI ≤ 30 → 과매도 (반등 가능성) ⚠️ 수치 도달 즉시 매매 ❌ ✔ 꺾임(되돌림)을 확인 후 대응 🔹 ② 50선 기준 추세 판단 • 50 이상 → 상승 추세 우위 • 50 이하 → 하락 추세 우위 📌 추세 확인용으로 매우 유용 🔹 ③ 다이버전스 (중요) ✔ 강세 다이버전스 • 주가: 저점 낮아짐
• RSI: 저점 높아짐
→ 반등 가능성 ↑
✔ 약세 다이버전스 • 주가: 고점 높아짐
• RSI: 고점 낮아짐
→ 하락 가능성 ↑
🔹 ④ RSI 추세선 활용 • RSI에도 지지·저항선을 그릴 수 있음 • RSI 추세선 이탈 = 주가 방향 전환 신호
4️⃣ 실전 활용 예시 📌 매수 타점 예 • RSI 30 이하 진입
• 바닥 다지고 30 상향 돌파
• 거래량 증가 확인 → 분할 매수
📌 매도 타점 예 • RSI 70 이상 진입
• 고점 형성 후 70 하향 이탈
• 분할 매도
5️⃣ 매매 스타일별 RSI 설정 스타일 RSI 설정 단타 7 ~ 9 스윙 14 중·장기 21 📌 기간 ↓ → 신호 빠름 (노이즈 ↑) 📌 기간 ↑ → 안정적 (신호 느림) 6️⃣ RSI의 장점과 한계 ✔ 장점 • 직관적
• 과열 구간 파악 쉬움
• 대부분의 시장에 적용 가능
❌ 한계 • 강한 추세장에서는 신호 왜곡
• 단독 사용 시 헛신호
👉 MACD·이동평균·거래량과 병행 필수
한 줄 요약 RSI는 매수·매도 세력의 힘을 보여주는 지표로, 30·70 기준과 다이버전스가 핵심 포인트입니다.
); case 'stochastic': return ( 1️⃣ Stochastic Slow란? 스토캐스틱은 "현재 가격이 최근 일정 기간의 고가–저가 범위 중 어디에 위치하는가" 를 보여주는 지표입니다. • 가격이 고가 근처 → 매수 과열 가능 • 가격이 저가 근처 → 매도 과열 가능 📌 MACD가 추세 지표라면, Stochastic은 속도·과열 지표입니다. 2️⃣ Stochastic Slow의 구성요소 🔹 %K (느린 K) • 가격의 위치를 나타내는 주선
• Fast Stochastic의 %K를 이동평균 처리한 값
🔹 %D • %K의 이동평균 (신호선 역할) 👉 Slow Stochastic은 %K와 %D 두 개 선으로 해석합니다.
3️⃣ 계산 방식 (개념적으로) 기본 설정 기준: (14, 3, 3) 이 가장 보편적 의미: • 14 → 기준 기간
• 3 → %K를 느리게 만드는 이동평균
• 3 → %D 이동평균
※ 실제 계산은 자동이므로 원리만 이해하면 충분합니다.
4️⃣ 핵심 해석 방법 🔸 1. 과매수 / 과매도 구간 가장 기본적인 사용법입니다. • 80 이상 → 과매수 구간 • 20 이하 → 과매도 구간 ⚠️ 바로 매도·매수하는 게 아니라 전환 신호를 기다리는 것이 중요 🔸 2. 골든크로스 / 데드크로스 ✔ 매수 신호 • 20 이하 과매도 구간에서
• %K가 %D를 아래에서 위로 돌파
✔ 매도 신호 • 80 이상 과매수 구간에서
• %K가 %D를 위에서 아래로 이탈
📌 구간 밖 크로스는 신뢰도 낮음
🔸 3. 다이버전스 (중요) ✔ 강세 다이버전스 • 주가는 저점 갱신
• Stochastic은 저점 상승
→ 반등 가능성
✔ 약세 다이버전스 • 주가는 고점 갱신
• Stochastic은 고점 하락
→ 조정 가능성
5️⃣ Fast vs Slow 차이 구분 Fast Slow 반응 속도 매우 빠름 느림 노이즈 많음 적음 실전 활용 제한적 가장 많이 사용 👉 실전에서는 Slow Stochastic을 기본으로 사용 6️⃣ 실전 활용 팁 ✅ 횡보장·조정장에 강함 ❌ 강한 상승장에서는 과매수 상태가 오래 지속될 수 있음 ✅ MACD, 이동평균과 병행 추천 • MACD로 추세 확인
• Stochastic으로 진입 타이밍
✅ 분봉에서는 설정값 늘리면 안정적
7️⃣ 대표적인 설정값 • 기본: (14, 3, 3) • 단타: (9, 3, 3) • 보수적: (21, 5, 5) 한 줄 요약 Stochastic Slow는 가격의 과열 정도를 보여주는 지표로, 20·80 구간에서의 골든/데드크로스가 핵심 신호입니다.
); case 'volume': return ( 1️⃣ 거래량이란? 거래량은 일정 기간 동안 거래된 주식의 수량을 나타내며, 주가 변동의 신뢰도를 확인하는 핵심 지표입니다. "거래량이 많다 = 많은 사람들이 참여했다 = 신뢰도 높은 움직임" 2️⃣ 거래량 해석 방법 🔹 가격과 거래량의 관계 ✔ 상승 + 거래량 증가 → 강한 상승 (신뢰도 높음) ✔ 상승 + 거래량 감소 → 약한 상승 (조정 가능성) ✔ 하락 + 거래량 증가 → 강한 하락 (추가 하락 가능성) ✔ 하락 + 거래량 감소 → 약한 하락 (바닥 근접) 🔹 거래량 패턴 • 거래량 급증 → 추세 전환 신호 • 거래량 감소 → 추세 약화 또는 횡보 가능성 • 거래량 평균 이상 → 신뢰할 수 있는 움직임 3️⃣ 실전 활용 팁 ✅ 모든 매매 판단의 기본 ✅ 돌파 구간에서 거래량 확인 필수 ✅ 이동평균선과 함께 보면 효과적 ); case 'cci': return ( 1️⃣ CCI란? CCI는 "현재 가격이 최근 평균 가격보다 비싸게 거래되는지, 싸게 거래되는지" 를 수치로 나타낸 지표입니다. • + 방향 → 평균보다 강함 • – 방향 → 평균보다 약함 📌 RSI·Stochastic과 달리 기준이 고정(0선)이고, 과매수·과매도 범위가 유연한 것이 특징 2️⃣ CCI 기본 설정 • 기간: 14 또는 20 (가장 많이 사용) • 기준선: 0선 일반적인 과열 기준: • +100 이상 → 과매수 • –100 이하 → 과매도 3️⃣ CCI 계산 개념 (이해만 하면 충분) CCI는 아래 개념으로 계산됩니다. • TP(대표가격) = (고가 + 저가 + 종가) ÷ 3 • TP가 평균(TP의 이동평균)에서 얼마나 떨어져 있는지를 수치화 👉 복잡해 보이지만 자동 계산되므로 해석이 핵심입니다. 4️⃣ 핵심 해석 방법 🔹 ① 0선 돌파 (추세 판단) • CCI > 0 → 상승 모멘텀 우위 • CCI < 0 → 하락 모멘텀 우위 📌 0선 돌파는 추세 시작 신호로 자주 활용 🔹 ② 과매수 / 과매도 • +100 이상 → 과매수 구간 • –100 이하 → 과매도 구간 ⚠️ 도달 즉시 매매 ❌ ✔ 되돌림(재진입)을 기다리는 게 중요 🔹 ③ 되돌림 매매 (가장 많이 사용) ✔ 매수 신호 • CCI가 –100 이하 진입
• 다시 –100 위로 회복
✔ 매도 신호 • CCI가 +100 이상 진입
• 다시 +100 아래로 이탈
📌 단기 매매에서 특히 효과적
🔹 ④ 다이버전스 ✔ 강세 다이버전스 • 주가 저점 하락
• CCI 저점 상승
→ 반등 가능성
✔ 약세 다이버전스 • 주가 고점 상승
• CCI 고점 하락
→ 하락 가능성
5️⃣ RSI·Stochastic과 차이점 기준 CCI: 0선 / RSI: 30·70 / Stochastic: 20·80 범위 CCI: 제한 없음 / RSI: 0~100 / Stochastic: 0~100 성격 CCI: 변동성 민감 / RSI: 힘의 균형 / Stochastic: 가격 위치 단타 활용 CCI: ★★★★☆ / RSI: ★★★ / Stochastic: ★★★★ 👉 CCI는 짧은 파동 포착에 강함 6️⃣ 실전 활용 팁 ✅ 횡보·조정장에서 강력 ❌ 강한 추세장에서는 과열 지속 가능 ✅ 분봉에서는 기간 늘리면 안정적 예: 20 → 30 ✅ MACD·이동평균과 병행 추천 • 추세는 MACD
• 타점은 CCI
7️⃣ 대표적인 설정값 • 기본: CCI(20) • 단타: CCI(9~14) • 보수적: CCI(30)
); case 'adx': return ( 1️⃣ ADX란? ADX(Average Directional Index)는 추세의 강도를 측정하는 지표입니다. 상승인지 하락인지가 아니라 "추세가 얼마나 강한지"만 보여줍니다. 2️⃣ ADX 해석 방법 • ADX 25 미만 → 약한 추세 (횡보) • ADX 25~50 → 강한 추세 • ADX 50 이상 → 매우 강한 추세 3️⃣ 실전 활용 팁 ✅ 추세 추종 전략 사용 여부 결정 ✅ +DI, -DI와 함께 보면 방향까지 확인 가능 ); case 'trix': return ( TRIX (Triple Exponential Average Oscillator) 📈 추세의 방향과 전환을 비교적 깔끔하게 보여주는 모멘텀 보조지표입니다. 이동평균의 노이즈를 크게 줄인 것이 가장 큰 특징입니다. 1️⃣ TRIX란? TRIX는 종가에 3중 지수이동평균(Triple EMA)을 적용한 뒤, 그 변화율(기울기)을 지표로 만든 것입니다. 즉, • 단순 이동평균보다 훨씬 부드럽고 • 잔파동(노이즈)을 많이 제거한 • 추세 중심 지표입니다. 📌 단타보다는 스윙·중기 매매에 적합 2️⃣ TRIX의 구성 요소 🔹 TRIX 선 • 3중 EMA의 변화율
• 추세의 방향과 속도를 나타냄
🔹 시그널 선 (선택) • TRIX의 이동평균
• 매매 신호 보조
3️⃣ 기본 설정값 • TRIX 기간: 14 (또는 15) • 시그널: 9 📌 RSI·MACD보다 신호가 느린 대신 신뢰도가 높음 4️⃣ 핵심 해석 방법 🔸 ① 0선 기준 해석 (가장 중요) • TRIX > 0 → 상승 추세 • TRIX < 0 → 하락 추세 ✔ 0선 돌파 = 추세 전환 신호 🔸 ② 골든크로스 / 데드크로스 (시그널 선 사용 시) ✔ 매수 신호 • TRIX가 시그널을 아래에서 위로 돌파
• 0선 근처에서 발생 시 신뢰도 ↑
✔ 매도 신호 • TRIX가 시그널을 위에서 아래로 이탈
🔸 ③ 기울기(각도) 해석 • 기울기 가팔라짐 → 추세 가속 • 기울기 둔화 → 추세 약화 📌 히스토그램처럼 힘의 변화를 직관적으로 파악 가능 🔸 ④ 다이버전스 ✔ 강세 다이버전스 • 주가 저점 하락
• TRIX 저점 상승
→ 반등 가능성
✔ 약세 다이버전스 • 주가 고점 상승
• TRIX 고점 하락
→ 하락 가능성
5️⃣ MACD와의 차이 구분 TRIX MACD 이동평균 3중 EMA 2중 EMA 반응 속도 느림 빠름 노이즈 매우 적음 중간 적합 매매 스윙·중기 단타~중기 👉 TRIX = 깔끔한 추세 판단용 6️⃣ 실전 활용 팁 ✅ 횡보장보다는 추세장에서 강력 ❌ 짧은 분봉에서는 신호 지연 ✅ 이동평균선과 궁합 좋음 • MA 정배열 + TRIX 0선 상방 = 상승 신뢰도 ↑ ✅ 단타에서는 기간 줄여 사용 가능 • 예: TRIX(7, 5) 7️⃣ TRIX의 한계 • 후행성 강함 • 급등락 초기 신호 포착 어려움 📌 진입 타점은 RSI·CCI, 📌 방향 확인은 TRIX로 분리해서 쓰면 효과적 한 줄 요약 TRIX는 3중 EMA 기반의 추세 지표로, 0선 돌파와 기울기 변화가 핵심 포인트입니다.
); case 'disparity': return ( 이격도(乖離度, Disparity Index) 📊 현재 주가가 이동평균선에서 얼마나 떨어져 있는지를 %로 나타낸 아주 직관적인 과열·과매도 보조지표입니다. 1️⃣ 이격도란? 이격도는 아래 질문에 답하는 지표입니다. "지금 주가는 평균 가격(이동평균)보다 얼마나 위(과열) 또는 아래(과매도)에 있는가?" • 100% → 이동평균과 동일 • 100% 초과 → 평균보다 비쌈 • 100% 미만 → 평균보다 쌈 2️⃣ 이격도 계산식 개념만 이해하시면 충분합니다. 이격도(%) = (현재가 ÷ 이동평균가) × 100 예) • 현재가 105, 20일선 100 → 이격도 105% • 현재가 95, 20일선 100 → 이격도 95% 3️⃣ 기준 이동평균선 선택 이격도는 어떤 이동평균을 쓰느냐가 매우 중요합니다. 이동평균 활용 5일 초단기 과열 20일 단기 매매(가장 많이 사용) 60일 중기 추세 120일 장기 과열 📌 20일 이격도가 가장 보편적 4️⃣ 핵심 해석 방법 🔹 ① 기준값 (과열/과매도) 종목·시장마다 다르지만 일반적인 기준은 다음과 같습니다. 📈 상승 과열 • 이격도 105~110% 이상
→ 단기 과열, 조정 가능성
📉 하락 과매도 • 이격도 90~95% 이하
→ 과매도, 반등 가능성
⚠️ 기준값은 고정이 아님 • 변동성 큰 종목 → 기준 확대
• 우량주 → 기준 축소
🔹 ② 평균 회귀 (Mean Reversion) 이격도의 핵심 개념입니다. • 주가는 결국 이동평균으로 되돌아가려는 성질이 있음 • 이격 ↑↑ → 조정 후 평균 회귀 • 이격 ↓↓ → 반등 후 평균 회귀 🔹 ③ 추세와 함께 사용 • 상승 추세 + 이격 과열 → 눌림목 대기 • 하락 추세 + 과매도 → 기술적 반등 주의 📌 추세 무시하고 역매매 ❌
5️⃣ 실전 활용 예시 📌 매수 타점 • 상승 추세 유지
• 조정으로 이격도 95% 내외
• 지지 확인 후 분할 매수
📌 매도 타점 • 급등 후 이격도 110% 이상
• 거래량 감소
• 분할 매도
6️⃣ RSI·CCI와 차이점 구분 이격도 RSI CCI 기준 이동평균 30·70 ±100 직관성 매우 높음 중간 중간 추세 판단 약함 보통 보통 평균회귀 ★★★★★ ★★★ ★★★ 👉 이격도 = 평균에서 얼마나 벗어났는지 한눈에 파악 7️⃣ 이격도의 장점과 한계 ✔ 장점 • 계산·해석 쉬움
• 과열/과매도 직관적
• 초보자도 사용 가능
❌ 한계 • 강한 추세장에서는 과열 지속
• 단독 사용 시 역추세 매매 위험
📌 이동평균·거래량·추세 지표와 병행 필수
한 줄 요약 이격도는 주가가 이동평균에서 얼마나 떨어졌는지를 %로 보여주는 지표로, 평균 회귀 관점에서 과열·과매도를 판단합니다.
); case 'newPsychological': return ( 신심리도(心理度, Psychological Line) 📊 투자자들의 '심리 상태'를 수치로 표현한 주식 보조지표입니다. "요즘 시장이 너무 낙관적인가, 너무 비관적인가?"를 판단할 때 사용합니다. 1️⃣ 신심리도란? 신심리도는 일정 기간 동안 주가가 오른 날의 비율 을 계산한 지표입니다. • 오르는 날이 많을수록 → 낙관(과열) • 내리는 날이 많을수록 → 비관(침체) 📌 가격 크기보다 '올랐는지/내렸는지'만 본다는 게 특징입니다. 2️⃣ 신심리도 계산 방식 (개념) 신심리도 = (상승한 날 수 ÷ 전체 기간) × 100 예) • 12일 중 9일 상승 → 신심리도 = 75 • 12일 중 3일 상승 → 신심리도 = 25 3️⃣ 기본 설정 • 기간: 12일 (가장 많이 사용) • 범위: 0 ~ 100 4️⃣ 핵심 해석 방법 🔹 ① 과매수 / 과매도 구간 가장 중요합니다. • 75 이상 → 과매수 (낙관 과도) • 25 이하 → 과매도 (비관 과도) ⚠️ 도달 즉시 매매 ❌ ✔ 되돌림(구간 이탈)을 확인 🔹 ② 평균 회귀 관점 신심리도는 역추세 지표 성격이 강합니다. • 과도한 낙관 → 조정 가능성 • 과도한 비관 → 반등 가능성 📌 횡보장·조정장에서 효과적 🔹 ③ 추세 확인 보조 • 50 이상 → 상승 심리 우위 • 50 이하 → 하락 심리 우위 5️⃣ RSI·이격도와의 차이 구분 | 신심리도 | RSI | 이격도 • 기준: 상승일 비율 | 상승·하락 강도 | 평균 대비 거리 • 가격 크기 반영: ❌ | ⭕ | ⭕ • 성격: 심리 | 모멘텀 | 평균회귀 • 역추세: ★★★★☆ | ★★★ | ★★★★★ 👉 신심리도는 "분위기 과열"을 보는 지표 6️⃣ 실전 활용 팁 ✅ 단독 사용보다는 보조용 RSI, CCI와 함께 쓰면 신뢰도 ↑ ✅ 강한 추세장에서는 신호 무력 상승장에서는 75 이상 유지 가능 ✅ 기간 조절 • 단타: 9 • 기본: 12 • 보수적: 20 7️⃣ 신심리도의 장점과 한계 ✔ 장점 • 계산·해석 매우 쉬움 • 투자심리 파악에 직관적 ❌ 한계 • 가격 변동폭 반영 안 함 • 추세 추종 능력 약함 📌 반드시 추세 지표와 병행 한 줄 요약 신심리도는 상승한 날의 비율로 투자자 심리를 수치화한 지표로, 25·75 구간 이탈이 핵심 매매 신호입니다. ); default: return ( {indicatorInfo[type]?.name} 지표에 대한 상세 설명은 준비 중입니다. ); } }; const IndicatorSettingsPopup: React.FC = ({ open, onClose, indicatorType, }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const { colors, updateColors } = useChartColors(); const { indicatorSettings, updateIndicatorSettings, saveIndicatorSettings, } = useIndicatorSettings(); // 로컬 상태 const [localColors, setLocalColors] = useState>({}); const [localSettings, setLocalSettings] = useState>({}); const [showHelpDialog, setShowHelpDialog] = useState(false); // ✅ 색상 input ref 추적 (리렌더링 방지를 위해) const colorInputRefsRef = useRef>(new Map()); // 팝업 위치 상태 (드래그 시 유지) const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); // 다이얼로그가 열릴 때 현재 값으로 초기화 (open/indicatorType 변경 시에만) useEffect(() => { if (open && indicatorType) { setLocalColors({ ...colors }); setLocalSettings({ ...indicatorSettings }); } // 다이얼로그가 닫힐 때 위치 초기화 if (!open) { setDialogPosition({ x: 0, y: 0 }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, indicatorType]); if (!indicatorType) return null; const info = indicatorInfo[indicatorType]; const handleColorChange = (key: keyof ChartColorSettings, value: string | number) => { console.log(`🎨 [IndicatorSettingsPopup] handleColorChange - key: ${key}, value: ${value}`); // ✅ 로컬 상태만 업데이트 (적용 버튼 클릭 시 Context에 반영) setLocalColors(prev => { const newColors = { ...prev, [key]: value }; console.log(`✅ [IndicatorSettingsPopup] localColors 업데이트:`, newColors); return newColors; }); }; const handleSettingChange = (key: keyof IndicatorSettings, value: number | boolean) => { // 로컬 상태만 업데이트 (적용 버튼 클릭 시 Context에 반영) setLocalSettings(prev => ({ ...prev, [key]: value })); }; const handleSave = () => { // 모든 색상 input의 현재 값을 읽어서 localColors에 반영 const finalColors: Partial = { ...localColors }; colorInputRefsRef.current.forEach((input, key) => { if (input && input.value) { (finalColors as any)[key] = input.value; } }); // 색상 업데이트 updateColors({ ...colors, ...finalColors }); // 설정값이 실제로 변경되었는지 확인 const settingsChanged = Object.keys(localSettings).some((key) => { const localValue = (localSettings as any)[key]; const currentValue = (indicatorSettings as any)[key]; return localValue !== currentValue; }); // 설정값이 변경된 경우 Context 업데이트 (settingsVersion 증가) 및 DB 저장 if (settingsChanged) { console.log('🔄 [IndicatorSettingsPopup] 설정 변경 감지 - updateIndicatorSettings 호출:', localSettings); // ✅ updateIndicatorSettings 호출 (settingsVersion 증가 → 차트 재계산 트리거) updateIndicatorSettings(localSettings); // ✅ DB 저장 saveIndicatorSettings({ ...indicatorSettings, ...localSettings }); } onClose(); }; // 파라미터 테이블 컴포넌트 const ParamTable = ({ params, }: { params: { label: string; paramKey: keyof IndicatorSettings; min?: number; max?: number }[]; }) => { // min/max 값을 기준으로 선택 옵션 생성 함수 const generateOptions = (min: number, max: number, step: number = 1) => { const options: number[] = []; for (let i = min; i <= max; i += step) { options.push(i); } return options; }; return ( 📊 파라미터 {/* 헤더 행 */} {params.map((param) => ( {param.label} ))} {/* 값 행 - 드롭다운으로 변경 */} {params.map((param) => { const min = param.min ?? 1; const max = param.max ?? 100; const currentValue = Number(localSettings[param.paramKey] ?? indicatorSettings[param.paramKey] ?? min); const options = generateOptions(min, max); return ( ); })} ); }; // 색상 및 굵기 테이블 컴포넌트 const ColorTable = ({ configs, }: { configs: { label: string; colorKey?: keyof ChartColorSettings; widthKey?: keyof ChartColorSettings; lineStyleKey?: keyof ChartColorSettings; valueKey?: keyof IndicatorSettings; valueMin?: number; valueMax?: number; valueStep?: number; enableKey?: keyof IndicatorSettings; disableToggle?: boolean; }[]; }) => { const hasValueColumn = configs.some(cfg => cfg.valueKey !== undefined); const hasEnableColumn = configs.some(cfg => cfg.enableKey !== undefined); const hasWidthColumn = configs.some(cfg => cfg.widthKey !== undefined); const hasLineStyleColumn = configs.some(cfg => cfg.lineStyleKey !== undefined); // 기준값 입력 필드의 임시 값 관리 (입력 중 포커스 유지를 위한 로컬 상태) const [inputValues, setInputValues] = React.useState>({}); // 타이틀 텍스트 생성 const getTitleText = () => { const parts = ['🎨 색상']; if (hasWidthColumn) parts.push('선 굵기'); if (hasLineStyleColumn) parts.push('선 유형'); return parts.join(', '); }; return ( {getTitleText()} {/* 헤더 행 */} theme.palette.mode === 'dark' ? '#616161' : '#9e9e9e', borderRadius: 1, }}> {hasEnableColumn && ( ON/OFF )} 항목 {hasValueColumn && ( 기준값 )} 색상 {hasWidthColumn && ( 굵기 )} {hasLineStyleColumn && ( 선 유형 )} 미리보기 {/* 각 색상 설정 행 */} {configs.map((cfg, index) => { const colorValue = cfg.colorKey ? String(localColors[cfg.colorKey] || colors[cfg.colorKey] || '#666666') : '#666666'; const widthValue = cfg.widthKey ? Number(localColors[cfg.widthKey] ?? colors[cfg.widthKey] ?? 1.5) : 1.5; const lineStyleValue = cfg.lineStyleKey ? String(localColors[cfg.lineStyleKey] ?? colors[cfg.lineStyleKey] ?? 'solid') : 'solid'; const paramValue = cfg.valueKey ? Number(localSettings[cfg.valueKey] ?? indicatorSettings[cfg.valueKey] ?? 0) : 0; const enabledValue = cfg.enableKey ? Boolean(localSettings[cfg.enableKey] ?? indicatorSettings[cfg.enableKey] ?? false) : true; return ( {/* ON/OFF 토글 */} {hasEnableColumn && ( {cfg.enableKey ? ( handleSettingChange(cfg.enableKey!, e.target.checked)} disabled={cfg.disableToggle === true} size="small" /> ) : ( - )} )} {/* 항목 이름 */} {cfg.label} {/* 기준값 선택 (valueKey가 있을 때만 표시) */} {hasValueColumn && ( {cfg.valueKey ? ( { // 드롭다운에서 선택했을 때 if (newValue !== null) { const numValue = typeof newValue === 'number' ? newValue : Number(newValue); if (!isNaN(numValue)) { const min = cfg.valueMin ?? 0; const max = cfg.valueMax ?? 100; // 기준값(Period, Length 등)은 양수만 허용 const isPeriodValue = cfg.valueKey && ( cfg.valueKey.toLowerCase().includes('period') || cfg.valueKey.toLowerCase().includes('length') || cfg.valueKey.toLowerCase().includes('slowing') ); if (numValue >= min && numValue <= max) { // 기준값인 경우 양수 확인 if (isPeriodValue && numValue < 0) { return; } handleSettingChange(cfg.valueKey!, numValue); setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(numValue) })); console.log(`🔄 [IndicatorSettingsPopup] ${cfg.label} - 선택: ${numValue}`); } } } }} inputValue={inputValues[cfg.valueKey] ?? String(paramValue)} onInputChange={(event, newInputValue, reason) => { // 입력값이 변경될 때는 로컬 상태만 업데이트 (필터링을 위해) if (reason === 'input') { // 기준값(Period, Length 등)은 양수만, 기준선(Overbought, Oversold 등)은 음수도 허용 const isPeriodValue = cfg.valueKey && ( cfg.valueKey.toLowerCase().includes('period') || cfg.valueKey.toLowerCase().includes('length') || cfg.valueKey.toLowerCase().includes('slowing') ); const numberPattern = isPeriodValue ? /^\d*\.?\d*$/ : /^-?\d*\.?\d*$/; // 빈 문자열이거나 숫자만 허용 if (newInputValue === '' || numberPattern.test(newInputValue)) { setInputValues(prev => ({ ...prev, [cfg.valueKey!]: newInputValue })); } } else if (reason === 'reset') { // 리셋 시에는 현재 paramValue로 복원 setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); } else if (reason === 'clear') { // 클리어 시 빈 문자열 setInputValues(prev => ({ ...prev, [cfg.valueKey!]: '' })); } }} options={(() => { const min = cfg.valueMin ?? 0; const max = cfg.valueMax ?? 100; const step = cfg.valueStep ?? 1; const values: number[] = []; for (let i = min; i <= max; i += step) { values.push(i); } return values; })()} getOptionLabel={(option) => String(option)} filterOptions={(options, state) => { // 입력값으로 필터링 const inputValue = state.inputValue.toLowerCase(); if (!inputValue) return options; return options.filter(option => String(option).toLowerCase().includes(inputValue) ); }} freeSolo size="small" fullWidth disableClearable renderInput={(params) => ( { // 포커스 아웃 시 실제 값 업데이트 const inputStr = e.target.value.trim(); if (inputStr === '') { // 빈 값이면 원래 값으로 복원 setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); return; } const numValue = Number(inputStr); const min = cfg.valueMin ?? 0; const max = cfg.valueMax ?? 100; // 기준값(Period, Length 등)은 양수만 허용 const isPeriodValue = cfg.valueKey && ( cfg.valueKey.toLowerCase().includes('period') || cfg.valueKey.toLowerCase().includes('length') || cfg.valueKey.toLowerCase().includes('slowing') ); if (!isNaN(numValue) && numValue >= min && numValue <= max) { // 기준값인 경우 양수 확인 if (isPeriodValue && numValue < 0) { setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); return; } handleSettingChange(cfg.valueKey!, numValue); setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(numValue) })); console.log(`🔄 [IndicatorSettingsPopup] ${cfg.label} - 입력 완료: ${numValue}`); } else { // 유효하지 않은 값이면 원래 값으로 복원 setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); } }} onKeyDown={(e) => { // 엔터 키 입력 시 실제 값 업데이트 if (e.key === 'Enter') { const inputStr = (e.target as HTMLInputElement).value.trim(); if (inputStr === '') { setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); (e.target as HTMLInputElement).blur(); return; } const numValue = Number(inputStr); const min = cfg.valueMin ?? 0; const max = cfg.valueMax ?? 100; // 기준값(Period, Length 등)은 양수만 허용 const isPeriodValue = cfg.valueKey && ( cfg.valueKey.toLowerCase().includes('period') || cfg.valueKey.toLowerCase().includes('length') || cfg.valueKey.toLowerCase().includes('slowing') ); if (!isNaN(numValue) && numValue >= min && numValue <= max) { // 기준값인 경우 양수 확인 if (isPeriodValue && numValue < 0) { setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); (e.target as HTMLInputElement).blur(); return; } handleSettingChange(cfg.valueKey!, numValue); setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(numValue) })); console.log(`🔄 [IndicatorSettingsPopup] ${cfg.label} - 엔터 입력: ${numValue}`); (e.target as HTMLInputElement).blur(); // 포커스 해제 } else { // 유효하지 않은 값이면 원래 값으로 복원 setInputValues(prev => ({ ...prev, [cfg.valueKey!]: String(paramValue) })); } } }} sx={{ '& .MuiInputBase-root': { height: 32, fontSize: '12px' }, '& .MuiInputBase-input': { py: 0.5 } }} /> )} ListboxProps={{ style: { maxHeight: '200px', fontSize: '12px' } }} sx={{ '& .MuiAutocomplete-option': { fontSize: '12px', minHeight: '32px' } }} /> ) : ( - )} )} {/* 색상 선택 */} {cfg.colorKey ? ( { if (el) { colorInputRefsRef.current.set(cfg.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: 28, height: 28, opacity: 0, cursor: 'pointer' }} /> ) : ( - )} {/* 선 굵기 슬라이더 */} {hasWidthColumn && ( {cfg.widthKey ? ( handleColorChange(cfg.widthKey!, value as number)} min={0.5} max={5} step={0.5} size="small" sx={{ flex: 1 }} /> {widthValue}px ) : ( - )} )} {/* 선 유형 선택 */} {hasLineStyleColumn && ( {cfg.lineStyleKey ? ( ) : ( - )} )} {/* 미리보기 */} {cfg.colorKey ? ( ) : ( - )} ); })} ); }; // 보조지표별 설정 렌더링 const renderIndicatorSettings = () => { switch (indicatorType) { case 'volume': return ( <> ); case 'macd': return ( <> ); case 'stochastic': return ( <> ℹ️ Stochastic Slow 설명 • 기간: Raw Stochastic %K 계산 기간 (업비트 기본값: 12일) • %K: Slow %K의 Smoothing 기간 (업비트 기본값: 5일) • %D: Slow %D (시그널선) 계산 기간 (업비트 기본값: 5일) ); case 'rsi': return ( <> ); case 'adx': return ( <> ); case 'cci': return ( <> ); case 'trix': return ( <> ); case 'disparity': return ( <> ); case 'newPsychological': return ( <> ); case 'investPsychological': return ( <> ); case 'williamsR': return ( <> ); case 'bwi': return ( <> ); case 'obv': return ( <> ); case 'volumeOsc': return ( <> ); case 'vr': return ( <> ); case 'dmi': return ( <> ); case 'momentum': return ( <> ); default: return 설정 항목이 없습니다.; } }; // 드래그 가능한 Paper 컴포넌트 function DraggablePaper(props: PaperProps) { return ( { setDialogPosition({ x: data.x, y: data.y }); }} > ); } return ( <> {info.name} 설정 setShowHelpDialog(true)} size="small" sx={{ ml: 0.5, color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.1)' } }} > e.stopPropagation()} > {renderIndicatorSettings()} {/* ✅ 하단 적용 버튼 (테스트용) */} {/* 상세 설명 다이얼로그 */} setShowHelpDialog(false)} maxWidth="md" fullWidth fullScreen={isMobile} PaperProps={{ sx: { bgcolor: 'background.paper', backgroundImage: 'none', }, }} > {info.name} 상세 설명 setShowHelpDialog(false)} size="small" sx={{ color: 'white' }}> {getIndicatorDetailedHelp(indicatorType)} ); }; export default IndicatorSettingsPopup;