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
+252
View File
@@ -0,0 +1,252 @@
# 이격도 기준선 설정 문제 수정 완료
## 🔍 문제점
이격도 보조지표 설정에서 기준선(100) ON/OFF 및 색상, 선 유형, 굵기 변경 시 차트에 제대로 반영되지 않는 문제
## 🐛 발견된 원인
### 1. **CandlestickChart.tsx - 기준선이 초단기 시리즈에만 고정**
```typescript
// ❌ 문제: ultraShortSeries에만 기준선 추가
if (indicatorSettings && indicatorSettings.disparityBaseLineEnabled) {
ultraShortSeries.createPriceLine({
price: 100,
color: colorSettings?.disparityBaseLineColor || '#9e9e9e',
// ...
});
}
```
**문제점:**
- 초단기 이격도가 비활성화되면 기준선도 함께 사라짐
- 다른 이격도 선(단기, 중기, 장기)만 활성화되어도 기준선이 표시되지 않음
### 2. **설정 UI 누락**
`IndicatorSettingsTab.tsx`에 이격도 기준선 설정 UI가 없음:
- ✅ Williams %R, TRIX 등 다른 지표는 기준선 설정 UI 존재
- ❌ 이격도는 기준선 설정 UI 없음
## ✅ 수정 사항
### 1. **CandlestickChart.tsx - 기준선을 visible 시리즈에 동적 할당**
```typescript
// ✅ 수정: visible인 첫 번째 시리즈에 기준선 추가
if (indicatorSettings && (indicatorSettings.disparityBaseLineEnabled ?? true)) {
// visible인 시리즈 찾기 (우선순위: 초단기 > 단기 > 중기 > 장기)
const visibleSeries =
(indicatorSettings.disparityUltraShortEnabled ?? true) ? ultraShortSeries :
(indicatorSettings.disparityShortEnabled ?? true) ? shortSeries :
(indicatorSettings.disparityMidEnabled ?? true) ? midSeries :
longSeries; // 기본값: 장기 (마지막 시리즈)
visibleSeries.createPriceLine({
price: 100,
color: colorSettings?.disparityBaseLineColor || '#999999',
lineWidth: (colorSettings?.disparityBaseLineWidth || 1) as any,
lineStyle: colorSettings?.disparityBaseLineStyle ? getLineStyle(colorSettings.disparityBaseLineStyle) : 2,
axisLabelVisible: false,
title: ''
});
}
```
**개선 효과:**
- ✅ 초단기 OFF 시에도 기준선 유지
- ✅ 어떤 이격도 선이 활성화되어 있어도 기준선 표시
- ✅ 우선순위에 따라 적절한 시리즈에 기준선 추가
### 2. **IndicatorSettingsTab.tsx - 기준선 설정 UI 추가**
```typescript
{/* 기준선 설정 */}
<Typography variant="subtitle2" sx={{ mt: 3, mb: 2, fontWeight: 'bold' }}>
기준선 스타일 설정
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={12}>
<Box sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<FormControlLabel
control={
<Switch
checked={settings.disparityBaseLineEnabled ?? true}
onChange={(e) => handleSettingChange({ disparityBaseLineEnabled: e.target.checked })}
/>
}
label="기준선 표시 (100)"
sx={{ mb: 2 }}
/>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="색상"
type="color"
value={colors.disparityBaseLineColor}
onChange={(e) => updateColors({ disparityBaseLineColor: e.target.value })}
size="small"
disabled={!settings.disparityBaseLineEnabled}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="굵기"
type="number"
value={colors.disparityBaseLineWidth}
onChange={(e) => updateColors({ disparityBaseLineWidth: Number(e.target.value) })}
inputProps={{ min: 0.5, max: 5, step: 0.5 }}
size="small"
disabled={!settings.disparityBaseLineEnabled}
/>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth size="small" disabled={!settings.disparityBaseLineEnabled}>
<InputLabel>선 스타일</InputLabel>
<Select
value={colors.disparityBaseLineStyle}
label="선 스타일"
onChange={(e) => updateColors({ disparityBaseLineStyle: e.target.value as 'solid' | 'dashed' | 'dotted' })}
>
<MenuItem value="solid">실선</MenuItem>
<MenuItem value="dashed">파선</MenuItem>
<MenuItem value="dotted">점선</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Box>
</Grid>
</Grid>
```
**추가된 기능:**
- ✅ 기준선 ON/OFF 스위치
- ✅ 색상 선택 (color picker)
- ✅ 굵기 조절 (0.5 ~ 5)
- ✅ 선 스타일 선택 (실선/파선/점선)
- ✅ 기준선 OFF 시 설정 비활성화
### 3. **필수 import 추가**
```typescript
import {
// ... 기존 imports
Switch,
FormControlLabel,
FormControl,
Select,
MenuItem,
InputLabel,
} from '@mui/material';
```
## 🔄 설정 변경 흐름
### 1. **사용자가 설정 변경**
```
보조지표 설정 > 이격도 > 기준선 스타일 설정
```
### 2. **Context 업데이트**
```typescript
// IndicatorSettingsContext
disparityBaseLineEnabled: true/false
// ChartColorContext
disparityBaseLineColor: string
disparityBaseLineWidth: number
disparityBaseLineStyle: 'solid' | 'dashed' | 'dotted'
```
### 3. **차트 자동 재렌더링**
```typescript
// CandlestickChart.tsx
useEffect(() => {
// 서브차트 재생성
}, [subPanels, data?.length, indicatorData?.length, themeMode,
indicatorSettings, colorSettings]); // ✅ 의존성 배열에 포함
```
### 4. **기준선 적용**
```typescript
// visible인 시리즈에 기준선 추가
visibleSeries.createPriceLine({
price: 100,
color: colorSettings?.disparityBaseLineColor,
lineWidth: colorSettings?.disparityBaseLineWidth,
lineStyle: getLineStyle(colorSettings?.disparityBaseLineStyle),
});
```
## ✅ 테스트 시나리오
### 1. **기준선 ON/OFF**
- [x] 기준선 ON → 100 기준선 표시
- [x] 기준선 OFF → 100 기준선 숨김
### 2. **색상 변경**
- [x] 색상 변경 → 기준선 색상 즉시 반영
### 3. **굵기 변경**
- [x] 굵기 0.5 ~ 5 조절 → 기준선 굵기 즉시 반영
### 4. **선 스타일 변경**
- [x] 실선 → 기준선 실선으로 표시
- [x] 파선 → 기준선 파선으로 표시
- [x] 점선 → 기준선 점선으로 표시
### 5. **이격도 선 ON/OFF 조합**
- [x] 초단기만 ON → 기준선 표시
- [x] 초단기 OFF, 단기 ON → 기준선 표시 (단기 시리즈에)
- [x] 초단기/단기 OFF, 중기 ON → 기준선 표시 (중기 시리즈에)
- [x] 초단기/단기/중기 OFF, 장기만 ON → 기준선 표시 (장기 시리즈에)
## 📊 기본값
```typescript
// IndicatorSettingsContext
disparityBaseLineEnabled: true
// ChartColorContext
disparityBaseLineColor: '#999999'
disparityBaseLineWidth: 1
disparityBaseLineStyle: 'solid'
```
## 🎯 결과
**이제 이격도 보조지표의 기준선 설정이 완벽하게 동작합니다!**
✅ 기준선 ON/OFF 정상 작동
✅ 색상 변경 즉시 반영
✅ 굵기 변경 즉시 반영
✅ 선 스타일 변경 즉시 반영
✅ 이격도 선 ON/OFF와 무관하게 기준선 유지
✅ 설정 UI 추가로 사용자 편의성 향상
## 🚀 배포
```bash
cd /Users/macbook/dev/goldenAnalysis
docker compose down
docker compose up --build
```
또는
```bash
cd frontend
npm run build
```
---
**수정 완료일:** 2026-02-01
**수정 파일:**
- `frontend/src/components/CandlestickChart.tsx`
- `frontend/src/components/settings/IndicatorSettingsTab.tsx`
@@ -0,0 +1,281 @@
# 이격도 선 ON/OFF 설정 문제 수정 완료
## 🔍 문제점
이격도 설정에서 초단기, 단기, 중기, 장기 중 항목을 OFF 하고 적용해도 차트에는 여전히 모든 그래프가 표시되는 문제
## 🐛 발견된 원인
### 1. **CandlestickChart.tsx - visible 속성 누락**
```typescript
// ❌ 문제: visible 속성이 없어서 설정과 무관하게 모든 선 표시
const ultraShortSeries = subChart.addLineSeries({
color: colorSettings?.disparityUltraShortColor || '#FF6B35',
lineWidth: (colorSettings?.disparityUltraShortWidth || 1.5) as any,
lineStyle: colorSettings?.disparityUltraShortLineStyle ? getLineStyle(colorSettings.disparityUltraShortLineStyle) : 0,
// visible 속성 없음!
});
```
### 2. **IndicatorSettingsTab.tsx - ON/OFF 스위치 UI 누락**
- 이격도 각 선(초단기, 단기, 중기, 장기)의 ON/OFF 스위치가 없음
- 사용자가 특정 선만 표시하고 싶어도 설정할 방법이 없음
## ✅ 수정 사항
### 1. **CandlestickChart.tsx - visible 속성 추가**
```typescript
// ✅ 수정: visible 속성 추가하여 설정에 따라 표시/숨김
// 초단기 이격도 (5일)
const ultraShortSeries = subChart.addLineSeries({
color: colorSettings?.disparityUltraShortColor || '#FF6B35',
lineWidth: (colorSettings?.disparityUltraShortWidth || 1.5) as any,
lineStyle: colorSettings?.disparityUltraShortLineStyle ? getLineStyle(colorSettings.disparityUltraShortLineStyle) : 0,
visible: indicatorSettings?.disparityUltraShortEnabled ?? true, // ✅ 추가
});
// 단기 이격도 (20일)
const shortSeries = subChart.addLineSeries({
color: colorSettings?.disparityShortColor || '#4CAF50',
lineWidth: (colorSettings?.disparityShortWidth || 2) as any,
lineStyle: colorSettings?.disparityShortLineStyle ? getLineStyle(colorSettings.disparityShortLineStyle) : 0,
visible: indicatorSettings?.disparityShortEnabled ?? true, // ✅ 추가
});
// 중기 이격도 (60일)
const midSeries = subChart.addLineSeries({
color: colorSettings?.disparityMidColor || '#2196F3',
lineWidth: (colorSettings?.disparityMidWidth || 2) as any,
lineStyle: colorSettings?.disparityMidLineStyle ? getLineStyle(colorSettings.disparityMidLineStyle) : 0,
visible: indicatorSettings?.disparityMidEnabled ?? true, // ✅ 추가
});
// 장기 이격도 (120일)
const longSeries = subChart.addLineSeries({
color: colorSettings?.disparityLongColor || '#9C27B0',
lineWidth: (colorSettings?.disparityLongWidth || 2) as any,
lineStyle: colorSettings?.disparityLongLineStyle ? getLineStyle(colorSettings.disparityLongLineStyle) : 0,
visible: indicatorSettings?.disparityLongEnabled ?? true, // ✅ 추가
});
```
### 2. **IndicatorSettingsTab.tsx - ON/OFF 스위치 UI 추가**
```typescript
{/* 각 선 활성화 스위치 */}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold' }}>
선 표시 설정
</Typography>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} md={3}>
<FormControlLabel
control={
<Switch
checked={settings.disparityUltraShortEnabled ?? true}
onChange={(e) => handleSettingChange({ disparityUltraShortEnabled: e.target.checked })}
/>
}
label="초단기 (5일)"
/>
</Grid>
<Grid item xs={6} md={3}>
<FormControlLabel
control={
<Switch
checked={settings.disparityShortEnabled ?? true}
onChange={(e) => handleSettingChange({ disparityShortEnabled: e.target.checked })}
/>
}
label="단기 (20일)"
/>
</Grid>
<Grid item xs={6} md={3}>
<FormControlLabel
control={
<Switch
checked={settings.disparityMidEnabled ?? true}
onChange={(e) => handleSettingChange({ disparityMidEnabled: e.target.checked })}
/>
}
label="중기 (60일)"
/>
</Grid>
<Grid item xs={6} md={3}>
<FormControlLabel
control={
<Switch
checked={settings.disparityLongEnabled ?? true}
onChange={(e) => handleSettingChange({ disparityLongEnabled: e.target.checked })}
/>
}
label="장기 (120일)"
/>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
{/* 기간 설정 (비활성화된 선은 입력 비활성화) */}
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold' }}>
기간 설정
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={3}>
<TextField
fullWidth
label="초단기 기간"
type="number"
value={settings.disparityUltraShortPeriod}
onChange={(e) => handleSettingChange({ disparityUltraShortPeriod: Number(e.target.value) })}
inputProps={{ min: 1, max: 20 }}
helperText="기본값: 5일"
disabled={!settings.disparityUltraShortEnabled} // ✅ 비활성화 시 입력 불가
/>
</Grid>
{/* 나머지 단기, 중기, 장기도 동일하게 disabled 속성 추가 */}
</Grid>
```
### 3. **IndicatorChart.tsx - 이미 올바르게 구현됨**
`IndicatorChart.tsx`는 이미 `visible` 속성이 올바르게 설정되어 있었습니다:
```typescript
visible: indicatorSettings.disparityUltraShortEnabled ?? true,
visible: indicatorSettings.disparityShortEnabled ?? true,
visible: indicatorSettings.disparityMidEnabled ?? true,
visible: indicatorSettings.disparityLongEnabled ?? true,
```
## 🔄 설정 변경 흐름
### 1. **사용자가 설정 변경**
```
보조지표 설정 > 이격도 > 선 표시 설정
```
### 2. **Context 업데이트**
```typescript
// IndicatorSettingsContext
disparityUltraShortEnabled: boolean
disparityShortEnabled: boolean
disparityMidEnabled: boolean
disparityLongEnabled: boolean
```
### 3. **차트 자동 재렌더링**
```typescript
// CandlestickChart.tsx
useEffect(() => {
// 서브차트 재생성
}, [subPanels, data?.length, indicatorData?.length, themeMode,
indicatorSettings, colorSettings]); // ✅ indicatorSettings 변경 감지
```
### 4. **visible 속성 적용**
```typescript
// 각 시리즈 생성 시 visible 속성 적용
const ultraShortSeries = subChart.addLineSeries({
// ...
visible: indicatorSettings?.disparityUltraShortEnabled ?? true,
});
```
## ✅ 테스트 시나리오
### 1. **모든 선 ON (기본값)**
- [x] 초단기, 단기, 중기, 장기 모두 표시
### 2. **초단기만 ON**
- [x] 초단기만 표시
- [x] 단기, 중기, 장기 숨김
- [x] 기준선은 초단기 시리즈에 표시
### 3. **초단기 OFF, 단기만 ON**
- [x] 단기만 표시
- [x] 초단기, 중기, 장기 숨김
- [x] 기준선은 단기 시리즈에 표시
### 4. **중기와 장기만 ON**
- [x] 중기와 장기만 표시
- [x] 초단기, 단기 숨김
- [x] 기준선은 중기 시리즈에 표시
### 5. **장기만 ON**
- [x] 장기만 표시
- [x] 초단기, 단기, 중기 숨김
- [x] 기준선은 장기 시리즈에 표시
### 6. **설정 변경 시 즉시 반영**
- [x] 스위치 ON/OFF 시 차트 즉시 업데이트
- [x] 비활성화된 선의 기간 설정 입력 비활성화
## 📊 기본값
```typescript
// IndicatorSettingsContext
disparityUltraShortEnabled: true // 초단기 (5일)
disparityShortEnabled: true // 단기 (20일)
disparityMidEnabled: true // 중기 (60일)
disparityLongEnabled: true // 장기 (120일)
```
## 🎯 결과
**이제 이격도 설정에서 각 선(초단기, 단기, 중기, 장기)의 ON/OFF가 완벽하게 동작합니다!**
✅ 초단기 ON/OFF 정상 작동
✅ 단기 ON/OFF 정상 작동
✅ 중기 ON/OFF 정상 작동
✅ 장기 ON/OFF 정상 작동
✅ 설정 변경 즉시 차트 반영
✅ 비활성화된 선의 기간 설정 입력 비활성화
✅ 기준선은 활성화된 선 중 하나에 표시
✅ 빌드 성공 (0 에러)
## 🚀 배포
```bash
cd /Users/macbook/dev/goldenAnalysis
docker compose down
docker compose up --build
```
또는
```bash
cd frontend
npm run build
```
---
**수정 완료일:** 2026-02-01
**수정 파일:**
- `frontend/src/components/CandlestickChart.tsx` - visible 속성 추가
- `frontend/src/components/settings/IndicatorSettingsTab.tsx` - ON/OFF 스위치 UI 추가
- `frontend/src/components/IndicatorChart.tsx` - 이미 올바르게 구현됨 (수정 불필요)
## 📝 참고
### Context 설정 키
```typescript
interface IndicatorSettings {
// ...
disparityUltraShortEnabled: boolean; // 초단기 활성화
disparityShortEnabled: boolean; // 단기 활성화
disparityMidEnabled: boolean; // 중기 활성화
disparityLongEnabled: boolean; // 장기 활성화
disparityBaseLineEnabled: boolean; // 기준선 활성화
// ...
}
```
### localStorage 저장
설정 변경 시 자동으로 localStorage에 저장되어 다음 접속 시에도 유지됩니다.
+63
View File
@@ -0,0 +1,63 @@
# Build stage
FROM node:18-alpine AS build
WORKDIR /app
# package.json 복사 및 의존성 설치
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
# node로 직접 react-scripts 실행 (권한 문제 회피)
ENV CI=false
ENV GENERATE_SOURCEMAP=false
# 캐시 무효화 (소스 변경 시 항상 재빌드)
ARG CACHE_BUST=1
RUN echo "Cache bust: $CACHE_BUST"
# 타임스탬프 기반 캐시 무효화 (매 빌드마다 다른 값)
ARG BUILD_TIMESTAMP
RUN echo "Build timestamp: $BUILD_TIMESTAMP"
# 소스 코드 복사
COPY . .
# ✅ BUILD_TIME을 빌드 시점으로 자동 설정하여 매번 캐시 무효화
ARG BUILD_TIME_ARG
ARG CACHE_BUST
ENV BUILD_TIME=${BUILD_TIME_ARG}
ENV REACT_APP_BUILD_TIME=${BUILD_TIME_ARG}
ENV REACT_APP_CACHE_BUST=${CACHE_BUST}
# ✅ 환경별 API URL 설정 (Docker 빌드 시점에 주입)
ARG REACT_APP_API_BASE_URL
ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
ARG REACT_APP_MARKET_DATA_URL
ENV REACT_APP_MARKET_DATA_URL=${REACT_APP_MARKET_DATA_URL}
ARG REACT_APP_OLLAMA_URL
ENV REACT_APP_OLLAMA_URL=${REACT_APP_OLLAMA_URL}
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
# 빌드 캐시 완전 무효화
RUN echo "🔨 Building at: ${BUILD_TIME}" && \
rm -rf node_modules/.cache build .cache && \
npm cache clean --force && \
node ./node_modules/react-scripts/bin/react-scripts.js build
# Production stage
FROM nginx:alpine
# nginx 설정 복사
COPY nginx.conf /etc/nginx/conf.d/default.conf
# SSL 인증서 복사
COPY ssl/server.crt /etc/nginx/ssl/server.crt
COPY ssl/server.key /etc/nginx/ssl/server.key
# 빌드된 파일 복사
COPY --from=build /app/build /usr/share/nginx/html
# HTTP 및 HTTPS 포트 노출
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
+68
View File
@@ -0,0 +1,68 @@
# Build stage
FROM node:18-alpine AS build
WORKDIR /app
# package.json 복사 및 의존성 설치
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
# 소스 코드 복사
COPY . .
# node로 직접 react-scripts 실행 (권한 문제 회피)
ENV CI=false
ENV GENERATE_SOURCEMAP=false
# 캐시 무효화 (소스 변경 시 항상 재빌드)
ARG CACHE_BUST=1
ARG BUILD_TIMESTAMP
ARG BUILD_TIME_ARG
ENV BUILD_TIME=${BUILD_TIME_ARG}
ENV REACT_APP_BUILD_TIME=${BUILD_TIME_ARG}
# ✅ 환경별 API/WebSocket URL 설정 (Docker 빌드 시점에 주입 - 기본 Dockerfile과 동일)
ARG REACT_APP_API_BASE_URL
ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
ARG REACT_APP_MARKET_DATA_URL
ENV REACT_APP_MARKET_DATA_URL=${REACT_APP_MARKET_DATA_URL}
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
# 빌드 캐시 완전 무효화 후 빌드
RUN echo "🔨 Building at: ${BUILD_TIME}" && \
rm -rf node_modules/.cache build .cache && \
npm cache clean --force && \
node ./node_modules/react-scripts/bin/react-scripts.js build
# Production stage
FROM nginx:alpine
# envsubst를 위한 gettext, 자체 서명 인증서를 위한 openssl 설치
RUN apk add --no-cache gettext openssl
# nginx 설정 템플릿 복사
COPY nginx-letsencrypt.conf /etc/nginx/templates/default.conf.template
# 초기 설정용 nginx 설정 (인증서 없이 시작하기 위함)
COPY nginx-init.conf /etc/nginx/conf.d/default.conf
# Let's Encrypt webroot 및 SSL 디렉토리 생성
RUN mkdir -p /var/www/certbot /etc/nginx/ssl
# 빌드된 파일 복사
COPY --from=build /app/build /usr/share/nginx/html
# 시작 스크립트 복사
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# HTTP 및 HTTPS 포트 노출
EXPOSE 80 443
# 환경변수 기본값
ENV DOMAIN=exdev.co.kr
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+17
View File
@@ -0,0 +1,17 @@
# Production stage only - use local build
FROM nginx:alpine
# nginx 설정 복사
COPY nginx.conf /etc/nginx/conf.d/default.conf
# SSL 인증서 복사
COPY ssl/server.crt /etc/nginx/ssl/server.crt
COPY ssl/server.key /etc/nginx/ssl/server.key
# 로컬에서 빌드된 파일 복사
COPY build /usr/share/nginx/html
# HTTP 및 HTTPS 포트 노출
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
+284
View File
@@ -0,0 +1,284 @@
# 프론트엔드 환경 설정 가이드
## 📋 환경별 설정
### 1. 로컬 개발 환경 (Development)
**실행 방법:**
```bash
npm start
# 또는 Windows에서
npm run start:win
```
**접속 URL:**
- 프론트엔드: `http://localhost:3100`
**자동 연결되는 서비스:**
- 백엔드 API: `http://localhost:8083/api` (로컬 Docker)
- Market Data WebSocket: `wss://exdev.co.kr/market-data/ws/chart` (서버)
- Backend WebSocket: `ws://localhost:8083/ws` (로컬 Docker)
**환경 변수 파일:** `.env.development`, `.env.local`
**특징:**
- Hot Reload 지원 (코드 수정 시 자동 새로고침)
- 소스맵 포함 (디버깅 용이)
- 개발자 도구 활성화
- **WebSocket Data Service는 서버(exdev.co.kr)에서 실행 중**
---
### 2. 로컬 프로덕션 빌드 (Local Production Build)
**빌드 방법:**
```bash
npm run build
```
**실행 방법:**
```bash
# serve 패키지 설치 (한 번만)
npm install -g serve
# 빌드된 파일 서빙 (포트 3100)
serve -s build -l 3100
```
**접속 URL:**
- 프론트엔드: `http://localhost:3100`
**자동 연결되는 서비스:**
- 백엔드 API: `http://localhost:8083/api` (로컬 Docker)
- Market Data WebSocket: `wss://exdev.co.kr/market-data/ws/chart` (서버)
- Backend WebSocket: `ws://localhost:8083/ws` (로컬 Docker)
**환경 변수 파일:** `.env.production.local` (없으면 코드에서 자동 감지)
**특징:**
- 최적화된 프로덕션 빌드
- 코드 압축 및 난독화
- **WebSocket Data Service는 서버(exdev.co.kr)에서 실행 중**
---
### 3. 서버 배포 환경 (Production on Server)
**빌드 방법:**
```bash
npm run build
```
**배포 방법:**
```bash
# Docker 이미지 빌드 및 배포
docker-compose build frontend
docker-compose up -d frontend
```
**접속 URL:**
- 프론트엔드: `https://exdev.co.kr` (HTTPS, 포트 443)
- 또는: `http://exdev.co.kr` (HTTP, 포트 80)
**nginx 프록시를 통해 연결되는 서비스:**
- 백엔드 API: `https://exdev.co.kr/api` → `backend:8083/api`
- Market Data WebSocket: `wss://exdev.co.kr/market-data/ws/chart` → `market-data-system:8086/ws/chart`
- Backend WebSocket: `wss://exdev.co.kr/ws` → `backend:8083/ws`
**환경 변수 파일:** `.env.production`
**특징:**
- nginx 리버스 프록시 사용
- SSL/TLS 암호화 (HTTPS)
- 모든 서비스가 443 포트를 통해 접근
- 외부 네트워크에서 접근 가능
---
## 🔧 환경 감지 로직
### API Base URL (`apiConfig.ts`)
```typescript
export const getApiBaseUrl = (): string => {
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';
if (isLocalhost) {
// 로컬: 직접 연결
return 'http://localhost:8083/api';
}
// 서버: nginx 프록시 (상대 경로)
return '/api';
};
```
### Market Data WebSocket URL (`useMarketDataWebSocket.ts`)
```typescript
const getMarketDataUrl = (): string => {
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '';
if (isLocalhost) {
// 로컬: 직접 연결
return 'http://localhost:8086';
}
// 서버: nginx 프록시
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
return `${protocol}//${window.location.host}/market-data`;
};
```
**핵심:** `window.location.hostname`을 기준으로 자동 감지하므로, 환경 변수 없이도 동작합니다!
---
## 📁 환경 변수 파일 우선순위
React는 다음 순서로 환경 변수 파일을 로드합니다:
1. `.env.development.local` (개발 환경, git 무시)
2. `.env.production.local` (프로덕션 환경, git 무시)
3. `.env.local` (모든 환경, git 무시)
4. `.env.development` (개발 환경)
5. `.env.production` (프로덕션 환경)
6. `.env` (모든 환경)
**현재 프로젝트:**
- `.env` - 기본 포트 설정
- `.env.local` - 로컬 개발 설정
- `.env.development` - 개발 환경 설정
- `.env.production` - 프로덕션 환경 설정 (주석만, 실제로는 코드에서 자동 감지)
---
## 🚀 시나리오별 사용법
### 시나리오 1: 로컬에서 개발
```bash
# 1. 백엔드 서비스 실행 (Docker)
docker-compose up -d backend market-data-system
# 2. 프론트엔드 개발 서버 실행
cd frontend
npm start
# 3. 브라우저에서 접속
# http://localhost:3100
```
### 시나리오 2: 로컬에서 프로덕션 빌드 테스트
```bash
# 1. 백엔드 서비스 실행 (Docker)
docker-compose up -d backend market-data-system
# 2. 프론트엔드 빌드
cd frontend
npm run build
# 3. 빌드된 파일 서빙
serve -s build -l 3100
# 4. 브라우저에서 접속
# http://localhost:3100
```
### 시나리오 3: 서버에 배포
```bash
# 1. 전체 서비스 빌드 및 배포
docker-compose build
docker-compose up -d
# 2. 외부에서 접속
# https://exdev.co.kr
```
---
## 🔍 트러블슈팅
### 문제: 로컬에서 API 연결 안 됨
**확인 사항:**
1. 백엔드 서비스가 실행 중인지 확인
```bash
docker-compose ps
```
2. 포트가 열려있는지 확인
```bash
# Windows
netstat -ano | findstr :8083
netstat -ano | findstr :8086
```
3. 브라우저 콘솔에서 URL 확인
```
[API Config] 로컬 환경 감지: http://localhost:8083/api
[Market Data] WebSocket URL: ws://localhost:8086/ws/chart
```
### 문제: 서버에서 WebSocket 연결 안 됨
**확인 사항:**
1. nginx 설정 확인
```bash
docker-compose exec frontend nginx -t
```
2. nginx 로그 확인
```bash
docker-compose logs frontend
```
3. 브라우저 콘솔에서 URL 확인
```
[Market Data] WebSocket URL: wss://exdev.co.kr/market-data/ws/chart
```
### 문제: Mixed Content 오류 (HTTP/HTTPS)
**원인:** HTTPS 페이지에서 HTTP 리소스 로드 시도
**해결:**
- 로컬: `http://localhost` 사용 (HTTPS 불필요)
- 서버: 모든 연결이 HTTPS/WSS로 자동 변환됨
---
## 📝 체크리스트
### 로컬 개발 시작 전
- [ ] Docker Desktop 실행 중
- [ ] `docker-compose up -d backend market-data-system` 실행
- [ ] `npm install` 완료
- [ ] 포트 3100, 8083, 8086 사용 가능
### 서버 배포 전
- [ ] 프로덕션 빌드 테스트 완료
- [ ] nginx 설정 확인
- [ ] SSL 인증서 유효
- [ ] 방화벽 443 포트 오픈
---
## 🎯 요약
| 환경 | 접속 URL | 백엔드 API | Market Data WS | 특징 |
|------|---------|-----------|----------------|------|
| **로컬 개발** | `http://localhost:3100` | `http://localhost:8083` | `wss://exdev.co.kr/market-data` | Hot Reload, 서버 WS |
| **로컬 빌드** | `http://localhost:3100` | `http://localhost:8083` | `wss://exdev.co.kr/market-data` | 최적화 빌드, 서버 WS |
| **서버 배포** | `https://exdev.co.kr` | `https://exdev.co.kr/api` | `wss://exdev.co.kr/market-data` | nginx 프록시 |
**핵심:**
- 백엔드 API는 로컬/서버 환경에 따라 자동 감지
- **WebSocket Data Service는 항상 서버(exdev.co.kr) 사용** 🚀
- 별도 설정 없이 어디서든 동작합니다!
@@ -0,0 +1,386 @@
# Week 3-4: Frontend 구현 완료
## 📋 구현 완료 목록
### 1. API 서비스
- ✅ **strategyDSLApi.ts** (600+ 라인)
- LogicNode, ConditionDSL, StrategyDSLDto 타입 정의
- CRUD API 함수 (생성, 조회, 수정, 삭제)
- 유틸리티 함수 (유효성 검증, 자연어 변환 등)
- 12개 API 엔드포인트 연동
### 2. 드래그 앤 드롭 컴포넌트 (strategy-tree/)
- ✅ **NodePalette.tsx** (300+ 라인)
- 드래그 가능한 지표/조건 팔레트
- 논리 연산자 (AND, OR, NOT)
- 보조 지표 (CCI, MACD, RSI 등 8개)
- 사용 가이드 및 예시 표시
- ✅ **TreeNode.tsx** (280+ 라인)
- 재귀적 트리 노드 렌더링
- 드래그 앤 드롭 지원 (@dnd-kit)
- 노드 타입별 색상 및 아이콘
- 편집/삭제/자식 추가 액션
- ✅ **ConditionEditor.tsx** (350+ 라인)
- 조건 설정 다이얼로그
- 지표 타입 선택
- 조건 타입 선택 (13종류)
- 기준값, 기간 입력
- 실시간 미리보기
- ✅ **NaturalLanguagePreview.tsx** (250+ 라인)
- DSL → 자연어 변환
- 전략 통계 (노드 수, 조건 수, 깊이, 복잡도)
- 유효성 검증 알림
- 복잡도 경고
- ✅ **StrategyTreeBuilder.tsx** (280+ 라인)
- DndContext 통합
- 노드 조작 로직 (찾기, 업데이트, 삭제, 추가)
- 드래그 앤 드롭 이벤트 처리
- 조건 편집 다이얼로그 통합
### 3. 메인 페이지
- ✅ **StrategyTreeBuilderPage.tsx** (450+ 라인)
- 3-컬럼 레이아웃
- 왼쪽: 전략 목록 + 팔레트
- 중앙: 전략 편집기
- 오른쪽: 자연어 미리보기
- 전략 CRUD 기능
- 저장/삭제 다이얼로그
- 스낵바 알림
### 4. 기존 파일 수정
- ✅ **App.tsx** (수정)
- StrategyTreeBuilderPage import 추가
- 메뉴 항목 추가
- "전략(기존)" → StrategyRulePage
- "전략(드래그)" → StrategyTreeBuilderPage
- renderPage에 'strategy-tree' 케이스 추가
---
## 📊 구현 통계
| 항목 | 개수 | 라인 수 |
|------|------|---------|
| API 서비스 | 1개 | 600+ |
| 컴포넌트 | 5개 | 1,460+ |
| 페이지 | 1개 | 450+ |
| App.tsx 수정 | - | +20 |
| **총계** | **7개 파일** | **2,530+ 라인** |
---
## 🎯 핵심 달성 사항
### ✅ 기존 기능 100% 보존
- `StrategyRulePage.tsx` 수정 없음
- 기존 메뉴 항목 그대로 유지
- 기존 API (`strategyRuleApi.ts`) 영향 없음
- 기존 백테스트 화면 정상 동작
### ✅ 드래그 앤 드롭 UI 완성
- @dnd-kit 라이브러리 활용
- 직관적인 트리 구조 편집
- 실시간 미리보기
- 자연어 설명 자동 생성
### ✅ Material-UI 디자인 일관성
- 기존 테마 시스템 활용
- 다크 모드 완벽 지원
- 반응형 레이아웃 (Grid 시스템)
### ✅ 사용자 경험 최적화
- 드래그 가이드 제공
- 실시간 유효성 검증
- 복잡도 경고
- 스낵바 알림
---
## 🔍 코드 검증 완료
- ✅ **Lint 오류 없음**
- ✅ **TypeScript 타입 안정성 보장**
- ✅ **재사용 가능한 컴포넌트 구조**
- ✅ **명확한 props 인터페이스**
---
## 🎨 UI 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎨 전략 설정 (드래그 방식) [새로고침] [새 전략] [저장] │
├───────────────┬──────────────────────────┬───────────────────────┤
│ │ │ │
│ 📋 전략 목록 │ ✏️ 전략 편집기 │ 📝 전략 미리보기 │
│ │ │ │
│ □ CCI 전략 │ ┌─────────────────┐ │ 📊 통계 │
│ □ MACD 전략 │ │ AND 노드 │ │ - 노드: 3개 │
│ □ RSI 전략 │ └──┬──────────┬───┘ │ - 조건: 2개 │
│ │ │ │ │ - 깊이: 2 │
│ [+ 새 전략] │ ┌─────┐ ┌─────┐ │ - 복잡도: 간단함 │
│ │ │ CCI │ │ RSI │ │ │
│ ───────────── │ └─────┘ └─────┘ │ 📝 설명 │
│ │ │ "다음 조건을 모두 │
│ 🔗 논리 연산자│ │ 만족: │
│ [AND] [OR] │ │ CCI(20)가 -100을 │
│ [NOT] │ │ 상향 돌파 │
│ │ │ RSI(14)가 30 이하" │
│ 📊 보조 지표 │ │ │
│ [CCI] [MACD] │ │ ✅ 전략이 유효합니다 │
│ [RSI] ... │ │ │
└───────────────┴──────────────────────────┴───────────────────────┘
```
---
## 🚀 주요 기능
### 1. 드래그 앤 드롭
- **팔레트에서 노드 드래그**
- 논리 연산자 (AND/OR/NOT)
- 보조 지표 (CCI, MACD, RSI 등)
- **트리 구조 생성**
- 자유로운 계층 구조
- 재귀적 렌더링
- **드롭 영역 하이라이트**
- 드롭 가능한 영역 표시
- 실시간 피드백
### 2. 조건 편집
- **지표 선택**
- 12종류 보조 지표 지원
- 권장 기간 자동 설정
- **조건 타입 선택**
- 13종류 조건 지원
- 조건별 필수 입력 항목 자동 표시
- **실시간 미리보기**
- 설정 중인 조건 자연어 표시
### 3. 자연어 변환
- **DSL → 한글 변환**
- 논리 구조 명확히 표현
- 들여쓰기로 계층 표시
- **통계 정보**
- 노드 개수, 조건 개수
- 트리 깊이, 복잡도 점수
- **유효성 검증**
- 저장 가능 여부 표시
- 복잡도 경고
### 4. 전략 관리
- **CRUD 작업**
- 생성, 조회, 수정, 삭제
- **전략 복제**
- 기존 전략 복사하여 새 전략 생성
- **활성화 관리**
- 개별 전략 활성화/비활성화
- **FSM 토글**
- Finite State Machine 사용 여부 설정
---
## 📝 사용 예시
### 예시 1: CCI 침체 탈출 전략
**구성:**
```
AND 노드
├─ CCI 조건: -100 상향 돌파
└─ RSI 조건: 30 이하
```
**자연어:**
```
다음 조건을 모두 만족:
CCI(20)가 -100을 상향 돌파
RSI(14)가 30 이하
```
**DSL JSON:**
```json
{
"id": "node-1",
"type": "AND",
"children": [
{
"id": "node-2",
"type": "CONDITION",
"condition": {
"indicatorType": "CCI",
"conditionType": "CROSS_UP",
"targetValue": -100,
"period": 20
}
},
{
"id": "node-3",
"type": "CONDITION",
"condition": {
"indicatorType": "RSI",
"conditionType": "BELOW",
"targetValue": 30,
"period": 14
}
}
]
}
```
### 예시 2: 복합 전략 (OR 조건)
**구성:**
```
OR 노드
├─ MACD 조건: 골든 크로스
└─ Stochastic 조건: 과매도
```
**자연어:**
```
다음 조건 중 하나 이상 만족:
MACD(12) 골든 크로스
Stochastic(14) 과매도 구간
```
---
## 🔄 데이터 흐름
### 전략 생성 흐름
```
1. 사용자: 팔레트에서 AND 노드 드래그
↓
2. Frontend: LogicNode 생성 (type: "AND")
↓
3. 사용자: CCI 지표 드래그 → AND 노드에 드롭
↓
4. Frontend: CONDITION 노드 생성 → 자식으로 추가
↓
5. 사용자: 조건 편집 (기준값 -100, 기간 20)
↓
6. Frontend: ConditionDSL 업데이트
↓
7. 사용자: 저장 버튼 클릭
↓
8. Frontend: LogicNode → JSON.stringify
↓
9. Backend API: POST /api/strategies/dsl
↓
10. Backend: DSL → Legacy 변환 (StrategyConverterService)
↓
11. Database: strategy_rule 테이블에 저장
↓
12. Frontend: 스낵바 "전략이 생성되었습니다" 표시
```
### 백테스트 실행 흐름
```
1. 사용자: 투자분석 화면에서 전략 선택 (DSL 전략)
↓
2. Frontend: 백테스트 요청
↓
3. Backend: StrategyRule 조회 (dsl_json 포함)
↓
4. Backend: 기존 BacktestService 사용 (변경 없음)
↓
5. Backend: ConditionGroup, StrategyCondition 기반 평가
↓
6. Frontend: 매수/매도 마커 표시
```
---
## 🎓 기술 스택
| 항목 | 기술 |
|------|------|
| 언어 | TypeScript |
| UI 프레임워크 | React 18 |
| 디자인 시스템 | Material-UI 5 |
| 드래그 앤 드롭 | @dnd-kit |
| 상태 관리 | React Hooks (useState, useEffect) |
| API 통신 | Axios |
| 라우팅 | 없음 (단일 페이지 앱 내 페이지 전환) |
---
## ✅ 검증 항목
- [x] 드래그 앤 드롭 동작
- [x] API 통신 (CRUD)
- [x] 자연어 변환
- [x] 전략 저장/로드
- [x] 기존 화면 정상 동작
- [x] 다크 모드 지원
- [x] 반응형 레이아웃
- [x] TypeScript 타입 안정성
- [x] Lint 오류 없음
---
## 📦 파일 구조
```
frontend/src/
├── services/
│ └── strategyDSLApi.ts (신규)
├── components/
│ └── strategy-tree/ (신규)
│ ├── NodePalette.tsx
│ ├── TreeNode.tsx
│ ├── ConditionEditor.tsx
│ ├── NaturalLanguagePreview.tsx
│ └── StrategyTreeBuilder.tsx
├── pages/
│ ├── StrategyRulePage.tsx (기존, 수정 없음)
│ └── StrategyTreeBuilderPage.tsx (신규)
└── App.tsx (수정)
```
---
## 🔧 다음 단계 (선택사항)
### Week 5-6: 고급 기능 추가 (선택)
1. **전략 템플릿**
- 사전 정의된 전략 템플릿 제공
- 한 클릭으로 적용
2. **전략 시뮬레이션**
- 드래그 화면에서 바로 백테스트
- 실시간 성과 미리보기
3. **전략 공유**
- 전략 내보내기/가져오기 (JSON)
- 커뮤니티 전략 라이브러리
### Week 7-8: FSM 엔진 통합 (선택)
1. **Indicator FSM 활성화**
- useFsm 플래그 활용
- 성능 비교 대시보드
2. **실시간 상태 추적**
- FSM 상태 시각화
- 디버깅 도구
---
## 🎉 Week 3-4 구현 완료!
**모든 Frontend 작업이 완료되었습니다.**
- ✅ 드래그 앤 드롭 UI 완성
- ✅ DSL API 통합 완료
- ✅ 기존 기능 100% 보존
- ✅ 사용자 경험 최적화
**사용자는 이제:**
- 기존 폼 방식 (StrategyRulePage)
- 드래그 앤 드롭 방식 (StrategyTreeBuilderPage)
**두 가지 방법으로 전략을 생성할 수 있습니다!**
모든 전략은 동일한 테이블에 저장되며, 기존 백테스트 엔진에서 정상 동작합니다.
+318
View File
@@ -0,0 +1,318 @@
#!/bin/sh
set -e
# 환경변수에서 도메인 가져오기
DOMAIN=${DOMAIN:-exdev.co.kr}
echo "========================================"
echo "Golden Analysis Frontend (Let's Encrypt)"
echo "Domain: $DOMAIN"
echo "========================================"
# Let's Encrypt 인증서 경로
CERT_PATH="/etc/letsencrypt/live/$DOMAIN"
# 자체 서명 인증서 경로
SELF_SIGNED_PATH="/etc/nginx/ssl"
# 인증서가 존재하는지 확인
if [ -f "$CERT_PATH/fullchain.pem" ] && [ -f "$CERT_PATH/privkey.pem" ]; then
echo "✅ Let's Encrypt SSL Certificate found. Enabling HTTPS..."
# nginx 설정 템플릿에서 도메인 치환하여 적용
envsubst '${DOMAIN}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
echo "HTTPS configuration applied with Let's Encrypt certificate."
else
echo "⚠️ Let's Encrypt SSL Certificate NOT found."
echo "Generating self-signed certificate for HTTPS..."
# 자체 서명 인증서 디렉토리 생성
mkdir -p "$SELF_SIGNED_PATH"
# 자체 서명 인증서 생성 (도메인과 localhost 모두 지원)
if [ ! -f "$SELF_SIGNED_PATH/server.crt" ] || [ ! -f "$SELF_SIGNED_PATH/server.key" ]; then
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout "$SELF_SIGNED_PATH/server.key" \
-out "$SELF_SIGNED_PATH/server.crt" \
-subj "/C=KR/ST=Seoul/L=Seoul/O=GoldenAnalysis/OU=Dev/CN=$DOMAIN" \
-addext "subjectAltName=DNS:$DOMAIN,DNS:localhost,IP:127.0.0.1"
echo "✅ Self-signed certificate generated."
else
echo "✅ Self-signed certificate already exists."
fi
# 자체 서명 인증서용 nginx 설정 생성
cat > /etc/nginx/conf.d/default.conf << 'NGINX_CONF'
# WebSocket Upgrade 헤더 맵
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP 서버 (localhost와 원격 도메인 모두 지원)
server {
listen 80;
server_name _;
# Let's Encrypt ACME challenge 경로
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# localhost나 127.0.0.1로 접속 시 HTTP로 정상 서비스
# 그 외 도메인은 HTTPS로 리다이렉트
set $redirect_https 0;
if ($host !~* ^(localhost|127\.0\.0\.1)$) {
set $redirect_https 1;
}
if ($redirect_https = 1) {
return 301 https://$host$request_uri;
}
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# Market Data WebSocket 프록시 (실시간 차트용 - Python Broadcast Server)
location /market-data/ {
proxy_pass http://market-data-broadcast:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# API 프록시 (Spring Boot 백엔드)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# 정적 파일 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# HTTPS 서버 (자체 서명 인증서)
server {
listen 443 ssl http2;
server_name _;
# 자체 서명 SSL 인증서 설정
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# SSL 보안 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# Market Data WebSocket 프록시 (실시간 차트용 - Python Broadcast Server)
location /market-data/ {
proxy_pass http://market-data-broadcast:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# API 프록시 (Spring Boot 백엔드)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# 정적 파일 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
NGINX_CONF
echo "HTTPS configuration applied with self-signed certificate."
echo ""
echo "⚠️ 브라우저에서 인증서 경고가 표시됩니다 (자체 서명 인증서)."
echo ""
echo "📋 Let's Encrypt 인증서 발급 방법:"
echo " docker exec golden-analysis-certbot certbot certonly --webroot -w /var/www/certbot -d $DOMAIN --email your-email@example.com --agree-tos --non-interactive"
echo ""
echo "인증서 발급 후 컨테이너를 재시작하세요."
fi
echo ""
echo "Starting Nginx..."
exec "$@"
@@ -0,0 +1,69 @@
{
"name": "lightweight-charts-indicators",
"version": "0.4.1",
"type": "module",
"description": "446 technical analysis indicators for TradingView's lightweight-charts library",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"prepublishOnly": "npm run build",
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:types",
"build:esm": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.mjs --platform=neutral --external:oakscriptjs --external:@types/*",
"build:cjs": "esbuild src/index.ts --bundle --format=cjs --outfile=dist/index.cjs --platform=neutral --external:oakscriptjs --external:@types/*",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"build:example": "vite build",
"dev:example": "vite",
"clean": "rimraf dist dist-example",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:pinesuite": "vitest run --config vitest.regression.config.ts"
},
"keywords": [
"lightweight-charts",
"pinescript",
"trading",
"technical-analysis",
"indicators",
"oakscript",
"ta",
"finance"
],
"author": "Odyssée",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/deepentropy/lightweight-charts-indicators.git"
},
"bugs": {
"url": "https://github.com/deepentropy/lightweight-charts-indicators/issues"
},
"homepage": "https://github.com/deepentropy/lightweight-charts-indicators#readme",
"peerDependencies": {
"oakscriptjs": "^0.2.8"
},
"devDependencies": {
"@types/node": "^20.10.6",
"esbuild": "^0.19.11",
"lightweight-charts": "^5.0.0",
"oakscriptjs": "^0.2.8",
"rimraf": "^6.1.0",
"typescript": "^5.3.3",
"vite": "^5.4.0",
"vitest": "^1.6.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
# 초기 설정 - Let's Encrypt 인증서 발급 전 HTTP만 서비스
# 인증서 발급 후 nginx-letsencrypt.conf로 교체됨
server {
listen 80;
server_name _;
# Let's Encrypt ACME challenge 경로
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
root /usr/share/nginx/html;
index index.html;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
}
# API 프록시 (Spring Boot 백엔드) - HTTP로 임시 서비스
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# AI Agent 프록시
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+366
View File
@@ -0,0 +1,366 @@
# WebSocket Upgrade 헤더 맵
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP 서버 (localhost와 원격 도메인 모두 지원)
server {
listen 80;
server_name _;
# Let's Encrypt ACME challenge 경로
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# localhost나 127.0.0.1로 접속 시 HTTP로 정상 서비스
# 그 외 도메인은 HTTPS로 리다이렉트
set $redirect_https 0;
if ($host !~* ^(localhost|127\.0\.0\.1)$) {
set $redirect_https 1;
}
if ($redirect_https = 1) {
return 301 https://$host$request_uri;
}
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# Market Data WebSocket 프록시 (실시간 차트용 - Python Broadcast Server)
location /market-data/ {
proxy_pass http://market-data-broadcast:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# Native WebSocket (Spring /ws/realtime — nginx.conf 와 동일, 누락 시 wss Code 1006)
location /ws {
proxy_pass http://backend:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# SSE 알림 구독 (버퍼링 비활성화 필수)
location /api/notifications/subscribe {
proxy_pass http://backend:8082/api/notifications/subscribe;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
}
# API 프록시 (Spring Boot 백엔드)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# 정적 파일 캐싱 (개발 환경에서는 캐시 비활성화)
location ~* \.(js|css)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 이미지 및 폰트는 짧은 캐시
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1h;
add_header Cache-Control "public";
}
}
# HTTPS 서버
server {
listen 443 ssl http2;
server_name _;
# Let's Encrypt SSL 인증서 설정
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
# SSL 보안 설정 (Let's Encrypt 권장 설정)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# HSTS (HTTP Strict Transport Security)
add_header Strict-Transport-Security "max-age=63072000" always;
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
# index.html 캐시 방지 (항상 최신 버전 제공)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off;
# CORS 헤더 추가
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# Market Data WebSocket 프록시 (실시간 차트용 - Python Broadcast Server)
location /market-data/ {
proxy_pass http://market-data-broadcast:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# Native WebSocket (Spring /ws/realtime — nginx.conf 와 동일, 누락 시 wss Code 1006)
location /ws {
proxy_pass http://backend:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# SSE 알림 구독 (버퍼링 비활성화 필수)
location /api/notifications/subscribe {
proxy_pass http://backend:8082/api/notifications/subscribe;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
}
# API 프록시 (Spring Boot 백엔드)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# CORS 헤더 추가
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# 정적 파일 캐싱 (개발 환경에서는 캐시 비활성화)
location ~* \.(js|css)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 이미지 및 폰트는 짧은 캐시
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1h;
add_header Cache-Control "public";
}
}
+162
View File
@@ -0,0 +1,162 @@
# 로컬 개발 환경용 nginx 설정 (localhost, 자체 서명 인증서)
# MultiChart / 실시간 차트용 Native WebSocket (Spring /ws/realtime) — 누락 시 wss 연결 실패(Code 1006)·로딩 무한 대기
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP 서버 (HTTPS로 리다이렉트 또는 직접 서비스)
server {
listen 80;
server_name localhost 127.0.0.1;
# 개발 환경에서는 HTTP로도 접근 가능 (선택사항: HTTPS로 리다이렉트)
# return 301 https://$host$request_uri;
root /usr/share/nginx/html;
index index.html;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off;
}
# API 프록시 (Spring Boot 백엔드)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# WebSocket (실시간 캔들 /ws/realtime — nginx.conf 와 동일)
location /ws {
proxy_pass http://backend:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
}
# HTTPS 서버 (자체 서명 인증서)
server {
listen 443 ssl http2;
server_name localhost 127.0.0.1;
# 자체 서명 SSL 인증서 설정
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# SSL 보안 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
# index.html 캐시 방지 (항상 최신 버전 제공)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off;
}
# API 프록시 (Spring Boot 백엔드)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# CORS는 백엔드(Spring Boot)에서 처리하므로 Nginx에서는 제거
}
# WebSocket (wss://localhost/ws/realtime → Spring RealtimeWebSocketHandler)
location /ws {
proxy_pass http://backend:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# 정적 파일 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+357
View File
@@ -0,0 +1,357 @@
# WebSocket Upgrade 헤더 맵 (Connection 헤더 정확한 전달)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP 서버 (모바일 앱 접근용)
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
# index.html 캐시 방지 + ETag/Last-Modified 제거
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
etag off;
if_modified_since off;
add_header Last-Modified "";
}
}
# SSE 알림 구독 (버퍼링 비활성화 필수)
location /api/notifications/subscribe {
proxy_pass http://backend:8082/api/notifications/subscribe;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
}
# API 프록시 설정 (백엔드로 요청 전달)
location /api/ {
proxy_pass http://backend:8082/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# CORS 헤더
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
# OPTIONS 요청 처리
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# WebSocket 프록시 (STOMP /ws, SockJS 하위경로, Native /ws/realtime 등 — URI 전체를 백엔드로 전달)
location /ws {
proxy_pass http://backend:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 타임아웃 설정 (길게 유지)
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# CORS 헤더
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept' always;
}
# Market Data WebSocket 프록시 (실시간 차트용 - Python Broadcast Server)
location /market-data/ {
proxy_pass http://market-data-broadcast:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_buffering off;
# WebSocket 타임아웃 설정 (길게 유지)
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# CORS 헤더
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept' always;
}
# 정적 파일 캐싱 비활성화 (개발용)
location ~* \.(js|css|map)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
etag off;
}
}
# HTTPS 서버
server {
listen 443 ssl http2;
server_name _;
# SSL 인증서 설정
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# SSL 보안 설정
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# React Router를 위한 설정
location / {
try_files $uri $uri/ /index.html;
# index.html 캐시 방지 + ETag/Last-Modified 제거 (항상 최신 버전 제공)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
etag off;
if_modified_since off;
add_header Last-Modified "";
}
}
# AI Agent 프록시 (LangGraph 기반)
location /api/agent/ {
proxy_pass http://ai-agent:8000/; # AI Agent 서비스
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_buffering off; # 스트리밍 응답을 위해 버퍼링 비활성화
# CORS 헤더 추가
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# OPTIONS 요청 처리 (Preflight)
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# WebSocket 프록시 (레거시 /api/ws → 백엔드 /ws 로 전달)
location /api/ws {
proxy_pass http://backend:8082/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 타임아웃 설정 (길게 유지)
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# CORS 헤더
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# SSE 알림 구독 (버퍼링 비활성화 필수)
location /api/notifications/subscribe {
proxy_pass http://backend:8082/api/notifications/subscribe;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
}
# API 프록시 (Spring Boot 백엔드 - REST API)
location /api/ {
proxy_pass http://backend:8082/api/; # Docker 내부 네트워크
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# CORS 헤더 추가
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# OPTIONS 요청 처리 (Preflight)
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# WebSocket 프록시 (STOMP /ws, Native /ws/realtime — URI 전체 전달)
location /ws {
proxy_pass http://backend:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 타임아웃 설정 (길게 유지)
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# CORS 헤더
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# Market Data WebSocket 프록시 (실시간 차트용 - Python Broadcast Server)
location /market-data/ {
proxy_pass http://market-data-broadcast:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 타임아웃 설정 (길게 유지)
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# CORS 헤더
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
# 정적 파일 캐싱 (개발 환경에서는 캐시 비활성화)
location ~* \.(js|css|map)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
add_header X-Content-Type-Options "nosniff";
# ETag 비활성화 (캐시 우회)
etag off;
if_modified_since off;
}
# 이미지 및 폰트는 짧은 캐시
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1h;
add_header Cache-Control "public";
}
}
+19817
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
{
"name": "golden-analysis-frontend",
"version": "1.0.3",
"private": true,
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.0",
"@mui/lab": "^5.0.0-alpha.156",
"@mui/material": "^5.15.0",
"@mui/x-date-pickers": "^6.18.3",
"@stomp/stompjs": "^7.0.0",
"@types/node": "^20.10.0",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.17",
"axios": "^1.6.2",
"chart.js": "^4.5.1",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"lightweight-charts": "^4.2.0",
"lightweight-charts-indicators": "file:libs/lightweight-charts-indicators",
"lightweight-charts-v5": "npm:lightweight-charts@^5.2.0",
"lightweight-charts-indicators-v5": "npm:lightweight-charts-indicators@^0.4.1",
"oakscriptjs": "^0.2.8",
"react": "^18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "^18.2.0",
"react-draggable": "^4.5.0",
"react-gauge-component": "^2.0.29",
"react-grid-layout": "^2.2.2",
"react-router-dom": "^6.20.0",
"react-scripts": "5.0.1",
"recharts": "^2.10.3",
"sockjs-client": "^1.6.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"zustand": "^5.0.12"
},
"scripts": {
"start": "PORT=3100 react-scripts start",
"start:win": "set PORT=3100 && react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react-grid-layout": "^1.3.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#1976d2"/>
<text x="16" y="22" font-size="18" fill="white" text-anchor="middle" font-family="sans-serif">G</text>
</svg>

After

Width:  |  Height:  |  Size: 221 B

@@ -0,0 +1,63 @@
/**
* Firebase Cloud Messaging Service Worker
* - 브라우저 종료 시에도 푸시 알림 수신
* - REACT_APP_FIREBASE_* 환경 변수로 설정 (빌드 시 주입)
*
* 설정 방법:
* 1. Firebase Console에서 프로젝트 생성
* 2. Cloud Messaging 활성화, VAPID 키 발급
* 3. .env에 REACT_APP_FIREBASE_VAPID_KEY 등 설정
* 4. 이 파일이 public/에 있으면 빌드 시 복사됨
*/
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
// Firebase 설정 - 빌드 시 환경 변수로 치환되거나, 수동 설정
const firebaseConfig = {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_PROJECT.firebaseapp.com',
projectId: 'YOUR_PROJECT_ID',
storageBucket: 'YOUR_PROJECT.appspot.com',
messagingSenderId: 'YOUR_SENDER_ID',
appId: 'YOUR_APP_ID',
};
if (firebaseConfig.apiKey && firebaseConfig.apiKey !== 'YOUR_API_KEY') {
firebase.initializeApp(firebaseConfig);
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
console.log('[firebase-messaging-sw] Received background message:', payload);
const notificationTitle = payload.notification?.title || '골든 애널리시스';
const notificationOptions = {
body: payload.notification?.body || payload.data?.message || '새 알림이 도착했습니다.',
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: 'golden-analysis-fcm',
data: payload.data || {},
requireInteraction: false,
};
return self.registration.showNotification(notificationTitle, notificationOptions);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const data = event.notification.data || {};
const url = data.page || '/#/alerts';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(self.location.origin + (url.startsWith('/') ? url : '/' + url));
}
})
);
});
}
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1976d2" />
<meta name="description" content="Golden Analysis - 투자전략 성능 분석 프로그램" />
<!-- 캐시 무효화 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- 빌드 버전 (캐시 식별용) -->
<meta name="build-time" content="%REACT_APP_BUILD_TIME%" />
<meta name="cache-bust" content="%REACT_APP_CACHE_BUST%" />
<title>Golden Analysis</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- ✅ Service Worker 제거 (개발 중 캐시 문제 방지) -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
for (let registration of registrations) {
registration.unregister();
console.log('🗑️ Service Worker 제거됨:', registration);
}
});
}
// 빌드 버전 콘솔 출력
const buildTime = document.querySelector('meta[name="build-time"]')?.getAttribute('content');
const cacheBust = document.querySelector('meta[name="cache-bust"]')?.getAttribute('content');
console.log('🔨 빌드 시간:', buildTime || 'unknown');
console.log('🔨 캐시 버스트:', cacheBust || 'unknown');
</script>
</body>
</html>
+242
View File
@@ -0,0 +1,242 @@
/**
* 보조지표 계산 Web Worker
* - 메인 스레드를 차단하지 않고 지표 계산
* - 복잡한 계산(MA, RSI, MACD 등)을 백그라운드에서 처리
*/
self.addEventListener('message', (event) => {
const { type, data } = event.data;
try {
switch (type) {
case 'CALCULATE_MA':
const maResult = calculateMA(data.candles, data.periods);
self.postMessage({ type: 'MA_RESULT', data: maResult });
break;
case 'CALCULATE_RSI':
const rsiResult = calculateRSI(data.candles, data.period);
self.postMessage({ type: 'RSI_RESULT', data: rsiResult });
break;
case 'CALCULATE_MACD':
const macdResult = calculateMACD(data.candles, data.fastPeriod, data.slowPeriod, data.signalPeriod);
self.postMessage({ type: 'MACD_RESULT', data: macdResult });
break;
case 'CALCULATE_STOCHASTIC':
const stochResult = calculateStochastic(data.candles, data.kPeriod, data.dPeriod);
self.postMessage({ type: 'STOCHASTIC_RESULT', data: stochResult });
break;
case 'CALCULATE_ALL':
const allResults = calculateAllIndicators(data.candles, data.config);
self.postMessage({ type: 'ALL_RESULT', data: allResults });
break;
default:
self.postMessage({ type: 'ERROR', error: 'Unknown command' });
}
} catch (error) {
self.postMessage({ type: 'ERROR', error: error.message });
}
});
/**
* 이동평균선 계산
*/
function calculateMA(candles, periods) {
const results = {};
periods.forEach(period => {
const ma = [];
for (let i = 0; i < candles.length; i++) {
if (i < period - 1) {
ma.push(null);
} else {
let sum = 0;
for (let j = 0; j < period; j++) {
sum += candles[i - j].close;
}
ma.push(sum / period);
}
}
results[`ma${period}`] = ma;
});
return results;
}
/**
* RSI 계산
*/
function calculateRSI(candles, period = 14) {
const rsi = [];
const gains = [];
const losses = [];
for (let i = 0; i < candles.length; i++) {
if (i === 0) {
rsi.push(null);
continue;
}
const change = candles[i].close - candles[i - 1].close;
gains.push(change > 0 ? change : 0);
losses.push(change < 0 ? Math.abs(change) : 0);
if (i < period) {
rsi.push(null);
continue;
}
const avgGain = gains.slice(-period).reduce((a, b) => a + b, 0) / period;
const avgLoss = losses.slice(-period).reduce((a, b) => a + b, 0) / period;
if (avgLoss === 0) {
rsi.push(100);
} else {
const rs = avgGain / avgLoss;
rsi.push(100 - (100 / (1 + rs)));
}
}
return rsi;
}
/**
* MACD 계산
*/
function calculateMACD(candles, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
// EMA 계산 헬퍼
const calculateEMA = (values, period) => {
const ema = [];
const multiplier = 2 / (period + 1);
for (let i = 0; i < values.length; i++) {
if (i < period - 1) {
ema.push(null);
} else if (i === period - 1) {
const sum = values.slice(0, period).reduce((a, b) => a + b, 0);
ema.push(sum / period);
} else {
const value = (values[i] - ema[i - 1]) * multiplier + ema[i - 1];
ema.push(value);
}
}
return ema;
};
const closes = candles.map(c => c.close);
const fastEMA = calculateEMA(closes, fastPeriod);
const slowEMA = calculateEMA(closes, slowPeriod);
const macdLine = [];
for (let i = 0; i < candles.length; i++) {
if (fastEMA[i] === null || slowEMA[i] === null) {
macdLine.push(null);
} else {
macdLine.push(fastEMA[i] - slowEMA[i]);
}
}
const signalLine = calculateEMA(
macdLine.map(v => v === null ? 0 : v),
signalPeriod
);
const histogram = [];
for (let i = 0; i < candles.length; i++) {
if (macdLine[i] === null || signalLine[i] === null) {
histogram.push(null);
} else {
histogram.push(macdLine[i] - signalLine[i]);
}
}
return { macdLine, signalLine, histogram };
}
/**
* Stochastic 계산
*/
function calculateStochastic(candles, kPeriod = 14, dPeriod = 3) {
const stochK = [];
for (let i = 0; i < candles.length; i++) {
if (i < kPeriod - 1) {
stochK.push(null);
continue;
}
const slice = candles.slice(i - kPeriod + 1, i + 1);
const highest = Math.max(...slice.map(c => c.high));
const lowest = Math.min(...slice.map(c => c.low));
const current = candles[i].close;
if (highest === lowest) {
stochK.push(50);
} else {
stochK.push(((current - lowest) / (highest - lowest)) * 100);
}
}
// %D 계산 (K의 이동평균)
const stochD = [];
for (let i = 0; i < stochK.length; i++) {
if (i < dPeriod - 1 || stochK[i] === null) {
stochD.push(null);
} else {
const sum = stochK.slice(i - dPeriod + 1, i + 1).reduce((a, b) => a + (b || 0), 0);
stochD.push(sum / dPeriod);
}
}
return { stochK, stochD };
}
/**
* 모든 지표 한 번에 계산
*/
function calculateAllIndicators(candles, config) {
const results = {};
// MA
if (config.maPeriods && config.maPeriods.length > 0) {
Object.assign(results, calculateMA(candles, config.maPeriods));
}
// RSI
if (config.rsi) {
results.rsi = calculateRSI(candles, config.rsiPeriod || 14);
}
// MACD
if (config.macd) {
const macd = calculateMACD(
candles,
config.macdFastPeriod || 12,
config.macdSlowPeriod || 26,
config.macdSignalPeriod || 9
);
results.macdLine = macd.macdLine;
results.macdSignal = macd.signalLine;
results.macdHistogram = macd.histogram;
}
// Stochastic
if (config.stochastic) {
const stoch = calculateStochastic(
candles,
config.stochasticKPeriod || 14,
config.stochasticDPeriod || 3
);
results.stochasticK = stoch.stochK;
results.stochasticD = stoch.stochD;
}
return results;
}
console.log('📊 Indicator Worker initialized');
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Connection Test</title>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7/bundles/stomp.umd.min.js"></script>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
#status { padding: 10px; margin: 10px 0; border-radius: 5px; }
.connected { background: #4caf50; color: white; }
.disconnected { background: #f44336; color: white; }
.connecting { background: #ff9800; color: white; }
#messages { border: 1px solid #ccc; padding: 10px; height: 400px; overflow-y: auto; }
.message { padding: 5px; border-bottom: 1px solid #eee; }
</style>
</head>
<body>
<h1>🔌 WebSocket 연결 테스트</h1>
<div id="status" class="connecting">연결 중...</div>
<div>
<strong>WebSocket URL:</strong> <span id="ws-url"></span>
</div>
<div>
<strong>구독 토픽:</strong> /topic/candles/KRW-BTC/1m
</div>
<hr>
<h2>📨 수신 메시지</h2>
<div id="messages"></div>
<script>
// 현재 페이지의 프로토콜에 맞게 URL 생성
const protocol = window.location.protocol; // 'http:' or 'https:'
const host = window.location.host;
const WS_URL = `${protocol}//${host}/ws`;
document.getElementById('ws-url').textContent = WS_URL;
console.log('🔗 Connecting to:', WS_URL);
const client = new StompJs.Client({
webSocketFactory: () => new SockJS(WS_URL),
debug: (str) => {
console.log('[STOMP Debug]', str);
},
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConnect = (frame) => {
console.log('✅ Connected:', frame);
document.getElementById('status').className = 'connected';
document.getElementById('status').textContent = '✅ 연결됨 - ' + new Date().toLocaleTimeString();
// KRW-BTC 1분봉 구독
const subscription = client.subscribe('/topic/candles/KRW-BTC/1m', (message) => {
console.log('📨 Message received:', message.body);
try {
const data = JSON.parse(message.body);
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `
<strong>[${new Date().toLocaleTimeString()}]</strong>
${data.market} ${data.interval}:
${data.candles.length} candle(s),
type: ${data.type},
price: ${data.candles[0]?.close || 'N/A'}
`;
document.getElementById('messages').prepend(div);
} catch (e) {
console.error('Failed to parse message:', e);
}
});
console.log('✅ Subscribed to /topic/candles/KRW-BTC/1m');
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `<strong>[${new Date().toLocaleTimeString()}]</strong> ✅ 구독 완료: /topic/candles/KRW-BTC/1m`;
document.getElementById('messages').prepend(div);
};
client.onDisconnect = () => {
console.log('🔌 Disconnected');
document.getElementById('status').className = 'disconnected';
document.getElementById('status').textContent = '🔌 연결 끊김 - ' + new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `<strong>[${new Date().toLocaleTimeString()}]</strong> 🔌 연결 끊김`;
document.getElementById('messages').prepend(div);
};
client.onStompError = (frame) => {
console.error('❌ STOMP Error:', frame);
document.getElementById('status').className = 'disconnected';
document.getElementById('status').textContent = '❌ 오류: ' + (frame.headers.message || 'Unknown error');
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `<strong>[${new Date().toLocaleTimeString()}]</strong> ❌ 오류: ${frame.headers.message || 'Unknown'}`;
document.getElementById('messages').prepend(div);
};
client.onWebSocketError = (event) => {
console.error('❌ WebSocket Error:', event);
document.getElementById('status').className = 'disconnected';
document.getElementById('status').textContent = '❌ WebSocket 오류';
};
// 연결 시작
console.log('🚀 Activating STOMP client...');
client.activate();
</script>
</body>
</html>
@@ -0,0 +1,603 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📊 투자전략 백테스트 UI 목업 - 전체 목록</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
color: #fff;
padding: 40px 20px;
}
.container { max-width: 1600px; margin: 0 auto; }
/* 헤더 */
.header { text-align: center; margin-bottom: 60px; }
.header h1 {
font-size: 42px;
font-weight: 700;
margin-bottom: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header p {
font-size: 18px;
color: #9ca3af;
max-width: 800px;
margin: 0 auto 24px;
line-height: 1.6;
}
.stats {
display: flex;
justify-content: center;
gap: 40px;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #a78bfa;
}
.stat-label {
font-size: 14px;
color: #6b7280;
}
/* 섹션 */
.section { margin-bottom: 60px; }
.section-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.section-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(99, 102, 241, 0.3));
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.section-info h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.section-info p {
font-size: 14px;
color: #6b7280;
}
/* 카드 그리드 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
/* 카드 */
.card {
background: rgba(255,255,255,0.05);
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
transition: all 0.3s;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
border-color: rgba(139, 92, 246, 0.5);
}
.card-preview {
height: 180px;
position: relative;
overflow: hidden;
background: #1a1a2e;
}
.card-preview iframe {
width: 300%;
height: 300%;
border: none;
transform: scale(0.333);
transform-origin: top left;
pointer-events: none;
}
.card-preview-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 40%, rgba(15, 15, 35, 0.95) 100%);
}
.card-body { padding: 20px; }
.card-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-bottom: 10px;
}
.card-badge.pc { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
.card-badge.tablet { background: rgba(139, 92, 246, 0.2); color: #a78bfa; }
.card-badge.mobile { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
.card-title {
font-size: 17px;
font-weight: 600;
margin-bottom: 8px;
}
.card-desc {
font-size: 13px;
color: #9ca3af;
line-height: 1.5;
margin-bottom: 16px;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.tag {
padding: 4px 10px;
background: rgba(255,255,255,0.08);
border-radius: 6px;
font-size: 11px;
color: #d1d5db;
}
.card-link {
display: block;
width: 100%;
padding: 12px;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.2));
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 10px;
color: #fff;
text-decoration: none;
text-align: center;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.card-link:hover {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
border-color: transparent;
}
/* 가이드 */
.guide {
background: rgba(255,255,255,0.03);
border-radius: 24px;
padding: 40px;
border: 1px solid rgba(255,255,255,0.08);
margin-top: 40px;
}
.guide h2 {
font-size: 24px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.guide-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.guide-item {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 24px;
}
.guide-item-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.guide-item-desc {
font-size: 14px;
color: #9ca3af;
line-height: 1.6;
}
.footer {
text-align: center;
margin-top: 60px;
padding-top: 30px;
border-top: 1px solid rgba(255,255,255,0.1);
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>📊 투자전략 백테스트 UI 목업</h1>
<p>
종목, 투자전략, 시간봉을 선택하여 실제 투자를 시뮬레이션하고<br>
매수/매도 시점, 수익률, 예측 정확도를 확인하는 15가지 UI 디자인
</p>
<div class="stats">
<div class="stat">
<div class="stat-value">5</div>
<div class="stat-label">PC 디자인</div>
</div>
<div class="stat">
<div class="stat-value">5</div>
<div class="stat-label">태블릿 디자인</div>
</div>
<div class="stat">
<div class="stat-value">5</div>
<div class="stat-label">모바일 디자인</div>
</div>
</div>
</header>
<!-- PC 섹션 -->
<section class="section">
<div class="section-header">
<div class="section-icon">🖥️</div>
<div class="section-info">
<h2>PC (데스크톱)</h2>
<p>1920px 이상 · 넓은 화면에 최적화된 대시보드 스타일</p>
</div>
</div>
<div class="card-grid">
<div class="card">
<div class="card-preview">
<iframe src="pc/pc-01-trading-terminal.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge pc">PC-01</span>
<h3 class="card-title">트레이딩 터미널</h3>
<p class="card-desc">3단 레이아웃의 트레이딩뷰 스타일. 좌측 종목리스트, 중앙 대형 차트, 우측 성과 패널</p>
<div class="card-tags">
<span class="tag">다크테마</span>
<span class="tag">3단레이아웃</span>
<span class="tag">실시간스타일</span>
</div>
<a href="pc/pc-01-trading-terminal.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="pc/pc-02-analytics-report.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge pc">PC-02</span>
<h3 class="card-title">분석 리포트</h3>
<p class="card-desc">라이트 테마의 비즈니스 리포트 스타일. 깔끔한 카드와 테이블 중심</p>
<div class="card-tags">
<span class="tag">라이트테마</span>
<span class="tag">리포트형</span>
<span class="tag">비즈니스</span>
</div>
<a href="pc/pc-02-analytics-report.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="pc/pc-03-minimal-clean.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge pc">PC-03</span>
<h3 class="card-title">미니멀 클린</h3>
<p class="card-desc">화이트 배경의 미니멀 디자인. 아이콘 사이드바와 넓은 콘텐츠 영역</p>
<div class="card-tags">
<span class="tag">미니멀</span>
<span class="tag">화이트</span>
<span class="tag">모던</span>
</div>
<a href="pc/pc-03-minimal-clean.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="pc/pc-04-dark-pro.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge pc">PC-04</span>
<h3 class="card-title">다크 프로페셔널</h3>
<p class="card-desc">퍼플 액센트의 프로페셔널 다크 테마. 그라데이션과 카드 중심</p>
<div class="card-tags">
<span class="tag">다크</span>
<span class="tag">퍼플</span>
<span class="tag">그라데이션</span>
</div>
<a href="pc/pc-04-dark-pro.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="pc/pc-05-splitview.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge pc">PC-05</span>
<h3 class="card-title">스플릿뷰</h3>
<p class="card-desc">좌우 50:50 분할 레이아웃. 설정/결과와 차트 영역 분리</p>
<div class="card-tags">
<span class="tag">2분할</span>
<span class="tag">네이비</span>
<span class="tag">정보분리</span>
</div>
<a href="pc/pc-05-splitview.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
</div>
</section>
<!-- 태블릿 섹션 -->
<section class="section">
<div class="section-header">
<div class="section-icon">📱</div>
<div class="section-info">
<h2>태블릿 (Tablet)</h2>
<p>768px - 1024px · 가로/세로 모드에 최적화</p>
</div>
</div>
<div class="card-grid">
<div class="card">
<div class="card-preview">
<iframe src="tablet/tablet-01-grid-dashboard.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge tablet">Tablet-01</span>
<h3 class="card-title">그리드 대시보드</h3>
<p class="card-desc">4컬럼 그리드 기반 대시보드. 메트릭 카드와 차트가 조화롭게 배치</p>
<div class="card-tags">
<span class="tag">그리드</span>
<span class="tag">다크</span>
<span class="tag">카드</span>
</div>
<a href="tablet/tablet-01-grid-dashboard.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="tablet/tablet-02-card-stack.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge tablet">Tablet-02</span>
<h3 class="card-title">카드 스택</h3>
<p class="card-desc">세로 스크롤 카드 스택 구조. 라이트 테마의 깔끔한 디자인</p>
<div class="card-tags">
<span class="tag">카드스택</span>
<span class="tag">라이트</span>
<span class="tag">스크롤</span>
</div>
<a href="tablet/tablet-02-card-stack.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="tablet/tablet-03-split-horizontal.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge tablet">Tablet-03</span>
<h3 class="card-title">가로 분할</h3>
<p class="card-desc">상단 설정/결과, 하단 차트/정보로 가로 분할된 레이아웃</p>
<div class="card-tags">
<span class="tag">가로분할</span>
<span class="tag">다크</span>
<span class="tag">컴팩트</span>
</div>
<a href="tablet/tablet-03-split-horizontal.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="tablet/tablet-04-slide-panel.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge tablet">Tablet-04</span>
<h3 class="card-title">슬라이드 패널</h3>
<p class="card-desc">탭 네비게이션과 슬라이드 가능한 메트릭 카드</p>
<div class="card-tags">
<span class="tag">탭</span>
<span class="tag">슬라이드</span>
<span class="tag">퍼플</span>
</div>
<a href="tablet/tablet-04-slide-panel.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="tablet/tablet-05-compact-pro.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge tablet">Tablet-05</span>
<h3 class="card-title">컴팩트 프로</h3>
<p class="card-desc">화이트 배경의 컴팩트한 프로페셔널 디자인</p>
<div class="card-tags">
<span class="tag">컴팩트</span>
<span class="tag">화이트</span>
<span class="tag">프로</span>
</div>
<a href="tablet/tablet-05-compact-pro.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
</div>
</section>
<!-- 모바일 섹션 -->
<section class="section">
<div class="section-header">
<div class="section-icon">📲</div>
<div class="section-info">
<h2>모바일 (Mobile)</h2>
<p>375px - 414px · 터치 인터페이스에 최적화</p>
</div>
</div>
<div class="card-grid">
<div class="card">
<div class="card-preview">
<iframe src="mobile/mobile-01-app-style.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge mobile">Mobile-01</span>
<h3 class="card-title">앱 스타일</h3>
<p class="card-desc">iOS/Android 앱 스타일. 바텀 네비게이션과 플로팅 버튼</p>
<div class="card-tags">
<span class="tag">앱스타일</span>
<span class="tag">다크</span>
<span class="tag">바텀네비</span>
</div>
<a href="mobile/mobile-01-app-style.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="mobile/mobile-02-card-scroll.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge mobile">Mobile-02</span>
<h3 class="card-title">카드 스크롤</h3>
<p class="card-desc">라이트 테마의 카드 기반 스크롤 UI. 가로 스크롤 메트릭</p>
<div class="card-tags">
<span class="tag">카드</span>
<span class="tag">라이트</span>
<span class="tag">스크롤</span>
</div>
<a href="mobile/mobile-02-card-scroll.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="mobile/mobile-03-fullscreen-chart.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge mobile">Mobile-03</span>
<h3 class="card-title">풀스크린 차트</h3>
<p class="card-desc">차트가 화면 전체를 차지. 오버레이로 정보 표시</p>
<div class="card-tags">
<span class="tag">풀스크린</span>
<span class="tag">오버레이</span>
<span class="tag">몰입형</span>
</div>
<a href="mobile/mobile-03-fullscreen-chart.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="mobile/mobile-04-bottom-sheet.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge mobile">Mobile-04</span>
<h3 class="card-title">바텀시트</h3>
<p class="card-desc">상단 차트 + 하단 바텀시트 구조. 드래그로 확장 가능</p>
<div class="card-tags">
<span class="tag">바텀시트</span>
<span class="tag">드래그</span>
<span class="tag">블루</span>
</div>
<a href="mobile/mobile-04-bottom-sheet.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
<div class="card">
<div class="card-preview">
<iframe src="mobile/mobile-05-minimal-dark.html"></iframe>
<div class="card-preview-overlay"></div>
</div>
<div class="card-body">
<span class="card-badge mobile">Mobile-05</span>
<h3 class="card-title">미니멀 다크</h3>
<p class="card-desc">대형 수익률 표시의 미니멀 디자인. 심플하고 직관적</p>
<div class="card-tags">
<span class="tag">미니멀</span>
<span class="tag">다크</span>
<span class="tag">심플</span>
</div>
<a href="mobile/mobile-05-minimal-dark.html" class="card-link" target="_blank">🔍 미리보기</a>
</div>
</div>
</div>
</section>
<!-- 가이드 -->
<div class="guide">
<h2>💡 UI 선택 가이드</h2>
<div class="guide-grid">
<div class="guide-item">
<h3 class="guide-item-title">🖥️ 데스크톱 중심</h3>
<p class="guide-item-desc">
<strong>PC-01 트레이딩 터미널</strong> 또는 <strong>PC-04 다크 프로</strong>를 추천합니다.
넓은 화면에서 차트와 정보를 동시에 확인하기 좋습니다.
</p>
</div>
<div class="guide-item">
<h3 class="guide-item-title">📊 상세 분석</h3>
<p class="guide-item-desc">
<strong>PC-02 분석 리포트</strong>를 추천합니다.
테이블과 상세 데이터 확인에 최적화되어 있습니다.
</p>
</div>
<div class="guide-item">
<h3 class="guide-item-title">📱 모바일 중심</h3>
<p class="guide-item-desc">
<strong>Mobile-01 앱 스타일</strong> 또는 <strong>Mobile-03 풀스크린 차트</strong>를 추천합니다.
터치에 최적화된 인터페이스입니다.
</p>
</div>
<div class="guide-item">
<h3 class="guide-item-title">🎨 라이트 테마 선호</h3>
<p class="guide-item-desc">
<strong>PC-03 미니멀 클린</strong>, <strong>Tablet-02 카드 스택</strong>,
<strong>Mobile-02 카드 스크롤</strong>을 추천합니다.
</p>
</div>
</div>
</div>
<footer class="footer">
<p>이 목업은 실제 구현 전 UI 디자인 검토를 위한 것입니다.</p>
<p>선호하는 스타일을 선택하시면 해당 디자인으로 구현해 드립니다.</p>
</footer>
</div>
</body>
</html>
@@ -0,0 +1,265 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=390">
<title>Mobile-01 앱 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #0f0f23; color: #fff; width: 390px; min-height: 844px; padding-bottom: 80px; }
.status-bar { height: 44px; background: #0f0f23; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; font-size: 14px; font-weight: 600; }
.header { padding: 12px 20px 16px; display: flex; justify-content: space-between; align-items: center; }
.header-title { font-size: 22px; font-weight: 700; }
.version-badge { background: rgba(139, 92, 246, 0.2); color: #a78bfa; padding: 4px 10px; border-radius: 10px; font-size: 10px; }
.container { padding: 0 16px; }
/* 종목 카드 */
.symbol-card { background: linear-gradient(135deg, #1e1e3f 0%, #0f0f23 100%); border-radius: 20px; padding: 20px; margin-bottom: 16px; border: 1px solid rgba(255,255,255,0.1); }
.symbol-row { display: flex; justify-content: space-between; align-items: center; }
.symbol-icon { width: 48px; height: 48px; background: linear-gradient(135deg, #f7931a, #ffab00); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 700; }
.symbol-info { flex: 1; margin-left: 14px; }
.symbol-name { font-size: 18px; font-weight: 700; }
.symbol-market { font-size: 12px; color: #9ca3af; }
.symbol-price { text-align: right; }
.price-value { font-size: 18px; font-weight: 700; }
.price-change { font-size: 13px; color: #22c55e; }
.change-btn { margin-top: 12px; padding: 10px; background: rgba(255,255,255,0.1); border: none; border-radius: 10px; color: #fff; font-size: 13px; width: 100%; cursor: pointer; }
/* 설정 칩 */
.chips-row { display: flex; gap: 8px; margin-bottom: 12px; overflow-x: auto; padding-bottom: 4px; }
.chip { flex-shrink: 0; padding: 10px 16px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; font-size: 13px; color: #9ca3af; }
.chip.active { background: rgba(139, 92, 246, 0.2); border-color: #8b5cf6; color: #a78bfa; }
/* 수익률 카드 */
.return-card { background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(16, 185, 129, 0.05)); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 20px; padding: 24px; margin-bottom: 16px; text-align: center; }
.return-label { font-size: 13px; color: #9ca3af; margin-bottom: 8px; }
.return-value { font-size: 48px; font-weight: 700; color: #22c55e; }
.return-amount { font-size: 14px; color: #22c55e; margin-top: 4px; }
/* 메트릭 그리드 */
.metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
.metric-card { background: rgba(255,255,255,0.05); border-radius: 16px; padding: 16px; text-align: center; }
.metric-icon { font-size: 24px; margin-bottom: 8px; }
.metric-value { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.metric-value.green { color: #22c55e; }
.metric-value.red { color: #ef4444; }
.metric-label { font-size: 11px; color: #71717a; }
/* 미니 차트 */
.chart-card { background: rgba(255,255,255,0.05); border-radius: 20px; padding: 16px; margin-bottom: 16px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.card-title { font-size: 14px; font-weight: 600; }
.chart-area { height: 160px; background: rgba(0,0,0,0.2); border-radius: 12px; position: relative; overflow: hidden; }
.chart-candles { position: absolute; inset: 16px 12px 30px 12px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 8px; border-radius: 2px; position: relative; }
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal { position: absolute; width: 16px; height: 16px; border-radius: 50%; font-size: 8px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.signal.buy { bottom: -20px; left: 50%; transform: translateX(-50%); background: rgba(34, 197, 94, 0.3); color: #22c55e; border: 1.5px solid #22c55e; }
.signal.sell { top: -20px; left: 50%; transform: translateX(-50%); background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 1.5px solid #ef4444; }
/* 정확도 */
.accuracy-card { background: rgba(255,255,255,0.05); border-radius: 20px; padding: 20px; margin-bottom: 16px; }
.accuracy-row { display: flex; align-items: center; gap: 16px; }
.accuracy-ring { width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(#8b5cf6 0deg 252deg, rgba(255,255,255,0.1) 252deg 360deg); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.accuracy-inner { width: 60px; height: 60px; background: #0f0f23; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.accuracy-pct { font-size: 20px; font-weight: 700; color: #8b5cf6; }
.accuracy-text { font-size: 8px; color: #71717a; }
.accuracy-bars { flex: 1; }
.acc-bar { margin-bottom: 10px; }
.acc-bar-header { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; }
.acc-bar-bg { height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; }
.acc-bar-fill { height: 100%; border-radius: 3px; }
.acc-bar-fill.green { background: #22c55e; }
.acc-bar-fill.purple { background: #8b5cf6; }
/* 거래 */
.trades-card { background: rgba(255,255,255,0.05); border-radius: 20px; padding: 16px; margin-bottom: 16px; }
.trade-item { display: flex; align-items: center; padding: 12px; background: rgba(255,255,255,0.03); border-radius: 12px; margin-bottom: 8px; }
.trade-badge { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 12px; }
.trade-badge.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 13px; font-weight: 500; }
.trade-time { font-size: 11px; color: #6b7280; }
.trade-result { text-align: right; }
.trade-price { font-size: 12px; }
.trade-pnl { font-size: 12px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
/* 실행 버튼 */
.run-btn { position: fixed; bottom: 100px; left: 16px; right: 16px; width: calc(100% - 32px); max-width: 358px; padding: 18px; background: linear-gradient(135deg, #8b5cf6, #6366f1); border: none; border-radius: 16px; color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; box-shadow: 0 8px 30px rgba(139, 92, 246, 0.4); }
/* 바텀 네비게이션 */
.bottom-nav { position: fixed; bottom: 0; left: 0; width: 390px; background: rgba(15, 15, 35, 0.95); backdrop-filter: blur(20px); border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: space-around; padding: 10px 0 30px; }
.nav-item { display: flex; flex-direction: column; align-items: center; gap: 4px; color: #6b7280; font-size: 10px; }
.nav-item.active { color: #8b5cf6; }
.nav-icon { font-size: 22px; }
</style>
</head>
<body>
<div class="status-bar">
<span>9:41</span>
<span>📶 🔋</span>
</div>
<header class="header">
<span class="header-title">백테스트</span>
<span class="version-badge">Mobile-01</span>
</header>
<div class="container">
<div class="symbol-card">
<div class="symbol-row">
<div class="symbol-icon">₿</div>
<div class="symbol-info">
<div class="symbol-name">BTC/KRW</div>
<div class="symbol-market">비트코인 · 업비트</div>
</div>
<div class="symbol-price">
<div class="price-value">₩58,450,000</div>
<div class="price-change">+2.34%</div>
</div>
</div>
<button class="change-btn">종목 변경 ▼</button>
</div>
<div class="chips-row">
<div class="chip active">MACD+RSI</div>
<div class="chip">볼린저밴드</div>
<div class="chip">이평크로스</div>
</div>
<div class="chips-row">
<div class="chip">15분</div>
<div class="chip active">1시간</div>
<div class="chip">4시간</div>
<div class="chip">일봉</div>
</div>
<div class="return-card">
<div class="return-label">💰 총 수익률</div>
<div class="return-value">+23.5%</div>
<div class="return-amount">₩10,000,000 → ₩12,350,000</div>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-icon">🎯</div>
<div class="metric-value green">68%</div>
<div class="metric-label">승률</div>
</div>
<div class="metric-card">
<div class="metric-icon">🔄</div>
<div class="metric-value">25회</div>
<div class="metric-label">총 거래</div>
</div>
<div class="metric-card">
<div class="metric-icon">⚡</div>
<div class="metric-value green">+₩94K</div>
<div class="metric-label">평균 수익</div>
</div>
<div class="metric-card">
<div class="metric-icon">📉</div>
<div class="metric-value red">-8.2%</div>
<div class="metric-label">최대 낙폭</div>
</div>
</div>
<div class="chart-card">
<div class="card-header">
<span class="card-title">📈 차트</span>
<span style="font-size: 11px; color: #8b5cf6;">전체보기 →</span>
</div>
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 30px;"></div>
<div class="candle up" style="height: 45px;"></div>
<div class="candle down" style="height: 25px;"></div>
<div class="candle down" style="height: 40px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle down" style="height: 35px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 50px;"></div>
<div class="candle up" style="height: 40px;"></div>
<div class="candle down" style="height: 25px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 45px;"></div>
</div>
</div>
</div>
<div class="accuracy-card">
<div class="card-header" style="margin-bottom: 12px;">
<span class="card-title">🎯 전략 정확도</span>
</div>
<div class="accuracy-row">
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">정확도</div>
</div>
</div>
<div class="accuracy-bars">
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #9ca3af;">매수</span>
<span style="color: #22c55e;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #9ca3af;">매도</span>
<span style="color: #8b5cf6;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill purple" style="width: 64%;"></div></div>
</div>
</div>
</div>
</div>
<div class="trades-card">
<div class="card-header" style="margin-bottom: 12px;">
<span class="card-title">📋 최근 거래</span>
<span style="font-size: 11px; color: #8b5cf6;">전체보기 →</span>
</div>
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
</div>
</div>
<button class="run-btn">▶ 백테스트 실행</button>
<nav class="bottom-nav">
<div class="nav-item"><span class="nav-icon">🏠</span>홈</div>
<div class="nav-item active"><span class="nav-icon">📊</span>백테스트</div>
<div class="nav-item"><span class="nav-icon">📋</span>거래</div>
<div class="nav-item"><span class="nav-icon">⚙️</span>설정</div>
</nav>
</body>
</html>
@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=375">
<title>Mobile-02 카드 스크롤</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #f8fafc; color: #1e293b; width: 375px; min-height: 812px; padding-bottom: 90px; }
.header { background: #fff; padding: 50px 20px 16px; border-bottom: 1px solid #e2e8f0; }
.header-row { display: flex; justify-content: space-between; align-items: center; }
.header-title { font-size: 24px; font-weight: 700; }
.version-badge { background: #dbeafe; color: #1d4ed8; padding: 4px 10px; border-radius: 10px; font-size: 10px; }
.container { padding: 16px; }
/* 카드 */
.card { background: #fff; border-radius: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; overflow: hidden; }
.card-header { padding: 18px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 15px; font-weight: 600; }
.card-body { padding: 20px; }
/* 설정 */
.config-row { display: flex; gap: 10px; margin-bottom: 12px; }
.config-field { flex: 1; }
.config-label { font-size: 11px; color: #64748b; margin-bottom: 4px; }
.config-input { width: 100%; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; font-size: 13px; }
.run-btn { width: 100%; padding: 16px; background: linear-gradient(135deg, #3b82f6, #2563eb); border: none; border-radius: 12px; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 8px; }
/* 결과 헤더 */
.result-card { background: linear-gradient(135deg, #3b82f6, #2563eb); color: #fff; }
.result-card .card-body { display: flex; justify-content: space-between; align-items: center; }
.result-symbol { font-size: 20px; font-weight: 700; }
.result-meta { font-size: 12px; opacity: 0.9; margin-top: 4px; }
.result-value { font-size: 36px; font-weight: 700; }
.result-amount { font-size: 13px; opacity: 0.9; text-align: right; }
/* 메트릭 스크롤 */
.metrics-scroll { display: flex; gap: 12px; overflow-x: auto; padding: 4px 0 12px; }
.metric-card { flex-shrink: 0; width: 120px; background: #f8fafc; border-radius: 14px; padding: 16px; text-align: center; }
.metric-icon { font-size: 24px; margin-bottom: 8px; }
.metric-value { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.metric-value.green { color: #16a34a; }
.metric-value.red { color: #dc2626; }
.metric-label { font-size: 10px; color: #64748b; }
/* 차트 */
.chart-area { height: 180px; background: #f8fafc; border-radius: 14px; position: relative; overflow: hidden; border: 1px solid #e2e8f0; }
.chart-candles { position: absolute; inset: 20px 12px 35px 12px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 10px; border-radius: 2px; position: relative; }
.candle.up { background: #16a34a; }
.candle.down { background: #dc2626; }
.signal { position: absolute; width: 18px; height: 18px; border-radius: 50%; font-size: 9px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.signal.buy { bottom: -22px; left: 50%; transform: translateX(-50%); background: #dcfce7; color: #16a34a; border: 2px solid #16a34a; }
.signal.sell { top: -22px; left: 50%; transform: translateX(-50%); background: #fee2e2; color: #dc2626; border: 2px solid #dc2626; }
/* 정확도 */
.accuracy-row { display: flex; align-items: center; gap: 16px; }
.accuracy-ring { width: 90px; height: 90px; border-radius: 50%; background: conic-gradient(#16a34a 0deg 252deg, #e2e8f0 252deg 360deg); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.accuracy-inner { width: 70px; height: 70px; background: #fff; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.accuracy-pct { font-size: 22px; font-weight: 700; color: #16a34a; }
.accuracy-text { font-size: 9px; color: #64748b; }
.accuracy-bars { flex: 1; }
.acc-bar { margin-bottom: 10px; }
.acc-bar-header { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; }
.acc-bar-bg { height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; }
.acc-bar-fill { height: 100%; border-radius: 4px; }
.acc-bar-fill.green { background: linear-gradient(90deg, #16a34a, #22c55e); }
.acc-bar-fill.orange { background: linear-gradient(90deg, #ea580c, #f97316); }
/* 거래 */
.trade-item { display: flex; align-items: center; padding: 14px; background: #f8fafc; border-radius: 14px; margin-bottom: 10px; }
.trade-badge { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; margin-right: 14px; }
.trade-badge.buy { background: #dcfce7; color: #16a34a; }
.trade-badge.sell { background: #fee2e2; color: #dc2626; }
.trade-info { flex: 1; }
.trade-type { font-size: 14px; font-weight: 500; }
.trade-time { font-size: 12px; color: #94a3b8; }
.trade-result { text-align: right; }
.trade-price { font-size: 13px; font-weight: 500; }
.trade-pnl { font-size: 13px; font-weight: 600; }
.trade-pnl.profit { color: #16a34a; }
.trade-pnl.loss { color: #dc2626; }
/* 바텀 네비게이션 */
.bottom-nav { position: fixed; bottom: 0; left: 0; width: 375px; background: #fff; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-around; padding: 10px 0 28px; }
.nav-item { display: flex; flex-direction: column; align-items: center; gap: 4px; color: #94a3b8; font-size: 10px; }
.nav-item.active { color: #3b82f6; }
.nav-icon { font-size: 22px; }
</style>
</head>
<body>
<header class="header">
<div class="header-row">
<span class="header-title">백테스트</span>
<span class="version-badge">Mobile-02</span>
</div>
</header>
<div class="container">
<div class="card">
<div class="card-header">
<span class="card-title">⚙️ 설정</span>
</div>
<div class="card-body">
<div class="config-row">
<div class="config-field">
<div class="config-label">종목</div>
<select class="config-input"><option>BTC/KRW</option></select>
</div>
<div class="config-field">
<div class="config-label">전략</div>
<select class="config-input"><option>MACD+RSI</option></select>
</div>
</div>
<div class="config-row">
<div class="config-field">
<div class="config-label">시간봉</div>
<select class="config-input"><option>1시간</option></select>
</div>
<div class="config-field">
<div class="config-label">기간</div>
<select class="config-input"><option>3개월</option></select>
</div>
</div>
<button class="run-btn">▶ 백테스트 실행</button>
</div>
</div>
<div class="card result-card">
<div class="card-body">
<div>
<div class="result-symbol">BTC/KRW</div>
<div class="result-meta">MACD+RSI · 1시간</div>
</div>
<div>
<div class="result-value">+23.5%</div>
<div class="result-amount">₩2,350,000</div>
</div>
</div>
</div>
<div class="metrics-scroll">
<div class="metric-card">
<div class="metric-icon">💰</div>
<div class="metric-value">₩12.35M</div>
<div class="metric-label">최종 자산</div>
</div>
<div class="metric-card">
<div class="metric-icon">🎯</div>
<div class="metric-value green">68%</div>
<div class="metric-label">승률</div>
</div>
<div class="metric-card">
<div class="metric-icon">🔄</div>
<div class="metric-value">25회</div>
<div class="metric-label">총 거래</div>
</div>
<div class="metric-card">
<div class="metric-icon">📉</div>
<div class="metric-value red">-8.2%</div>
<div class="metric-label">MDD</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📈 가격 차트</span>
</div>
<div class="card-body">
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 35px;"></div>
<div class="candle up" style="height: 50px;"></div>
<div class="candle down" style="height: 30px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle up" style="height: 75px;"></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle down" style="height: 40px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 55px;"></div>
<div class="candle up" style="height: 45px;"></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">🎯 정확도</span>
</div>
<div class="card-body">
<div class="accuracy-row">
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">정확도</div>
</div>
</div>
<div class="accuracy-bars">
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #64748b;">매수</span>
<span style="color: #16a34a; font-weight: 600;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #64748b;">매도</span>
<span style="color: #ea580c; font-weight: 600;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill orange" style="width: 64%;"></div></div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📋 거래 내역</span>
<span style="font-size: 12px; color: #3b82f6;">전체 →</span>
</div>
<div class="card-body" style="padding-top: 12px;">
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
</div>
</div>
</div>
<nav class="bottom-nav">
<div class="nav-item"><span class="nav-icon">🏠</span>홈</div>
<div class="nav-item active"><span class="nav-icon">📊</span>백테스트</div>
<div class="nav-item"><span class="nav-icon">📋</span>거래</div>
<div class="nav-item"><span class="nav-icon">⚙️</span>설정</div>
</nav>
</body>
</html>
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=414">
<title>Mobile-03 풀스크린 차트</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #000; color: #fff; width: 414px; height: 896px; overflow: hidden; }
/* 상단 오버레이 */
.top-overlay { position: absolute; top: 0; left: 0; right: 0; z-index: 10; background: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, transparent 100%); padding: 50px 20px 30px; }
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.back-btn { background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; }
.header-title { font-size: 18px; font-weight: 600; }
.version-badge { background: rgba(34, 197, 94, 0.2); color: #22c55e; padding: 4px 10px; border-radius: 10px; font-size: 10px; }
/* 심볼 정보 */
.symbol-info { display: flex; justify-content: space-between; align-items: flex-end; }
.symbol-left h2 { font-size: 24px; font-weight: 700; }
.symbol-left p { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.symbol-right { text-align: right; }
.price { font-size: 28px; font-weight: 700; color: #22c55e; }
.change { font-size: 14px; color: #22c55e; }
/* 전체 화면 차트 */
.fullscreen-chart { position: absolute; inset: 0; background: #000; }
.chart-grid { position: absolute; inset: 0; background: linear-gradient(90deg, rgba(39, 39, 42, 0.2) 1px, transparent 1px), linear-gradient(rgba(39, 39, 42, 0.2) 1px, transparent 1px); background-size: 40px 35px; }
.chart-candles { position: absolute; top: 180px; bottom: 320px; left: 12px; right: 12px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 10px; border-radius: 2px; position: relative; }
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal { position: absolute; width: 22px; height: 22px; border-radius: 50%; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 10px; }
.signal.buy { bottom: -28px; left: 50%; transform: translateX(-50%); background: rgba(34, 197, 94, 0.3); color: #22c55e; border: 2px solid #22c55e; box-shadow: 0 0 15px rgba(34, 197, 94, 0.5); }
.signal.sell { top: -28px; left: 50%; transform: translateX(-50%); background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 2px solid #ef4444; box-shadow: 0 0 15px rgba(239, 68, 68, 0.5); }
/* 하단 오버레이 */
.bottom-overlay { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; background: linear-gradient(0deg, rgba(0,0,0,0.95) 0%, transparent 100%); padding: 40px 20px 40px; }
/* 수익률 */
.return-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.return-box { background: rgba(34, 197, 94, 0.15); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 14px; padding: 16px 20px; flex: 1; margin-right: 12px; }
.return-label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
.return-value { font-size: 32px; font-weight: 700; color: #22c55e; }
.accuracy-box { background: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); border-radius: 14px; padding: 16px 20px; text-align: center; width: 100px; }
.accuracy-value { font-size: 28px; font-weight: 700; color: #8b5cf6; }
.accuracy-label { font-size: 10px; color: #9ca3af; }
/* 메트릭 스크롤 */
.metrics-scroll { display: flex; gap: 10px; overflow-x: auto; margin-bottom: 20px; padding-bottom: 4px; }
.metric-pill { flex-shrink: 0; background: rgba(255,255,255,0.1); border-radius: 12px; padding: 12px 16px; display: flex; align-items: center; gap: 10px; }
.metric-pill-icon { font-size: 18px; }
.metric-pill-value { font-size: 16px; font-weight: 600; }
.metric-pill-value.green { color: #22c55e; }
.metric-pill-value.red { color: #ef4444; }
.metric-pill-label { font-size: 10px; color: #71717a; }
/* 거래 내역 슬라이드 */
.trades-section { }
.section-header { display: flex; justify-content: space-between; margin-bottom: 12px; }
.section-title { font-size: 13px; font-weight: 600; }
.section-link { font-size: 11px; color: #8b5cf6; }
.trades-scroll { display: flex; gap: 10px; overflow-x: auto; }
.trade-card { flex-shrink: 0; background: rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; width: 160px; }
.trade-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.trade-badge { padding: 4px 10px; border-radius: 6px; font-size: 10px; font-weight: 700; }
.trade-badge.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-pnl { font-size: 12px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
.trade-price { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.trade-time { font-size: 10px; color: #6b7280; }
/* 설정 버튼 */
.settings-btn { position: absolute; top: 50px; right: 20px; width: 40px; height: 40px; background: rgba(255,255,255,0.1); border: none; border-radius: 12px; color: #fff; font-size: 18px; cursor: pointer; z-index: 20; }
</style>
</head>
<body>
<div class="fullscreen-chart">
<div class="chart-grid"></div>
<div class="chart-candles">
<div class="candle up" style="height: 40px;"></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle down" style="height: 35px;"></div>
<div class="candle down" style="height: 50px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle up" style="height: 85px;"></div>
<div class="candle up" style="height: 75px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 60px;"></div>
<div class="candle up" style="height: 50px;"></div>
<div class="candle down" style="height: 35px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle down" style="height: 40px;"></div>
<div class="candle up" style="height: 60px;"></div>
</div>
</div>
<div class="top-overlay">
<div class="header-row">
<button class="back-btn">←</button>
<span class="header-title">백테스트</span>
<span class="version-badge">Mobile-03</span>
</div>
<div class="symbol-info">
<div class="symbol-left">
<h2>BTC/KRW</h2>
<p>MACD+RSI · 1H · 3개월</p>
</div>
<div class="symbol-right">
<div class="price">₩58,450,000</div>
<div class="change">+2.34%</div>
</div>
</div>
</div>
<button class="settings-btn">⚙️</button>
<div class="bottom-overlay">
<div class="return-row">
<div class="return-box">
<div class="return-label">💰 총 수익률</div>
<div class="return-value">+23.5%</div>
</div>
<div class="accuracy-box">
<div class="accuracy-value">70%</div>
<div class="accuracy-label">🎯 정확도</div>
</div>
</div>
<div class="metrics-scroll">
<div class="metric-pill">
<span class="metric-pill-icon">💰</span>
<div>
<div class="metric-pill-value">₩12.35M</div>
<div class="metric-pill-label">최종 자산</div>
</div>
</div>
<div class="metric-pill">
<span class="metric-pill-icon">🎯</span>
<div>
<div class="metric-pill-value green">68%</div>
<div class="metric-pill-label">승률</div>
</div>
</div>
<div class="metric-pill">
<span class="metric-pill-icon">🔄</span>
<div>
<div class="metric-pill-value">25회</div>
<div class="metric-pill-label">총 거래</div>
</div>
</div>
<div class="metric-pill">
<span class="metric-pill-icon">📉</span>
<div>
<div class="metric-pill-value red">-8.2%</div>
<div class="metric-pill-label">MDD</div>
</div>
</div>
</div>
<div class="trades-section">
<div class="section-header">
<span class="section-title">📋 최근 거래</span>
<span class="section-link">전체보기 →</span>
</div>
<div class="trades-scroll">
<div class="trade-card">
<div class="trade-header">
<span class="trade-badge sell">SELL</span>
<span class="trade-pnl profit">+5.2%</span>
</div>
<div class="trade-price">₩58,450,000</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-card">
<div class="trade-header">
<span class="trade-badge buy">BUY</span>
<span class="trade-pnl">-</span>
</div>
<div class="trade-price">₩55,520,000</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-card">
<div class="trade-header">
<span class="trade-badge sell">SELL</span>
<span class="trade-pnl loss">-2.1%</span>
</div>
<div class="trade-price">₩54,200,000</div>
<div class="trade-time">03.08 16:00</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=390">
<title>Mobile-04 바텀시트</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #18181b; color: #fafafa; width: 390px; height: 844px; overflow: hidden; position: relative; }
/* 메인 화면 */
.main-screen { height: 100%; display: flex; flex-direction: column; }
/* 헤더 */
.header { padding: 50px 20px 16px; background: #18181b; }
.header-row { display: flex; justify-content: space-between; align-items: center; }
.header-title { font-size: 22px; font-weight: 700; }
.version-badge { background: rgba(59, 130, 246, 0.2); color: #60a5fa; padding: 4px 10px; border-radius: 10px; font-size: 10px; }
/* 종목 선택 */
.symbol-selector { padding: 0 20px 16px; }
.symbol-card { background: linear-gradient(135deg, #1e3a5f 0%, #18181b 100%); border-radius: 18px; padding: 18px; display: flex; align-items: center; border: 1px solid rgba(59, 130, 246, 0.3); }
.symbol-icon { width: 48px; height: 48px; background: linear-gradient(135deg, #f7931a, #ffab00); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 700; margin-right: 14px; }
.symbol-info { flex: 1; }
.symbol-name { font-size: 18px; font-weight: 700; }
.symbol-meta { font-size: 12px; color: #9ca3af; }
.symbol-price { text-align: right; }
.price-value { font-size: 18px; font-weight: 700; }
.price-change { font-size: 13px; color: #22c55e; }
/* 차트 영역 */
.chart-area { flex: 1; position: relative; background: linear-gradient(90deg, rgba(39, 39, 42, 0.15) 1px, transparent 1px), linear-gradient(rgba(39, 39, 42, 0.15) 1px, transparent 1px); background-size: 35px 30px; margin: 0 12px; border-radius: 16px; overflow: hidden; }
.chart-candles { position: absolute; inset: 30px 12px 40px 12px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 8px; border-radius: 2px; position: relative; }
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal { position: absolute; width: 18px; height: 18px; border-radius: 50%; font-size: 9px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.signal.buy { bottom: -22px; left: 50%; transform: translateX(-50%); background: rgba(34, 197, 94, 0.3); color: #22c55e; border: 2px solid #22c55e; }
.signal.sell { top: -22px; left: 50%; transform: translateX(-50%); background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 2px solid #ef4444; }
.chart-timeframes { position: absolute; top: 10px; right: 10px; display: flex; gap: 4px; }
.tf-btn { padding: 6px 10px; background: rgba(255,255,255,0.1); border: none; border-radius: 6px; color: #71717a; font-size: 10px; }
.tf-btn.active { background: #3b82f6; color: #fff; }
/* 바텀시트 */
.bottom-sheet { position: absolute; bottom: 0; left: 0; right: 0; background: #27272a; border-radius: 24px 24px 0 0; padding: 12px 20px 40px; box-shadow: 0 -10px 40px rgba(0,0,0,0.5); }
.sheet-handle { width: 40px; height: 4px; background: #52525b; border-radius: 2px; margin: 0 auto 16px; }
/* 수익률 */
.return-row { display: flex; gap: 12px; margin-bottom: 20px; }
.return-card { flex: 1; background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 14px; padding: 16px; text-align: center; }
.return-value { font-size: 32px; font-weight: 700; color: #22c55e; }
.return-label { font-size: 11px; color: #9ca3af; margin-top: 4px; }
.accuracy-card { width: 100px; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 14px; padding: 16px; text-align: center; }
.accuracy-value { font-size: 26px; font-weight: 700; color: #3b82f6; }
.accuracy-label { font-size: 10px; color: #9ca3af; margin-top: 4px; }
/* 메트릭 그리드 */
.metrics-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px; }
.metric-item { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 14px; text-align: center; }
.metric-value { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.metric-value.green { color: #22c55e; }
.metric-value.red { color: #ef4444; }
.metric-label { font-size: 9px; color: #71717a; }
/* 실행 버튼 */
.action-row { display: flex; gap: 10px; }
.settings-btn { flex: 1; padding: 16px; background: rgba(255,255,255,0.1); border: none; border-radius: 14px; color: #fff; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; }
.run-btn { flex: 2; padding: 16px; background: linear-gradient(135deg, #3b82f6, #2563eb); border: none; border-radius: 14px; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; }
</style>
</head>
<body>
<div class="main-screen">
<header class="header">
<div class="header-row">
<span class="header-title">백테스트</span>
<span class="version-badge">Mobile-04</span>
</div>
</header>
<div class="symbol-selector">
<div class="symbol-card">
<div class="symbol-icon">₿</div>
<div class="symbol-info">
<div class="symbol-name">BTC/KRW</div>
<div class="symbol-meta">비트코인 · MACD+RSI</div>
</div>
<div class="symbol-price">
<div class="price-value">₩58.45M</div>
<div class="price-change">+2.34%</div>
</div>
</div>
</div>
<div class="chart-area">
<div class="chart-timeframes">
<button class="tf-btn">15m</button>
<button class="tf-btn active">1H</button>
<button class="tf-btn">4H</button>
<button class="tf-btn">1D</button>
</div>
<div class="chart-candles">
<div class="candle up" style="height: 35px;"></div>
<div class="candle up" style="height: 50px;"></div>
<div class="candle down" style="height: 30px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle down" style="height: 40px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 55px;"></div>
<div class="candle up" style="height: 45px;"></div>
<div class="candle down" style="height: 30px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 50px;"></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle up" style="height: 75px;"></div>
</div>
</div>
</div>
<div class="bottom-sheet">
<div class="sheet-handle"></div>
<div class="return-row">
<div class="return-card">
<div class="return-value">+23.5%</div>
<div class="return-label">💰 총 수익률</div>
</div>
<div class="accuracy-card">
<div class="accuracy-value">70%</div>
<div class="accuracy-label">🎯 정확도</div>
</div>
</div>
<div class="metrics-grid">
<div class="metric-item">
<div class="metric-value">₩12.3M</div>
<div class="metric-label">최종 자산</div>
</div>
<div class="metric-item">
<div class="metric-value green">68%</div>
<div class="metric-label">승률</div>
</div>
<div class="metric-item">
<div class="metric-value">25</div>
<div class="metric-label">거래</div>
</div>
<div class="metric-item">
<div class="metric-value red">-8.2%</div>
<div class="metric-label">MDD</div>
</div>
</div>
<div class="action-row">
<button class="settings-btn">⚙️ 설정</button>
<button class="run-btn">▶ 백테스트 실행</button>
</div>
</div>
</body>
</html>
@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=375">
<title>Mobile-05 미니멀 다크</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #09090b; color: #fafafa; width: 375px; min-height: 812px; padding-bottom: 100px; }
.header { padding: 54px 20px 20px; }
.header-row { display: flex; justify-content: space-between; align-items: center; }
.header-title { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; }
.version-badge { background: #27272a; color: #71717a; padding: 6px 12px; border-radius: 8px; font-size: 10px; }
.container { padding: 0 20px; }
/* 심볼 */
.symbol-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.symbol-name { font-size: 16px; color: #a1a1aa; }
.symbol-price { font-size: 16px; font-weight: 600; color: #22c55e; }
/* 대형 수익률 */
.big-return { text-align: center; margin-bottom: 40px; }
.big-return-value { font-size: 72px; font-weight: 700; color: #22c55e; letter-spacing: -2px; }
.big-return-label { font-size: 14px; color: #52525b; margin-top: 8px; }
/* 메트릭 리스트 */
.metrics-list { margin-bottom: 32px; }
.metric-row { display: flex; justify-content: space-between; align-items: center; padding: 18px 0; border-bottom: 1px solid #27272a; }
.metric-row:last-child { border: none; }
.metric-label { font-size: 14px; color: #71717a; }
.metric-value { font-size: 16px; font-weight: 600; }
.metric-value.green { color: #22c55e; }
.metric-value.red { color: #ef4444; }
/* 정확도 바 */
.accuracy-section { margin-bottom: 32px; }
.accuracy-header { display: flex; justify-content: space-between; margin-bottom: 16px; }
.accuracy-title { font-size: 14px; color: #71717a; }
.accuracy-value { font-size: 16px; font-weight: 600; }
.accuracy-bars { }
.acc-bar { margin-bottom: 16px; }
.acc-bar-header { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 8px; }
.acc-bar-label { color: #71717a; }
.acc-bar-pct { font-weight: 600; }
.acc-bar-pct.green { color: #22c55e; }
.acc-bar-pct.orange { color: #f97316; }
.acc-bar-bg { height: 8px; background: #27272a; border-radius: 4px; overflow: hidden; }
.acc-bar-fill { height: 100%; border-radius: 4px; }
.acc-bar-fill.green { background: linear-gradient(90deg, #22c55e, #4ade80); }
.acc-bar-fill.orange { background: linear-gradient(90deg, #f97316, #fb923c); }
/* 미니 차트 */
.mini-chart { background: #18181b; border-radius: 16px; padding: 20px; margin-bottom: 32px; }
.chart-header { display: flex; justify-content: space-between; margin-bottom: 16px; }
.chart-title { font-size: 12px; color: #71717a; }
.chart-area { height: 120px; display: flex; align-items: flex-end; justify-content: space-around; gap: 4px; }
.bar { width: 8px; border-radius: 2px; position: relative; }
.bar.up { background: #22c55e; }
.bar.down { background: #ef4444; }
.dot { position: absolute; width: 14px; height: 14px; border-radius: 50%; font-size: 7px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.dot.buy { bottom: -18px; left: 50%; transform: translateX(-50%); background: #22c55e; color: #000; }
.dot.sell { top: -18px; left: 50%; transform: translateX(-50%); background: #ef4444; color: #fff; }
/* 거래 */
.trades-section { }
.section-header { display: flex; justify-content: space-between; margin-bottom: 16px; }
.section-title { font-size: 14px; font-weight: 600; }
.section-link { font-size: 12px; color: #3b82f6; }
.trade-item { display: flex; align-items: center; padding: 16px; background: #18181b; border-radius: 14px; margin-bottom: 10px; }
.trade-type { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; margin-right: 14px; }
.trade-type.buy { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
.trade-type.sell { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.trade-info { flex: 1; }
.trade-action { font-size: 14px; font-weight: 500; }
.trade-time { font-size: 11px; color: #52525b; margin-top: 2px; }
.trade-result { text-align: right; }
.trade-price { font-size: 14px; font-weight: 500; }
.trade-pnl { font-size: 12px; font-weight: 600; margin-top: 2px; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
/* 실행 버튼 */
.run-btn { position: fixed; bottom: 36px; left: 20px; right: 20px; width: calc(100% - 40px); max-width: 335px; padding: 18px; background: #fafafa; border: none; border-radius: 14px; color: #09090b; font-size: 16px; font-weight: 600; cursor: pointer; }
</style>
</head>
<body>
<header class="header">
<div class="header-row">
<span class="header-title">백테스트</span>
<span class="version-badge">Mobile-05</span>
</div>
</header>
<div class="container">
<div class="symbol-row">
<span class="symbol-name">BTC/KRW · 1H · MACD+RSI</span>
<span class="symbol-price">+2.34%</span>
</div>
<div class="big-return">
<div class="big-return-value">+23.5%</div>
<div class="big-return-label">₩10,000,000 → ₩12,350,000</div>
</div>
<div class="metrics-list">
<div class="metric-row">
<span class="metric-label">최종 자산</span>
<span class="metric-value">₩12,350,000</span>
</div>
<div class="metric-row">
<span class="metric-label">승률</span>
<span class="metric-value green">68% (17/25)</span>
</div>
<div class="metric-row">
<span class="metric-label">최대 낙폭</span>
<span class="metric-value red">-8.2%</span>
</div>
</div>
<div class="accuracy-section">
<div class="accuracy-header">
<span class="accuracy-title">전략 정확도</span>
<span class="accuracy-value">70%</span>
</div>
<div class="accuracy-bars">
<div class="acc-bar">
<div class="acc-bar-header">
<span class="acc-bar-label">매수</span>
<span class="acc-bar-pct green">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span class="acc-bar-label">매도</span>
<span class="acc-bar-pct orange">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill orange" style="width: 64%;"></div></div>
</div>
</div>
</div>
<div class="mini-chart">
<div class="chart-header">
<span class="chart-title">가격 차트</span>
<span class="chart-title">3개월</span>
</div>
<div class="chart-area">
<div class="bar up" style="height: 30px;"></div>
<div class="bar up" style="height: 45px;"></div>
<div class="bar down" style="height: 25px;"></div>
<div class="bar down" style="height: 40px;"><div class="dot buy">B</div></div>
<div class="bar up" style="height: 55px;"></div>
<div class="bar up" style="height: 70px;"></div>
<div class="bar up" style="height: 60px;"></div>
<div class="bar down" style="height: 35px;"><div class="dot sell">S</div></div>
<div class="bar down" style="height: 50px;"></div>
<div class="bar up" style="height: 40px;"></div>
<div class="bar down" style="height: 25px;"><div class="dot buy">B</div></div>
<div class="bar up" style="height: 45px;"></div>
<div class="bar up" style="height: 55px;"></div>
<div class="bar up" style="height: 65px;"></div>
</div>
</div>
<div class="trades-section">
<div class="section-header">
<span class="section-title">최근 거래</span>
<span class="section-link">전체보기 →</span>
</div>
<div class="trade-item">
<div class="trade-type sell">S</div>
<div class="trade-info">
<div class="trade-action">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-type buy">B</div>
<div class="trade-info">
<div class="trade-action">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
</div>
</div>
<button class="run-btn">백테스트 실행</button>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,714 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC-01 트레이딩 터미널 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, sans-serif;
background: #0a0a0f;
color: #e4e4e7;
min-height: 100vh;
}
/* 최상단 바 */
.top-bar {
height: 40px;
background: #131318;
border-bottom: 1px solid #27272a;
display: flex;
align-items: center;
padding: 0 16px;
gap: 24px;
}
.logo {
font-weight: 700;
font-size: 14px;
color: #22c55e;
display: flex;
align-items: center;
gap: 6px;
}
.top-nav {
display: flex;
gap: 4px;
}
.top-nav-item {
padding: 8px 16px;
font-size: 13px;
color: #71717a;
cursor: pointer;
border-radius: 4px;
}
.top-nav-item:hover { background: #27272a; color: #fff; }
.top-nav-item.active { background: #22c55e; color: #000; font-weight: 500; }
.top-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: #71717a;
}
/* 메인 레이아웃 */
.main-layout {
display: grid;
grid-template-columns: 280px 1fr 320px;
grid-template-rows: auto 1fr;
height: calc(100vh - 40px);
}
/* 왼쪽 패널 */
.left-panel {
background: #131318;
border-right: 1px solid #27272a;
overflow-y: auto;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #27272a;
font-size: 12px;
font-weight: 600;
color: #a1a1aa;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.symbol-search {
padding: 12px 16px;
border-bottom: 1px solid #27272a;
}
.symbol-search input {
width: 100%;
padding: 10px 12px;
background: #0a0a0f;
border: 1px solid #27272a;
border-radius: 6px;
color: #fff;
font-size: 13px;
}
.symbol-list {
padding: 8px;
}
.symbol-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
}
.symbol-item:hover { background: #1f1f26; }
.symbol-item.active { background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.3); }
.symbol-name { font-size: 13px; font-weight: 500; }
.symbol-market { font-size: 11px; color: #71717a; }
.symbol-price { text-align: right; }
.symbol-price-value { font-size: 13px; font-weight: 500; }
.symbol-change { font-size: 11px; }
.symbol-change.up { color: #22c55e; }
.symbol-change.down { color: #ef4444; }
/* 설정 섹션 */
.settings-section {
padding: 16px;
border-top: 1px solid #27272a;
}
.setting-group {
margin-bottom: 16px;
}
.setting-label {
font-size: 11px;
color: #71717a;
margin-bottom: 6px;
}
.setting-select {
width: 100%;
padding: 10px 12px;
background: #0a0a0f;
border: 1px solid #27272a;
border-radius: 6px;
color: #fff;
font-size: 13px;
}
.btn-run {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border: none;
border-radius: 8px;
color: #000;
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
.btn-run:hover { filter: brightness(1.1); }
/* 중앙 차트 영역 */
.center-area {
display: flex;
flex-direction: column;
background: #0a0a0f;
}
/* 차트 헤더 */
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #27272a;
background: #131318;
}
.chart-symbol-info {
display: flex;
align-items: center;
gap: 16px;
}
.chart-symbol-name {
font-size: 18px;
font-weight: 700;
}
.chart-price {
font-size: 24px;
font-weight: 700;
color: #22c55e;
}
.chart-change {
font-size: 14px;
color: #22c55e;
}
.timeframe-selector {
display: flex;
gap: 4px;
background: #1f1f26;
padding: 4px;
border-radius: 6px;
}
.tf-btn {
padding: 6px 12px;
background: transparent;
border: none;
color: #71717a;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
}
.tf-btn:hover { color: #fff; }
.tf-btn.active { background: #22c55e; color: #000; font-weight: 500; }
/* 차트 영역 */
.chart-container {
flex: 1;
position: relative;
background:
linear-gradient(90deg, rgba(39, 39, 42, 0.3) 1px, transparent 1px),
linear-gradient(rgba(39, 39, 42, 0.3) 1px, transparent 1px);
background-size: 60px 40px;
}
.chart-candles {
position: absolute;
inset: 40px 80px 60px 20px;
display: flex;
align-items: flex-end;
justify-content: space-around;
gap: 4px;
}
.candle {
width: 12px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.candle-body { width: 100%; border-radius: 1px; }
.candle-body.bull { background: #22c55e; }
.candle-body.bear { background: #ef4444; }
.signal-badge {
position: absolute;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.signal-badge.buy {
bottom: -28px;
background: #22c55e;
color: #000;
}
.signal-badge.sell {
top: -28px;
background: #ef4444;
color: #fff;
}
.price-axis {
position: absolute;
right: 0;
top: 40px;
bottom: 60px;
width: 80px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px;
font-size: 11px;
color: #52525b;
text-align: right;
background: rgba(19, 19, 24, 0.8);
}
.time-axis {
position: absolute;
bottom: 0;
left: 20px;
right: 80px;
height: 60px;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 11px;
color: #52525b;
}
/* 보조지표 영역 */
.indicators-panel {
height: 150px;
border-top: 1px solid #27272a;
display: flex;
}
.indicator-chart {
flex: 1;
border-right: 1px solid #27272a;
padding: 12px;
position: relative;
}
.indicator-chart:last-child { border-right: none; }
.indicator-title {
font-size: 11px;
color: #71717a;
margin-bottom: 8px;
}
.indicator-mock {
height: calc(100% - 24px);
background: linear-gradient(180deg,
rgba(34, 197, 94, 0.1) 0%,
transparent 30%,
transparent 70%,
rgba(239, 68, 68, 0.1) 100%
);
border-radius: 4px;
position: relative;
}
.indicator-line {
position: absolute;
left: 5%;
right: 5%;
top: 50%;
height: 2px;
background: linear-gradient(90deg, #ef4444, #eab308, #22c55e, #3b82f6, #22c55e);
border-radius: 1px;
}
/* 오른쪽 패널 */
.right-panel {
background: #131318;
border-left: 1px solid #27272a;
display: flex;
flex-direction: column;
}
/* 성과 요약 */
.performance-summary {
padding: 16px;
border-bottom: 1px solid #27272a;
}
.perf-title {
font-size: 12px;
font-weight: 600;
color: #a1a1aa;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
.perf-highlight {
text-align: center;
padding: 20px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
border-radius: 12px;
margin-bottom: 16px;
}
.perf-highlight-label {
font-size: 12px;
color: #71717a;
margin-bottom: 4px;
}
.perf-highlight-value {
font-size: 36px;
font-weight: 700;
color: #22c55e;
}
.perf-highlight-sub {
font-size: 12px;
color: #22c55e;
margin-top: 4px;
}
.perf-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.perf-item {
background: #1f1f26;
padding: 14px;
border-radius: 8px;
}
.perf-item-label {
font-size: 11px;
color: #71717a;
margin-bottom: 4px;
}
.perf-item-value {
font-size: 18px;
font-weight: 600;
}
.perf-item-value.up { color: #22c55e; }
.perf-item-value.down { color: #ef4444; }
/* 정확도 */
.accuracy-section {
padding: 16px;
border-bottom: 1px solid #27272a;
}
.accuracy-bar-container {
margin-bottom: 12px;
}
.accuracy-bar-header {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 6px;
}
.accuracy-bar-bg {
height: 8px;
background: #27272a;
border-radius: 4px;
overflow: hidden;
}
.accuracy-bar-fill {
height: 100%;
border-radius: 4px;
}
.accuracy-bar-fill.green { background: linear-gradient(90deg, #22c55e, #4ade80); }
.accuracy-bar-fill.yellow { background: linear-gradient(90deg, #eab308, #facc15); }
/* 거래 내역 */
.trades-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.trades-header {
padding: 16px;
border-bottom: 1px solid #27272a;
display: flex;
justify-content: space-between;
align-items: center;
}
.trades-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.trade-item {
display: flex;
align-items: center;
padding: 12px;
background: #1f1f26;
border-radius: 8px;
margin-bottom: 6px;
gap: 12px;
}
.trade-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.trade-icon.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-icon.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 12px; font-weight: 500; }
.trade-time { font-size: 10px; color: #52525b; }
.trade-result { text-align: right; }
.trade-price { font-size: 12px; }
.trade-pnl { font-size: 11px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
</style>
</head>
<body>
<div class="top-bar">
<div class="logo">📊 BACKTEST PRO</div>
<nav class="top-nav">
<div class="top-nav-item">대시보드</div>
<div class="top-nav-item active">백테스트</div>
<div class="top-nav-item">포트폴리오</div>
<div class="top-nav-item">전략관리</div>
<div class="top-nav-item">리포트</div>
</nav>
<div class="top-right">
<span>PC-01 트레이딩 터미널</span>
<span>🔔</span>
<span>⚙️</span>
</div>
</div>
<div class="main-layout">
<!-- 왼쪽 패널 -->
<aside class="left-panel">
<div class="panel-header">종목 선택</div>
<div class="symbol-search">
<input type="text" placeholder="🔍 종목 검색...">
</div>
<div class="symbol-list">
<div class="symbol-item active">
<div>
<div class="symbol-name">BTC/KRW</div>
<div class="symbol-market">비트코인</div>
</div>
<div class="symbol-price">
<div class="symbol-price-value">58,450,000</div>
<div class="symbol-change up">+2.34%</div>
</div>
</div>
<div class="symbol-item">
<div>
<div class="symbol-name">ETH/KRW</div>
<div class="symbol-market">이더리움</div>
</div>
<div class="symbol-price">
<div class="symbol-price-value">3,245,000</div>
<div class="symbol-change up">+1.82%</div>
</div>
</div>
<div class="symbol-item">
<div>
<div class="symbol-name">XRP/KRW</div>
<div class="symbol-market">리플</div>
</div>
<div class="symbol-price">
<div class="symbol-price-value">892</div>
<div class="symbol-change down">-0.45%</div>
</div>
</div>
</div>
<div class="settings-section">
<div class="panel-header" style="padding: 0 0 12px 0; border: none;">전략 설정</div>
<div class="setting-group">
<div class="setting-label">투자 전략</div>
<select class="setting-select">
<option>MACD + RSI 복합전략</option>
<option>볼린저밴드 돌파</option>
<option>이동평균 크로스</option>
</select>
</div>
<div class="setting-group">
<div class="setting-label">분석 기간</div>
<select class="setting-select">
<option>최근 3개월</option>
<option>최근 6개월</option>
<option>최근 1년</option>
</select>
</div>
<div class="setting-group">
<div class="setting-label">초기 투자금</div>
<input type="text" class="setting-select" value="₩10,000,000">
</div>
<button class="btn-run">▶ 백테스트 실행</button>
</div>
</aside>
<!-- 중앙 차트 영역 -->
<main class="center-area">
<div class="chart-header">
<div class="chart-symbol-info">
<span class="chart-symbol-name">BTC/KRW</span>
<span class="chart-price">₩58,450,000</span>
<span class="chart-change">+2.34% (+1,340,000)</span>
</div>
<div class="timeframe-selector">
<button class="tf-btn">15분</button>
<button class="tf-btn active">1시간</button>
<button class="tf-btn">4시간</button>
<button class="tf-btn">1일</button>
<button class="tf-btn">1주</button>
</div>
</div>
<div class="chart-container">
<div class="price-axis">
<span>62,000,000</span>
<span>60,000,000</span>
<span>58,000,000</span>
<span>56,000,000</span>
<span>54,000,000</span>
</div>
<div class="time-axis">
<span>03/10</span>
<span>03/11</span>
<span>03/12</span>
<span>03/13</span>
<span>03/14</span>
<span>03/15</span>
</div>
<div class="chart-candles">
<div class="candle"><div class="candle-body bull" style="height: 50px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 65px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 40px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 55px;"></div><div class="signal-badge buy">BUY</div></div>
<div class="candle"><div class="candle-body bull" style="height: 70px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 90px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 80px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 45px;"></div><div class="signal-badge sell">SELL</div></div>
<div class="candle"><div class="candle-body bear" style="height: 60px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 50px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 35px;"></div><div class="signal-badge buy">BUY</div></div>
<div class="candle"><div class="candle-body bull" style="height: 55px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 75px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 85px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 40px;"></div></div>
</div>
</div>
<div class="indicators-panel">
<div class="indicator-chart">
<div class="indicator-title">MACD (12, 26, 9)</div>
<div class="indicator-mock"><div class="indicator-line"></div></div>
</div>
<div class="indicator-chart">
<div class="indicator-title">RSI (14)</div>
<div class="indicator-mock"><div class="indicator-line"></div></div>
</div>
<div class="indicator-chart">
<div class="indicator-title">Volume</div>
<div class="indicator-mock"><div class="indicator-line"></div></div>
</div>
</div>
</main>
<!-- 오른쪽 패널 -->
<aside class="right-panel">
<div class="performance-summary">
<div class="perf-title">📊 성과 요약</div>
<div class="perf-highlight">
<div class="perf-highlight-label">총 수익률</div>
<div class="perf-highlight-value">+23.5%</div>
<div class="perf-highlight-sub">₩2,350,000 수익</div>
</div>
<div class="perf-grid">
<div class="perf-item">
<div class="perf-item-label">최종 자산</div>
<div class="perf-item-value">₩12.35M</div>
</div>
<div class="perf-item">
<div class="perf-item-label">승률</div>
<div class="perf-item-value up">68%</div>
</div>
<div class="perf-item">
<div class="perf-item-label">총 거래</div>
<div class="perf-item-value">25회</div>
</div>
<div class="perf-item">
<div class="perf-item-label">최대 낙폭</div>
<div class="perf-item-value down">-8.2%</div>
</div>
</div>
</div>
<div class="accuracy-section">
<div class="perf-title">🎯 전략 정확도</div>
<div class="accuracy-bar-container">
<div class="accuracy-bar-header">
<span style="color: #71717a;">전체 정확도</span>
<span style="color: #22c55e; font-weight: 600;">70%</span>
</div>
<div class="accuracy-bar-bg">
<div class="accuracy-bar-fill green" style="width: 70%;"></div>
</div>
</div>
<div class="accuracy-bar-container">
<div class="accuracy-bar-header">
<span style="color: #71717a;">매수 정확도</span>
<span style="color: #22c55e; font-weight: 600;">76%</span>
</div>
<div class="accuracy-bar-bg">
<div class="accuracy-bar-fill green" style="width: 76%;"></div>
</div>
</div>
<div class="accuracy-bar-container">
<div class="accuracy-bar-header">
<span style="color: #71717a;">매도 정확도</span>
<span style="color: #eab308; font-weight: 600;">64%</span>
</div>
<div class="accuracy-bar-bg">
<div class="accuracy-bar-fill yellow" style="width: 64%;"></div>
</div>
</div>
</div>
<div class="trades-section">
<div class="trades-header">
<span class="perf-title" style="margin: 0;">📋 거래 내역</span>
<span style="font-size: 11px; color: #22c55e; cursor: pointer;">전체보기 →</span>
</div>
<div class="trades-list">
<div class="trade-item">
<div class="trade-icon sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">2024.03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">2024.03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">2024.03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54,200,000</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
</div>
</div>
</aside>
</div>
</body>
</html>
@@ -0,0 +1,553 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC-02 분석 리포트</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
padding: 24px;
}
/* 헤더 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-title {
font-size: 28px;
font-weight: 700;
}
.header-subtitle {
color: #94a3b8;
font-size: 14px;
margin-top: 4px;
}
.header-actions {
display: flex;
gap: 12px;
}
.badge {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
}
.btn {
background: #3b82f6;
color: #fff;
border: none;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
/* 설정 카드 */
.settings-card {
background: #1e293b;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
border: 1px solid #334155;
}
.settings-title {
font-size: 13px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.settings-item label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 6px;
}
.settings-item select,
.settings-item input {
width: 100%;
padding: 10px 12px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 13px;
}
/* 메트릭 그리드 */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: #1e293b;
border-radius: 16px;
padding: 20px;
border: 1px solid #334155;
}
.metric-card.highlight {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
}
.metric-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.metric-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.metric-icon.green { background: rgba(34, 197, 94, 0.2); }
.metric-icon.blue { background: rgba(59, 130, 246, 0.2); }
.metric-icon.purple { background: rgba(139, 92, 246, 0.2); }
.metric-icon.cyan { background: rgba(14, 165, 233, 0.2); }
.metric-label {
font-size: 12px;
color: #94a3b8;
}
.metric-value {
font-size: 32px;
font-weight: 700;
}
.metric-value.green { color: #22c55e; }
.metric-value.purple { color: #8b5cf6; }
/* 메인 그리드 */
.main-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
/* 차트 카드 */
.chart-card {
background: #1e293b;
border-radius: 16px;
border: 1px solid #334155;
overflow: hidden;
}
.chart-header {
padding: 16px;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-title {
font-size: 14px;
font-weight: 600;
}
.chart-controls {
display: flex;
gap: 8px;
align-items: center;
}
.chart-controls button {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px 8px;
}
.chart-controls .chip {
background: #334155;
color: #f1f5f9;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
}
.chart-area {
height: 320px;
background: #0f172a;
position: relative;
}
.chart-grid {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(51, 65, 85, 0.3) 1px, transparent 1px),
linear-gradient(rgba(51, 65, 85, 0.3) 1px, transparent 1px);
background-size: 60px 40px;
}
.candles {
position: absolute;
inset: 30px 40px 40px 20px;
display: flex;
align-items: flex-end;
justify-content: space-around;
}
.candle {
width: 14px;
border-radius: 2px;
}
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
/* 사이드 카드 */
.side-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.side-card {
background: #1e293b;
border-radius: 16px;
padding: 20px;
border: 1px solid #334155;
}
.side-card-title {
font-size: 13px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
/* 프로그레스 바 */
.progress-item {
margin-bottom: 12px;
}
.progress-header {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 6px;
}
.progress-header span:first-child { color: #94a3b8; }
.progress-bar {
height: 8px;
background: #0f172a;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
}
.progress-fill.purple { background: #8b5cf6; }
.progress-fill.green { background: #22c55e; }
.progress-fill.red { background: #ef4444; }
/* 자산 현황 */
.asset-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 13px;
}
.asset-row span:first-child { color: #94a3b8; }
.asset-row span:last-child { font-weight: 500; }
.asset-row.green span:last-child { color: #22c55e; }
.asset-row.red span:last-child { color: #ef4444; }
/* 통계 그리드 */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-box {
text-align: center;
padding: 12px;
background: #0f172a;
border-radius: 12px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
}
.stat-value.green { color: #22c55e; }
.stat-value.red { color: #ef4444; }
.stat-label {
font-size: 11px;
color: #64748b;
margin-top: 4px;
}
/* 테이블 */
.table-card {
background: #1e293b;
border-radius: 16px;
border: 1px solid #334155;
overflow: hidden;
}
.table-header {
padding: 16px;
border-bottom: 1px solid #334155;
}
.table-header h3 {
font-size: 14px;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 12px 16px;
text-align: left;
font-size: 11px;
color: #64748b;
background: #0f172a;
font-weight: 600;
}
th:nth-child(3), th:nth-child(4), th:nth-child(5) { text-align: right; }
td {
padding: 12px 16px;
font-size: 12px;
border-bottom: 1px solid #334155;
}
td:nth-child(3), td:nth-child(4), td:nth-child(5) { text-align: right; }
tr:hover { background: rgba(59, 130, 246, 0.05); }
.type-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.type-badge.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.type-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
</style>
</head>
<body>
<!-- 헤더 -->
<div class="header">
<div>
<h1 class="header-title">📊 투자 분석 리포트</h1>
<p class="header-subtitle">KRW-BTC • MACD + RSI 전략 • 2024년 12월 7일</p>
</div>
<div class="header-actions">
<span class="badge">PC-02 분석리포트</span>
<button class="btn">▶ 백테스트 실행</button>
</div>
</div>
<!-- 설정 카드 -->
<div class="settings-card">
<div class="settings-title">분석 설정</div>
<div class="settings-grid">
<div class="settings-item">
<label>종목</label>
<select><option>KRW-BTC</option></select>
</div>
<div class="settings-item">
<label>투자 전략</label>
<select><option>MACD + RSI</option></select>
</div>
<div class="settings-item">
<label>시간봉</label>
<select><option>1시간</option></select>
</div>
<div class="settings-item">
<label>분석 기간</label>
<input type="number" value="500">
</div>
<div class="settings-item">
<label>초기 투자금</label>
<input type="text" value="₩10,000,000">
</div>
</div>
</div>
<!-- 메트릭 그리드 -->
<div class="metrics-grid">
<div class="metric-card highlight">
<div class="metric-header">
<div class="metric-icon green">📈</div>
<span class="metric-label">총 수익률</span>
</div>
<div class="metric-value green">+23.45%</div>
</div>
<div class="metric-card">
<div class="metric-header">
<div class="metric-icon blue">💰</div>
<span class="metric-label">순이익</span>
</div>
<div class="metric-value green">+₩234만</div>
</div>
<div class="metric-card">
<div class="metric-header">
<div class="metric-icon purple">🎯</div>
<span class="metric-label">승률</span>
</div>
<div class="metric-value purple">68.5%</div>
</div>
<div class="metric-card">
<div class="metric-header">
<div class="metric-icon cyan">🔄</div>
<span class="metric-label">총 거래</span>
</div>
<div class="metric-value">27회</div>
</div>
</div>
<!-- 메인 그리드 -->
<div class="main-grid">
<!-- 차트 카드 -->
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">📈 가격 차트 & 매매 신호</span>
<div class="chart-controls">
<button>◀</button>
<span class="chip">1h</span>
<button>▶</button>
</div>
</div>
<div class="chart-area">
<div class="chart-grid"></div>
<div class="candles">
<div class="candle up" style="height: 60px;"></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle down" style="height: 50px;"></div>
<div class="candle down" style="height: 70px;"></div>
<div class="candle up" style="height: 90px;"></div>
<div class="candle up" style="height: 110px;"></div>
<div class="candle up" style="height: 100px;"></div>
<div class="candle down" style="height: 65px;"></div>
<div class="candle down" style="height: 80px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 85px;"></div>
<div class="candle up" style="height: 95px;"></div>
</div>
</div>
</div>
<!-- 사이드 카드 -->
<div class="side-cards">
<div class="side-card">
<div class="side-card-title">정확도 분석</div>
<div class="progress-item">
<div class="progress-header">
<span>전체 정확도</span>
<span style="color: #8b5cf6; font-weight: 600;">68.5%</span>
</div>
<div class="progress-bar"><div class="progress-fill purple" style="width: 68.5%;"></div></div>
</div>
<div class="progress-item">
<div class="progress-header">
<span>매수 정확도</span>
<span style="color: #22c55e; font-weight: 600;">72%</span>
</div>
<div class="progress-bar"><div class="progress-fill green" style="width: 72%;"></div></div>
</div>
<div class="progress-item">
<div class="progress-header">
<span>매도 정확도</span>
<span style="color: #ef4444; font-weight: 600;">65%</span>
</div>
<div class="progress-bar"><div class="progress-fill red" style="width: 65%;"></div></div>
</div>
</div>
<div class="side-card">
<div class="side-card-title">자산 현황</div>
<div class="asset-row">
<span>초기 투자금</span>
<span>₩10,000,000</span>
</div>
<div class="asset-row">
<span>최종 자산</span>
<span>₩12,345,000</span>
</div>
<div class="asset-row green">
<span>최대 수익</span>
<span>₩3,200,000</span>
</div>
<div class="asset-row red">
<span>최대 손실</span>
<span>₩-850,000</span>
</div>
</div>
<div class="side-card">
<div class="side-card-title">거래 통계</div>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-value green">18</div>
<div class="stat-label">수익 거래</div>
</div>
<div class="stat-box">
<div class="stat-value red">9</div>
<div class="stat-label">손실 거래</div>
</div>
</div>
</div>
</div>
</div>
<!-- 거래 내역 테이블 -->
<div class="table-card">
<div class="table-header">
<h3>📋 거래 내역</h3>
</div>
<table>
<thead>
<tr>
<th>유형</th>
<th>시간</th>
<th>가격</th>
<th>수량</th>
<th>손익</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="type-badge sell">매도</span></td>
<td style="color: #94a3b8;">2024-12-07 14:00</td>
<td>₩58,450,000</td>
<td style="color: #94a3b8;">0.142857</td>
<td style="color: #22c55e; font-weight: 600;">+₩520,000</td>
</tr>
<tr>
<td><span class="type-badge buy">매수</span></td>
<td style="color: #94a3b8;">2024-12-05 09:00</td>
<td>₩55,520,000</td>
<td style="color: #94a3b8;">0.142857</td>
<td style="color: #64748b;">-</td>
</tr>
<tr>
<td><span class="type-badge sell">매도</span></td>
<td style="color: #94a3b8;">2024-12-03 16:00</td>
<td>₩54,200,000</td>
<td style="color: #94a3b8;">0.156250</td>
<td style="color: #ef4444; font-weight: 600;">-₩180,000</td>
</tr>
<tr>
<td><span class="type-badge buy">매수</span></td>
<td style="color: #94a3b8;">2024-12-01 11:00</td>
<td>₩55,350,000</td>
<td style="color: #94a3b8;">0.156250</td>
<td style="color: #64748b;">-</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
@@ -0,0 +1,780 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC-04 다크 프로페셔널</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: #09090b;
color: #fafafa;
min-height: 100vh;
}
.app {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
/* 좌측 패널 - 모의투자 설정 */
.sidebar {
background: #18181b;
border-right: 1px solid #27272a;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid #27272a;
background: linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(99, 102, 241, 0.15));
}
.sidebar-header-content {
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #a855f7, #6366f1);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.sidebar-title {
font-size: 15px;
font-weight: 700;
}
.sidebar-subtitle {
font-size: 11px;
color: #a1a1aa;
}
/* 전략 설정 섹션 */
.settings-section {
padding: 20px;
border-bottom: 1px solid #27272a;
flex: 1;
overflow-y: auto;
}
.section-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #52525b;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
font-size: 12px;
color: #a1a1aa;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.form-input, .form-select {
width: 100%;
padding: 12px 14px;
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 10px;
color: #fff;
font-size: 13px;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: #a855f7;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.run-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #a855f7, #6366f1);
border: none;
border-radius: 12px;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.4);
transition: all 0.2s;
}
.run-btn:hover {
background: linear-gradient(135deg, #9333ea, #4f46e5);
box-shadow: 0 6px 20px rgba(168, 85, 247, 0.5);
}
/* 투자 요약 섹션 */
.summary-section {
padding: 20px;
}
.profit-box {
padding: 16px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 12px;
margin-bottom: 16px;
}
.profit-label {
font-size: 11px;
color: #71717a;
margin-bottom: 4px;
}
.profit-value {
font-size: 28px;
font-weight: 700;
color: #22c55e;
line-height: 1;
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.summary-item-label {
font-size: 12px;
color: #71717a;
}
.summary-item-value {
font-size: 13px;
font-weight: 600;
}
.summary-item-value.green { color: #22c55e; }
.summary-item-value.purple { color: #a855f7; }
/* 메인 콘텐츠 */
.main-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 톱바 */
.topbar {
padding: 16px 32px;
border-bottom: 1px solid #27272a;
display: flex;
justify-content: space-between;
align-items: center;
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
font-size: 22px;
font-weight: 600;
}
.version-badge {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
padding: 6px 14px;
border-radius: 20px;
font-size: 11px;
}
.topbar-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid #27272a;
background: transparent;
color: #a1a1aa;
}
.btn-primary {
background: linear-gradient(135deg, #a855f7, #6366f1);
border: none;
color: #fff;
}
/* 콘텐츠 영역 */
.content-area {
flex: 1;
padding: 24px 32px;
overflow-y: auto;
}
/* 현재 설정 요약 바 */
.config-summary {
background: #18181b;
border-radius: 16px;
padding: 16px 24px;
display: flex;
gap: 24px;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
}
.config-item {
display: flex;
align-items: center;
gap: 8px;
}
.config-item-label {
font-size: 12px;
color: #71717a;
}
.config-chip {
padding: 6px 12px;
background: #27272a;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.config-chip.purple {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
/* 결과 배너 */
.result-banner {
background: linear-gradient(135deg, rgba(168, 85, 247, 0.1), rgba(99, 102, 241, 0.1));
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: 20px;
padding: 32px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.result-info h2 {
font-size: 28px;
font-weight: 700;
margin-bottom: 4px;
}
.result-info p {
color: #71717a;
font-size: 14px;
}
.result-stats {
display: flex;
gap: 48px;
text-align: center;
}
.result-stat-value {
font-size: 36px;
font-weight: 700;
}
.result-stat-value.green { color: #22c55e; }
.result-stat-label {
font-size: 12px;
color: #71717a;
}
/* 메트릭 그리드 */
.metrics-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.metric-box {
background: #18181b;
border-radius: 12px;
padding: 20px;
border: 1px solid #27272a;
}
.metric-icon {
font-size: 20px;
margin-bottom: 12px;
}
.metric-label {
font-size: 11px;
color: #71717a;
margin-bottom: 6px;
}
.metric-value {
font-size: 22px;
font-weight: 600;
}
.metric-value.up { color: #22c55e; }
.metric-value.down { color: #ef4444; }
/* 차트 + 패널 */
.chart-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
.chart-panel {
background: #18181b;
border-radius: 16px;
padding: 24px;
border: 1px solid #27272a;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.panel-title {
font-size: 15px;
font-weight: 600;
}
.tab-group {
display: flex;
background: #27272a;
padding: 4px;
border-radius: 8px;
}
.tab-btn {
padding: 8px 14px;
background: transparent;
border: none;
color: #71717a;
font-size: 12px;
border-radius: 6px;
cursor: pointer;
}
.tab-btn.active { background: #3f3f46; color: #fff; }
/* 차트 영역 */
.chart-area {
height: 320px;
background: #0c0c0e;
border-radius: 12px;
position: relative;
overflow: hidden;
}
.chart-grid {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(63, 63, 70, 0.3) 1px, transparent 1px),
linear-gradient(rgba(63, 63, 70, 0.3) 1px, transparent 1px);
background-size: 50px 32px;
}
.candles-container {
position: absolute;
inset: 30px 60px 50px 20px;
display: flex;
align-items: flex-end;
justify-content: space-around;
}
.candle {
width: 12px;
border-radius: 2px;
position: relative;
}
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal-tag {
position: absolute;
padding: 3px 8px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
white-space: nowrap;
}
.signal-tag.buy { bottom: -24px; background: #22c55e; color: #000; }
.signal-tag.sell { top: -24px; background: #ef4444; color: #fff; }
/* 사이드 패널 */
.side-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.accuracy-card {
background: #18181b;
border-radius: 16px;
padding: 24px;
border: 1px solid #27272a;
text-align: center;
}
.accuracy-ring {
width: 140px;
height: 140px;
margin: 0 auto 20px;
border-radius: 50%;
background: conic-gradient(#a855f7 0deg 252deg, #27272a 252deg 360deg);
display: flex;
align-items: center;
justify-content: center;
}
.accuracy-inner {
width: 110px;
height: 110px;
background: #18181b;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.accuracy-pct {
font-size: 32px;
font-weight: 700;
color: #a855f7;
}
.accuracy-text { font-size: 11px; color: #71717a; }
.accuracy-bars {
text-align: left;
}
.acc-bar-item {
margin-bottom: 12px;
}
.acc-bar-header {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 6px;
}
.acc-bar-bg {
height: 6px;
background: #27272a;
border-radius: 3px;
overflow: hidden;
}
.acc-bar-fill {
height: 100%;
border-radius: 3px;
}
.acc-bar-fill.purple { background: linear-gradient(90deg, #a855f7, #6366f1); }
.acc-bar-fill.green { background: #22c55e; }
/* 거래 리스트 */
.trades-panel {
background: #18181b;
border-radius: 16px;
padding: 20px;
border: 1px solid #27272a;
flex: 1;
}
.trades-list {
max-height: 200px;
overflow-y: auto;
}
.trade-item {
display: flex;
align-items: center;
padding: 12px;
background: #27272a;
border-radius: 8px;
margin-bottom: 8px;
}
.trade-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
margin-right: 12px;
}
.trade-icon.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-icon.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 12px; font-weight: 500; }
.trade-time { font-size: 10px; color: #52525b; }
.trade-result { text-align: right; }
.trade-price { font-size: 11px; }
.trade-pnl { font-size: 10px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
</style>
</head>
<body>
<div class="app">
<!-- 좌측 패널 - 모의투자 설정 -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-header-content">
<div class="sidebar-icon">📊</div>
<div>
<div class="sidebar-title">모의투자 설정</div>
<div class="sidebar-subtitle">전략을 설정하고 백테스트를 실행하세요</div>
</div>
</div>
</div>
<div class="settings-section">
<div class="section-title">전략 설정</div>
<div class="form-group">
<div class="form-label">🪙 종목</div>
<select class="form-select">
<option>KRW-BTC (비트코인)</option>
<option>KRW-ETH (이더리움)</option>
</select>
</div>
<div class="form-group">
<div class="form-label">🎯 투자 전략</div>
<select class="form-select">
<option>MACD + RSI 복합</option>
<option>골든크로스</option>
<option>볼린저밴드</option>
</select>
</div>
<div class="form-row form-group">
<div>
<div class="form-label">⏱️ 시간봉</div>
<select class="form-select">
<option>1시간</option>
<option>4시간</option>
<option>1일</option>
</select>
</div>
<div>
<div class="form-label">📅 기간</div>
<input type="number" class="form-input" value="500">
</div>
</div>
<div class="form-group">
<div class="form-label">💰 초기 투자금</div>
<input type="text" class="form-input" value="₩10,000,000">
</div>
<button class="run-btn">▶ 백테스트 실행</button>
</div>
<div class="summary-section">
<div class="section-title">투자 요약</div>
<div class="profit-box">
<div class="profit-label">총 수익률</div>
<div class="profit-value">+23.5%</div>
</div>
<div class="summary-item">
<span class="summary-item-label">순이익</span>
<span class="summary-item-value green">+₩235만</span>
</div>
<div class="summary-item">
<span class="summary-item-label">승률</span>
<span class="summary-item-value purple">68%</span>
</div>
<div class="summary-item">
<span class="summary-item-label">총 거래</span>
<span class="summary-item-value">25회</span>
</div>
<div class="summary-item">
<span class="summary-item-label">최종 자산</span>
<span class="summary-item-value">₩12,350,000</span>
</div>
</div>
</aside>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<header class="topbar">
<div class="topbar-left">
<h1 class="page-title">투자전략 백테스트</h1>
<span class="version-badge">PC-04 다크 프로</span>
</div>
<div class="topbar-actions">
<button class="btn">📥 내보내기</button>
<button class="btn btn-primary">+ 새 테스트</button>
</div>
</header>
<div class="content-area">
<!-- 현재 설정 요약 바 -->
<div class="config-summary">
<div class="config-item">
<span class="config-item-label">종목:</span>
<span class="config-chip">KRW-BTC</span>
</div>
<div class="config-item">
<span class="config-item-label">전략:</span>
<span class="config-chip purple">MACD + RSI</span>
</div>
<div class="config-item">
<span class="config-item-label">시간봉:</span>
<span class="config-chip">1h</span>
</div>
<div class="config-item">
<span class="config-item-label">기간:</span>
<span class="config-chip">500개</span>
</div>
<div class="config-item">
<span class="config-item-label">초기자본:</span>
<span class="config-chip">₩1000만</span>
</div>
</div>
<!-- 결과 배너 -->
<div class="result-banner">
<div class="result-info">
<h2>KRW-BTC 백테스트 완료</h2>
<p>MACD + RSI 전략 · 1h 봉 기준</p>
</div>
<div class="result-stats">
<div>
<div class="result-stat-value green">+23.5%</div>
<div class="result-stat-label">총 수익률</div>
</div>
<div>
<div class="result-stat-value">₩12.35M</div>
<div class="result-stat-label">최종 자산</div>
</div>
</div>
</div>
<!-- 메트릭 그리드 -->
<div class="metrics-row">
<div class="metric-box">
<div class="metric-icon">💰</div>
<div class="metric-label">투자 수익</div>
<div class="metric-value up">₩235만</div>
</div>
<div class="metric-box">
<div class="metric-icon">🎯</div>
<div class="metric-label">승률</div>
<div class="metric-value">68%</div>
</div>
<div class="metric-box">
<div class="metric-icon">🔄</div>
<div class="metric-label">총 거래</div>
<div class="metric-value">25회</div>
</div>
<div class="metric-box">
<div class="metric-icon">✅</div>
<div class="metric-label">수익 거래</div>
<div class="metric-value up">17회</div>
</div>
<div class="metric-box">
<div class="metric-icon">❌</div>
<div class="metric-label">손실 거래</div>
<div class="metric-value down">8회</div>
</div>
<div class="metric-box">
<div class="metric-icon">📉</div>
<div class="metric-label">최대 손실</div>
<div class="metric-value down">-8.2%</div>
</div>
</div>
<!-- 차트 + 사이드 패널 -->
<div class="chart-row">
<div class="chart-panel">
<div class="panel-header">
<span class="panel-title">📈 가격 차트 & 매매 시점</span>
<div class="tab-group">
<button class="tab-btn active">캔들</button>
<button class="tab-btn">라인</button>
<button class="tab-btn">수익곡선</button>
</div>
</div>
<div class="chart-area">
<div class="chart-grid"></div>
<div class="candles-container">
<div class="candle up" style="height: 50px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle down" style="height: 40px;"></div>
<div class="candle down" style="height: 60px;"><div class="signal-tag buy">BUY</div></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle up" style="height: 100px;"></div>
<div class="candle up" style="height: 90px;"></div>
<div class="candle down" style="height: 55px;"><div class="signal-tag sell">SELL</div></div>
<div class="candle down" style="height: 70px;"></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle down" style="height: 40px;"><div class="signal-tag buy">BUY</div></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle up" style="height: 85px;"></div>
<div class="candle up" style="height: 95px;"></div>
</div>
</div>
</div>
<div class="side-panel">
<div class="accuracy-card">
<div class="panel-title" style="margin-bottom: 16px;">🎯 전략 정확도</div>
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">정확도</div>
</div>
</div>
<div class="accuracy-bars">
<div class="acc-bar-item">
<div class="acc-bar-header">
<span style="color: #71717a;">매수 정확도</span>
<span style="color: #22c55e;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar-item">
<div class="acc-bar-header">
<span style="color: #71717a;">매도 정확도</span>
<span style="color: #a855f7;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill purple" style="width: 64%;"></div></div>
</div>
</div>
</div>
<div class="trades-panel">
<div class="panel-header" style="margin-bottom: 12px;">
<span class="panel-title">📋 최근 거래</span>
</div>
<div class="trades-list">
<div class="trade-item">
<div class="trade-icon sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58.45M</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55.52M</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54.2M</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC-05 스플릿뷰 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
.layout {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100vh;
}
/* 왼쪽: 설정 & 결과 */
.left-pane {
background: #1e293b;
padding: 32px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.logo {
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.version-tag {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
}
/* 설정 카드 */
.config-card {
background: #0f172a;
border-radius: 20px;
padding: 28px;
margin-bottom: 24px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 8px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
margin-bottom: 0;
}
.form-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 8px;
display: block;
}
.form-input {
width: 100%;
padding: 14px 16px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
color: #fff;
font-size: 14px;
}
.form-input:focus { outline: none; border-color: #3b82f6; }
.run-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none;
border-radius: 12px;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
/* 결과 헤더 */
.result-header {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(16, 185, 129, 0.05));
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 20px;
padding: 28px;
margin-bottom: 24px;
}
.result-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.result-symbol {
font-size: 24px;
font-weight: 700;
}
.result-meta {
font-size: 13px;
color: #94a3b8;
margin-top: 4px;
}
.result-return {
text-align: right;
}
.return-value {
font-size: 42px;
font-weight: 700;
color: #22c55e;
}
.return-amount {
font-size: 14px;
color: #22c55e;
}
/* 메트릭 그리드 */
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.metric-card {
background: #0f172a;
border-radius: 14px;
padding: 20px;
}
.metric-label {
font-size: 11px;
color: #64748b;
margin-bottom: 8px;
}
.metric-value {
font-size: 22px;
font-weight: 600;
}
.metric-value.green { color: #22c55e; }
.metric-value.red { color: #ef4444; }
/* 정확도 */
.accuracy-section {
background: #0f172a;
border-radius: 20px;
padding: 28px;
}
.accuracy-row {
display: flex;
align-items: center;
gap: 24px;
}
.accuracy-ring {
width: 120px;
height: 120px;
border-radius: 50%;
background: conic-gradient(#3b82f6 0deg 252deg, #334155 252deg 360deg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.accuracy-inner {
width: 90px;
height: 90px;
background: #0f172a;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.accuracy-pct {
font-size: 28px;
font-weight: 700;
color: #3b82f6;
}
.accuracy-text {
font-size: 10px;
color: #64748b;
}
.accuracy-details {
flex: 1;
}
.acc-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #1e293b;
}
.acc-item:last-child { border: none; }
.acc-label { font-size: 13px; color: #94a3b8; }
.acc-value { font-size: 15px; font-weight: 600; }
.acc-value.green { color: #22c55e; }
.acc-value.yellow { color: #eab308; }
/* 오른쪽: 차트 & 거래 */
.right-pane {
background: #0f172a;
display: flex;
flex-direction: column;
}
.chart-header {
padding: 20px 32px;
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-title {
font-size: 16px;
font-weight: 600;
}
.chart-tabs {
display: flex;
gap: 4px;
background: #1e293b;
padding: 4px;
border-radius: 8px;
}
.chart-tab {
padding: 8px 16px;
background: transparent;
border: none;
color: #64748b;
font-size: 12px;
border-radius: 6px;
cursor: pointer;
}
.chart-tab.active { background: #3b82f6; color: #fff; }
/* 차트 */
.chart-area {
flex: 1;
position: relative;
background:
linear-gradient(90deg, rgba(30, 41, 59, 0.5) 1px, transparent 1px),
linear-gradient(rgba(30, 41, 59, 0.5) 1px, transparent 1px);
background-size: 50px 35px;
}
.chart-candles {
position: absolute;
inset: 30px 60px 80px 30px;
display: flex;
align-items: flex-end;
justify-content: space-around;
}
.candle {
width: 14px;
border-radius: 2px;
position: relative;
}
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal-marker {
position: absolute;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.signal-marker.buy {
bottom: -34px;
left: 50%;
transform: translateX(-50%);
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border: 2px solid #22c55e;
}
.signal-marker.sell {
top: -34px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 2px solid #ef4444;
}
/* 거래 섹션 */
.trades-section {
height: 280px;
border-top: 1px solid #1e293b;
display: flex;
flex-direction: column;
}
.trades-header {
padding: 16px 32px;
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
}
.trades-title {
font-size: 14px;
font-weight: 600;
}
.trades-view-all {
font-size: 12px;
color: #3b82f6;
cursor: pointer;
}
.trades-list {
flex: 1;
overflow-y: auto;
padding: 16px 32px;
}
.trade-row {
display: flex;
align-items: center;
padding: 14px 16px;
background: #1e293b;
border-radius: 10px;
margin-bottom: 8px;
}
.trade-badge {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
margin-right: 14px;
}
.trade-badge.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 13px; font-weight: 500; }
.trade-time { font-size: 11px; color: #64748b; }
.trade-result { text-align: right; }
.trade-price { font-size: 13px; }
.trade-pnl { font-size: 12px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
</style>
</head>
<body>
<div class="layout">
<!-- 왼쪽 패널 -->
<div class="left-pane">
<div class="pane-header">
<div class="logo">📊 백테스트 시뮬레이터</div>
<span class="version-tag">PC-05 스플릿뷰</span>
</div>
<div class="config-card">
<h3 class="card-title">⚙️ 테스트 설정</h3>
<div class="form-grid">
<div class="form-group">
<label class="form-label">종목</label>
<select class="form-input"><option>BTC/KRW</option></select>
</div>
<div class="form-group">
<label class="form-label">전략</label>
<select class="form-input"><option>MACD + RSI</option></select>
</div>
<div class="form-group">
<label class="form-label">시간봉</label>
<select class="form-input"><option>1시간</option></select>
</div>
<div class="form-group">
<label class="form-label">분석기간</label>
<select class="form-input"><option>3개월</option></select>
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<label class="form-label">초기 투자금</label>
<input type="text" class="form-input" value="₩10,000,000">
</div>
<button class="run-btn">▶ 백테스트 실행</button>
</div>
<div class="result-header">
<div class="result-row">
<div>
<div class="result-symbol">BTC/KRW</div>
<div class="result-meta">MACD+RSI · 1H · 2024.01.01 - 03.15</div>
</div>
<div class="result-return">
<div class="return-value">+23.5%</div>
<div class="return-amount">₩2,350,000</div>
</div>
</div>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">최종 자산</div>
<div class="metric-value">₩12.35M</div>
</div>
<div class="metric-card">
<div class="metric-label">승률</div>
<div class="metric-value green">68%</div>
</div>
<div class="metric-card">
<div class="metric-label">총 거래</div>
<div class="metric-value">25회</div>
</div>
<div class="metric-card">
<div class="metric-label">수익 거래</div>
<div class="metric-value green">17회</div>
</div>
<div class="metric-card">
<div class="metric-label">손실 거래</div>
<div class="metric-value red">8회</div>
</div>
<div class="metric-card">
<div class="metric-label">최대 낙폭</div>
<div class="metric-value red">-8.2%</div>
</div>
</div>
<div class="accuracy-section">
<h3 class="card-title">🎯 전략 정확도</h3>
<div class="accuracy-row">
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">정확도</div>
</div>
</div>
<div class="accuracy-details">
<div class="acc-item">
<span class="acc-label">매수 신호 정확도</span>
<span class="acc-value green">76%</span>
</div>
<div class="acc-item">
<span class="acc-label">매도 신호 정확도</span>
<span class="acc-value yellow">64%</span>
</div>
<div class="acc-item">
<span class="acc-label">평균 수익률</span>
<span class="acc-value green">+4.2%</span>
</div>
</div>
</div>
</div>
</div>
<!-- 오른쪽 패널 -->
<div class="right-pane">
<div class="chart-header">
<span class="chart-title">📈 가격 차트 & 매매 시점</span>
<div class="chart-tabs">
<button class="chart-tab active">캔들</button>
<button class="chart-tab">라인</button>
<button class="chart-tab">수익곡선</button>
</div>
</div>
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 55px;"></div>
<div class="candle up" style="height: 75px;"></div>
<div class="candle down" style="height: 45px;"></div>
<div class="candle down" style="height: 65px;"><div class="signal-marker buy">B</div></div>
<div class="candle up" style="height: 85px;"></div>
<div class="candle up" style="height: 110px;"></div>
<div class="candle up" style="height: 95px;"></div>
<div class="candle down" style="height: 60px;"><div class="signal-marker sell">S</div></div>
<div class="candle down" style="height: 75px;"></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal-marker buy">B</div></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 90px;"></div>
<div class="candle up" style="height: 100px;"></div>
<div class="candle down" style="height: 50px;"></div>
</div>
</div>
<div class="trades-section">
<div class="trades-header">
<span class="trades-title">📋 거래 내역</span>
<span class="trades-view-all">전체보기 →</span>
</div>
<div class="trades-list">
<div class="trade-row">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">2024.03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-row">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">2024.03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-row">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">2024.03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54,200,000</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,615 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>투자전략 테스트 UI - V1 대시보드 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.header h1 {
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.header h1::before {
content: "📊";
font-size: 28px;
}
.version-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 6px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
/* 컨트롤 패널 */
.control-panel {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid rgba(255,255,255,0.1);
}
.control-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.control-group {
flex: 1;
min-width: 180px;
}
.control-group label {
display: block;
font-size: 12px;
color: #a0aec0;
margin-bottom: 8px;
font-weight: 500;
}
.control-group select, .control-group input {
width: 100%;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.1);
color: #fff;
font-size: 14px;
cursor: pointer;
}
.control-group select:focus, .control-group input:focus {
outline: none;
border-color: #667eea;
}
.btn-run {
padding: 12px 32px;
background: linear-gradient(135deg, #00d2ff 0%, #3a7bd5 100%);
border: none;
border-radius: 10px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-run:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,210,255,0.3);
}
/* 메트릭 카드 */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
transition: transform 0.2s;
}
.metric-card:hover {
transform: translateY(-4px);
}
.metric-card.positive { border-left: 4px solid #10b981; }
.metric-card.negative { border-left: 4px solid #ef4444; }
.metric-card.neutral { border-left: 4px solid #667eea; }
.metric-card.warning { border-left: 4px solid #f59e0b; }
.metric-label {
font-size: 12px;
color: #a0aec0;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.metric-value {
font-size: 28px;
font-weight: 700;
}
.metric-value.positive { color: #10b981; }
.metric-value.negative { color: #ef4444; }
.metric-sub {
font-size: 12px;
color: #a0aec0;
margin-top: 4px;
}
/* 메인 콘텐츠 */
.main-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
/* 차트 영역 */
.chart-section {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
}
.chart-tabs {
display: flex;
gap: 8px;
}
.chart-tab {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
background: rgba(255,255,255,0.1);
border: none;
color: #a0aec0;
}
.chart-tab.active {
background: #667eea;
color: #fff;
}
.chart-placeholder {
height: 400px;
background: linear-gradient(180deg, rgba(102,126,234,0.1) 0%, rgba(118,75,162,0.1) 100%);
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
}
/* 캔들차트 목업 */
.candle-chart {
width: 100%;
height: 100%;
position: relative;
padding: 20px;
}
.candle-row {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 70%;
padding: 0 20px;
}
.candle {
width: 8px;
position: relative;
}
.candle-body {
width: 100%;
min-height: 20px;
border-radius: 2px;
}
.candle-body.up { background: #10b981; }
.candle-body.down { background: #ef4444; }
.candle-wick {
width: 2px;
background: currentColor;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.signal-marker {
position: absolute;
font-size: 20px;
animation: bounce 1s infinite;
}
.signal-marker.buy { bottom: -30px; color: #10b981; }
.signal-marker.sell { top: -30px; color: #ef4444; }
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.chart-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #a0aec0;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
/* 사이드바 */
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 정확도 게이지 */
.accuracy-card {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
text-align: center;
}
.accuracy-title {
font-size: 14px;
color: #a0aec0;
margin-bottom: 16px;
}
.accuracy-gauge {
width: 150px;
height: 150px;
margin: 0 auto;
position: relative;
}
.gauge-circle {
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(
#10b981 0deg 252deg,
rgba(255,255,255,0.1) 252deg 360deg
);
display: flex;
align-items: center;
justify-content: center;
}
.gauge-inner {
width: 120px;
height: 120px;
background: #1a1a2e;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.gauge-value {
font-size: 32px;
font-weight: 700;
color: #10b981;
}
.gauge-label {
font-size: 12px;
color: #a0aec0;
}
/* 거래 내역 */
.trades-card {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
flex: 1;
}
.trades-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.trade-list {
max-height: 300px;
overflow-y: auto;
}
.trade-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: rgba(255,255,255,0.03);
border-radius: 8px;
margin-bottom: 8px;
}
.trade-type {
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.trade-type.buy { background: rgba(16,185,129,0.2); color: #10b981; }
.trade-type.sell { background: rgba(239,68,68,0.2); color: #ef4444; }
.trade-info {
text-align: right;
}
.trade-price {
font-size: 13px;
font-weight: 500;
}
.trade-date {
font-size: 11px;
color: #a0aec0;
}
.trade-profit {
font-size: 12px;
font-weight: 600;
}
.trade-profit.positive { color: #10b981; }
.trade-profit.negative { color: #ef4444; }
/* 반응형 */
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 1fr;
}
.sidebar {
flex-direction: row;
flex-wrap: wrap;
}
.accuracy-card, .trades-card {
flex: 1;
min-width: 300px;
}
}
@media (max-width: 768px) {
.control-row {
flex-direction: column;
}
.control-group {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>투자전략 백테스트</h1>
<span class="version-badge">V1 - 대시보드 스타일</span>
</header>
<!-- 컨트롤 패널 -->
<section class="control-panel">
<div class="control-row">
<div class="control-group">
<label>📈 종목 선택</label>
<select>
<option>BTC/KRW (비트코인)</option>
<option>ETH/KRW (이더리움)</option>
<option>XRP/KRW (리플)</option>
</select>
</div>
<div class="control-group">
<label>🎯 투자전략</label>
<select>
<option>MACD + RSI 복합전략</option>
<option>볼린저밴드 돌파전략</option>
<option>이동평균 크로스</option>
</select>
</div>
<div class="control-group">
<label>⏰ 시간봉</label>
<select>
<option>1시간봉</option>
<option>4시간봉</option>
<option>일봉</option>
</select>
</div>
<div class="control-group">
<label>📅 분석 기간</label>
<select>
<option>최근 1개월</option>
<option>최근 3개월</option>
<option>최근 6개월</option>
</select>
</div>
<div class="control-group">
<label>💰 초기 투자금</label>
<input type="text" value="10,000,000 원">
</div>
<button class="btn-run">🚀 백테스트 실행</button>
</div>
</section>
<!-- 메트릭 카드 -->
<section class="metrics-grid">
<div class="metric-card positive">
<div class="metric-label">💰 총 수익률</div>
<div class="metric-value positive">+23.5%</div>
<div class="metric-sub">초기 대비 +2,350,000원</div>
</div>
<div class="metric-card positive">
<div class="metric-label">📊 최종 자산</div>
<div class="metric-value">12,350,000</div>
<div class="metric-sub">원금 10,000,000원</div>
</div>
<div class="metric-card neutral">
<div class="metric-label">🎯 승률</div>
<div class="metric-value">68%</div>
<div class="metric-sub">17승 8패</div>
</div>
<div class="metric-card warning">
<div class="metric-label">📉 최대 낙폭</div>
<div class="metric-value negative">-8.2%</div>
<div class="metric-sub">2024.01.15 기록</div>
</div>
<div class="metric-card neutral">
<div class="metric-label">🔄 총 거래</div>
<div class="metric-value">25</div>
<div class="metric-sub">매수 13회 / 매도 12회</div>
</div>
<div class="metric-card positive">
<div class="metric-label">⚡ 평균 수익</div>
<div class="metric-value positive">+94,000</div>
<div class="metric-sub">거래당 평균</div>
</div>
</section>
<!-- 메인 콘텐츠 -->
<section class="main-content">
<!-- 차트 영역 -->
<div class="chart-section">
<div class="chart-header">
<h3 class="chart-title">📈 BTC/KRW 가격 차트 & 매매 시점</h3>
<div class="chart-tabs">
<button class="chart-tab active">캔들</button>
<button class="chart-tab">라인</button>
<button class="chart-tab">수익곡선</button>
</div>
</div>
<div class="chart-placeholder">
<div class="candle-chart">
<div class="candle-row">
<div class="candle" style="height: 60px;"><div class="candle-body up" style="height: 40px;"></div></div>
<div class="candle" style="height: 80px;"><div class="candle-body up" style="height: 50px;"></div></div>
<div class="candle" style="height: 70px;"><div class="candle-body down" style="height: 45px;"></div></div>
<div class="candle" style="height: 50px;"><div class="candle-body up" style="height: 30px;"></div><span class="signal-marker buy">▲</span></div>
<div class="candle" style="height: 90px;"><div class="candle-body up" style="height: 60px;"></div></div>
<div class="candle" style="height: 100px;"><div class="candle-body up" style="height: 70px;"></div></div>
<div class="candle" style="height: 85px;"><div class="candle-body down" style="height: 55px;"></div></div>
<div class="candle" style="height: 95px;"><div class="candle-body up" style="height: 65px;"></div><span class="signal-marker sell">▼</span></div>
<div class="candle" style="height: 75px;"><div class="candle-body down" style="height: 50px;"></div></div>
<div class="candle" style="height: 65px;"><div class="candle-body down" style="height: 40px;"></div></div>
<div class="candle" style="height: 55px;"><div class="candle-body up" style="height: 35px;"></div><span class="signal-marker buy">▲</span></div>
<div class="candle" style="height: 70px;"><div class="candle-body up" style="height: 45px;"></div></div>
<div class="candle" style="height: 85px;"><div class="candle-body up" style="height: 55px;"></div></div>
<div class="candle" style="height: 80px;"><div class="candle-body down" style="height: 50px;"></div></div>
<div class="candle" style="height: 90px;"><div class="candle-body up" style="height: 60px;"></div></div>
</div>
</div>
</div>
<div class="chart-legend">
<div class="legend-item"><div class="legend-dot" style="background: #10b981;"></div> 매수 시점</div>
<div class="legend-item"><div class="legend-dot" style="background: #ef4444;"></div> 매도 시점</div>
<div class="legend-item"><div class="legend-dot" style="background: #667eea;"></div> MA(20)</div>
<div class="legend-item"><div class="legend-dot" style="background: #f59e0b;"></div> MA(60)</div>
</div>
</div>
<!-- 사이드바 -->
<div class="sidebar">
<!-- 예측 정확도 -->
<div class="accuracy-card">
<div class="accuracy-title">🎯 전략 예측 정확도</div>
<div class="accuracy-gauge">
<div class="gauge-circle">
<div class="gauge-inner">
<div class="gauge-value">70%</div>
<div class="gauge-label">정확도</div>
</div>
</div>
</div>
<div style="margin-top: 16px; text-align: left;">
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 8px;">
<span style="color: #a0aec0;">매수 정확도</span>
<span style="color: #10b981; font-weight: 600;">76%</span>
</div>
<div style="display: flex; justify-content: space-between; font-size: 12px;">
<span style="color: #a0aec0;">매도 정확도</span>
<span style="color: #ef4444; font-weight: 600;">64%</span>
</div>
</div>
</div>
<!-- 거래 내역 -->
<div class="trades-card">
<div class="trades-title">📋 최근 거래 내역</div>
<div class="trade-list">
<div class="trade-item">
<div>
<span class="trade-type sell">SELL</span>
</div>
<div class="trade-info">
<div class="trade-price">58,450,000원</div>
<div class="trade-date">2024.03.15 14:00</div>
</div>
<div class="trade-profit positive">+5.2%</div>
</div>
<div class="trade-item">
<div>
<span class="trade-type buy">BUY</span>
</div>
<div class="trade-info">
<div class="trade-price">55,520,000원</div>
<div class="trade-date">2024.03.12 09:00</div>
</div>
<div class="trade-profit">-</div>
</div>
<div class="trade-item">
<div>
<span class="trade-type sell">SELL</span>
</div>
<div class="trade-info">
<div class="trade-price">54,200,000원</div>
<div class="trade-date">2024.03.08 16:00</div>
</div>
<div class="trade-profit negative">-2.1%</div>
</div>
<div class="trade-item">
<div>
<span class="trade-type buy">BUY</span>
</div>
<div class="trade-info">
<div class="trade-price">55,350,000원</div>
<div class="trade-date">2024.03.05 11:00</div>
</div>
<div class="trade-profit">-</div>
</div>
<div class="trade-item">
<div>
<span class="trade-type sell">SELL</span>
</div>
<div class="trade-info">
<div class="trade-price">52,800,000원</div>
<div class="trade-date">2024.03.01 10:00</div>
</div>
<div class="trade-profit positive">+8.3%</div>
</div>
</div>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,775 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>투자전략 테스트 UI - V2 차트 중심 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background: #0f0f23;
min-height: 100vh;
color: #e0e0e0;
}
/* 헤더 */
.top-bar {
background: linear-gradient(90deg, #1a1a3e 0%, #2d1b4e 100%);
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo {
font-size: 20px;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.logo::before {
content: "💹";
font-size: 24px;
}
.version-badge {
background: rgba(139, 92, 246, 0.3);
color: #a78bfa;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
/* 메인 레이아웃 */
.main-layout {
display: flex;
height: calc(100vh - 56px);
}
/* 왼쪽 사이드바 */
.left-sidebar {
width: 280px;
background: #141428;
border-right: 1px solid rgba(255,255,255,0.1);
padding: 20px;
overflow-y: auto;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-title {
font-size: 11px;
text-transform: uppercase;
color: #6b7280;
letter-spacing: 1px;
margin-bottom: 12px;
font-weight: 600;
}
.input-group {
margin-bottom: 12px;
}
.input-group label {
display: block;
font-size: 12px;
color: #9ca3af;
margin-bottom: 6px;
}
.input-group select, .input-group input {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05);
color: #fff;
font-size: 13px;
}
.input-group select:focus, .input-group input:focus {
outline: none;
border-color: #8b5cf6;
}
.btn-execute {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border: none;
border-radius: 10px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
transition: all 0.3s;
}
.btn-execute:hover {
transform: scale(1.02);
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.4);
}
/* 성과 요약 카드 */
.performance-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.perf-card {
background: rgba(255,255,255,0.03);
border-radius: 10px;
padding: 14px;
border: 1px solid rgba(255,255,255,0.08);
}
.perf-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.perf-label {
font-size: 11px;
color: #6b7280;
}
.perf-icon {
font-size: 16px;
}
.perf-value {
font-size: 22px;
font-weight: 700;
}
.perf-value.up { color: #10b981; }
.perf-value.down { color: #ef4444; }
.perf-sub {
font-size: 11px;
color: #6b7280;
margin-top: 4px;
}
/* 중앙 차트 영역 */
.chart-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
gap: 12px;
}
/* 심볼 헤더 */
.symbol-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255,255,255,0.03);
border-radius: 12px;
}
.symbol-info {
display: flex;
align-items: center;
gap: 16px;
}
.symbol-name {
font-size: 20px;
font-weight: 700;
}
.symbol-price {
font-size: 24px;
font-weight: 700;
color: #10b981;
}
.symbol-change {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.symbol-change.up { color: #10b981; }
.symbol-change.down { color: #ef4444; }
.timeframe-tabs {
display: flex;
gap: 4px;
background: rgba(255,255,255,0.05);
padding: 4px;
border-radius: 8px;
}
.tf-tab {
padding: 6px 12px;
border: none;
background: transparent;
color: #6b7280;
font-size: 12px;
cursor: pointer;
border-radius: 6px;
}
.tf-tab.active {
background: #8b5cf6;
color: #fff;
}
/* 메인 차트 */
.main-chart {
flex: 1;
background: rgba(255,255,255,0.02);
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
position: relative;
overflow: hidden;
}
.chart-toolbar {
position: absolute;
top: 12px;
left: 12px;
display: flex;
gap: 8px;
z-index: 10;
}
.chart-tool {
width: 32px;
height: 32px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 14px;
}
.chart-tool:hover {
background: rgba(139, 92, 246, 0.3);
color: #fff;
}
/* 차트 목업 (캔들스틱 + 매매신호) */
.chart-mock {
width: 100%;
height: 100%;
position: relative;
background:
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 60px 40px;
}
.price-axis {
position: absolute;
right: 0;
top: 0;
bottom: 60px;
width: 80px;
background: rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px 8px;
font-size: 11px;
color: #6b7280;
}
.time-axis {
position: absolute;
bottom: 0;
left: 0;
right: 80px;
height: 60px;
background: rgba(0,0,0,0.3);
display: flex;
justify-content: space-around;
align-items: center;
padding: 0 40px;
font-size: 11px;
color: #6b7280;
}
.candles-area {
position: absolute;
top: 20px;
left: 40px;
right: 100px;
bottom: 80px;
display: flex;
align-items: flex-end;
justify-content: space-around;
}
.mock-candle {
width: 12px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.mock-candle-body {
width: 100%;
border-radius: 2px;
}
.mock-candle-body.bullish { background: #10b981; }
.mock-candle-body.bearish { background: #ef4444; }
.mock-wick {
width: 2px;
background: inherit;
}
.signal-dot {
position: absolute;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
animation: pulse 2s infinite;
}
.signal-dot.buy {
bottom: -35px;
background: rgba(16, 185, 129, 0.3);
border: 2px solid #10b981;
color: #10b981;
}
.signal-dot.sell {
top: -35px;
background: rgba(239, 68, 68, 0.3);
border: 2px solid #ef4444;
color: #ef4444;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
/* 보조지표 차트 */
.indicator-charts {
height: 120px;
display: flex;
gap: 12px;
}
.indicator-panel {
flex: 1;
background: rgba(255,255,255,0.02);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.08);
padding: 12px;
}
.indicator-title {
font-size: 11px;
color: #6b7280;
margin-bottom: 8px;
}
.indicator-chart-mock {
height: calc(100% - 24px);
background: linear-gradient(180deg, rgba(139, 92, 246, 0.1) 0%, transparent 50%, rgba(239, 68, 68, 0.1) 100%);
border-radius: 6px;
position: relative;
}
.indicator-line {
position: absolute;
top: 50%;
left: 10%;
right: 10%;
height: 2px;
background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981, #8b5cf6);
border-radius: 1px;
}
/* 오른쪽 패널 */
.right-panel {
width: 320px;
background: #141428;
border-left: 1px solid rgba(255,255,255,0.1);
display: flex;
flex-direction: column;
}
.panel-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.panel-tab {
flex: 1;
padding: 14px;
background: transparent;
border: none;
color: #6b7280;
font-size: 13px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.panel-tab.active {
color: #fff;
border-bottom-color: #8b5cf6;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* 통계 그리드 */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.stat-box {
background: rgba(255,255,255,0.03);
border-radius: 10px;
padding: 14px;
text-align: center;
}
.stat-value {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-label {
font-size: 11px;
color: #6b7280;
}
/* 정확도 바 */
.accuracy-section {
margin-bottom: 20px;
}
.accuracy-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.accuracy-bar-bg {
height: 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
overflow: hidden;
}
.accuracy-bar {
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #10b981);
border-radius: 4px;
}
/* 거래 리스트 */
.trade-list-header {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: #9ca3af;
}
.trade-entry {
display: flex;
align-items: center;
padding: 12px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
margin-bottom: 8px;
gap: 12px;
}
.trade-entry-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.trade-entry-icon.buy {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.trade-entry-icon.sell {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.trade-entry-details {
flex: 1;
}
.trade-entry-price {
font-size: 13px;
font-weight: 500;
}
.trade-entry-time {
font-size: 11px;
color: #6b7280;
}
.trade-entry-result {
text-align: right;
}
.trade-entry-pnl {
font-size: 13px;
font-weight: 600;
}
.trade-entry-pnl.profit { color: #10b981; }
.trade-entry-pnl.loss { color: #ef4444; }
.trade-entry-return {
font-size: 11px;
color: #6b7280;
}
</style>
</head>
<body>
<div class="top-bar">
<div class="logo">투자전략 백테스터</div>
<span class="version-badge">V2 - 차트 중심 스타일</span>
</div>
<div class="main-layout">
<!-- 왼쪽 사이드바 -->
<aside class="left-sidebar">
<div class="sidebar-section">
<div class="sidebar-title">📊 백테스트 설정</div>
<div class="input-group">
<label>종목</label>
<select>
<option>BTC/KRW</option>
<option>ETH/KRW</option>
<option>XRP/KRW</option>
</select>
</div>
<div class="input-group">
<label>투자전략</label>
<select>
<option>MACD 크로스오버</option>
<option>RSI 과매수/과매도</option>
<option>볼린저밴드</option>
</select>
</div>
<div class="input-group">
<label>시간 프레임</label>
<select>
<option>1시간</option>
<option>4시간</option>
<option>1일</option>
</select>
</div>
<div class="input-group">
<label>분석 기간</label>
<input type="text" value="2024.01.01 - 2024.03.15">
</div>
<div class="input-group">
<label>초기 자본</label>
<input type="text" value="₩10,000,000">
</div>
<button class="btn-execute">▶ 백테스트 실행</button>
</div>
<div class="sidebar-section">
<div class="sidebar-title">📈 성과 요약</div>
<div class="performance-cards">
<div class="perf-card">
<div class="perf-card-header">
<span class="perf-label">총 수익률</span>
<span class="perf-icon">📊</span>
</div>
<div class="perf-value up">+18.7%</div>
<div class="perf-sub">₩1,870,000 수익</div>
</div>
<div class="perf-card">
<div class="perf-card-header">
<span class="perf-label">최종 자산</span>
<span class="perf-icon">💰</span>
</div>
<div class="perf-value">₩11,870,000</div>
<div class="perf-sub">원금 대비 +18.7%</div>
</div>
<div class="perf-card">
<div class="perf-card-header">
<span class="perf-label">최대 낙폭</span>
<span class="perf-icon">📉</span>
</div>
<div class="perf-value down">-6.3%</div>
<div class="perf-sub">2024.02.05 기록</div>
</div>
</div>
</div>
</aside>
<!-- 중앙 차트 영역 -->
<main class="chart-area">
<div class="symbol-header">
<div class="symbol-info">
<span class="symbol-name">BTC/KRW</span>
<span class="symbol-price">₩58,450,000</span>
<span class="symbol-change up">▲ +2.34% (+1,340,000)</span>
</div>
<div class="timeframe-tabs">
<button class="tf-tab">15분</button>
<button class="tf-tab active">1시간</button>
<button class="tf-tab">4시간</button>
<button class="tf-tab">1일</button>
</div>
</div>
<div class="main-chart">
<div class="chart-toolbar">
<button class="chart-tool">✏️</button>
<button class="chart-tool">📏</button>
<button class="chart-tool">🔍</button>
<button class="chart-tool">📷</button>
</div>
<div class="chart-mock">
<div class="price-axis">
<span>62,000,000</span>
<span>60,000,000</span>
<span>58,000,000</span>
<span>56,000,000</span>
<span>54,000,000</span>
</div>
<div class="time-axis">
<span>03/10</span>
<span>03/11</span>
<span>03/12</span>
<span>03/13</span>
<span>03/14</span>
<span>03/15</span>
</div>
<div class="candles-area">
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 45px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 30px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bearish" style="height: 55px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bearish" style="height: 40px;"></div><div class="signal-dot buy">B</div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 50px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 70px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 60px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bearish" style="height: 35px;"></div><div class="signal-dot sell">S</div></div>
<div class="mock-candle"><div class="mock-candle-body bearish" style="height: 50px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 40px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bearish" style="height: 25px;"></div><div class="signal-dot buy">B</div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 55px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 65px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bullish" style="height: 75px;"></div></div>
<div class="mock-candle"><div class="mock-candle-body bearish" style="height: 30px;"></div></div>
</div>
</div>
</div>
<div class="indicator-charts">
<div class="indicator-panel">
<div class="indicator-title">MACD (12, 26, 9)</div>
<div class="indicator-chart-mock">
<div class="indicator-line"></div>
</div>
</div>
<div class="indicator-panel">
<div class="indicator-title">RSI (14)</div>
<div class="indicator-chart-mock">
<div class="indicator-line"></div>
</div>
</div>
<div class="indicator-panel">
<div class="indicator-title">Volume</div>
<div class="indicator-chart-mock">
<div class="indicator-line"></div>
</div>
</div>
</div>
</main>
<!-- 오른쪽 패널 -->
<aside class="right-panel">
<div class="panel-tabs">
<button class="panel-tab active">통계</button>
<button class="panel-tab">거래내역</button>
<button class="panel-tab">분석</button>
</div>
<div class="panel-content">
<div class="stats-grid">
<div class="stat-box">
<div class="stat-value" style="color: #10b981;">68%</div>
<div class="stat-label">승률</div>
</div>
<div class="stat-box">
<div class="stat-value">23</div>
<div class="stat-label">총 거래</div>
</div>
<div class="stat-box">
<div class="stat-value" style="color: #10b981;">15</div>
<div class="stat-label">수익 거래</div>
</div>
<div class="stat-box">
<div class="stat-value" style="color: #ef4444;">8</div>
<div class="stat-label">손실 거래</div>
</div>
</div>
<div class="accuracy-section">
<div class="accuracy-header">
<span style="font-size: 12px; color: #9ca3af;">전략 정확도</span>
<span style="font-size: 14px; font-weight: 600; color: #10b981;">72%</span>
</div>
<div class="accuracy-bar-bg">
<div class="accuracy-bar" style="width: 72%;"></div>
</div>
</div>
<div class="accuracy-section">
<div class="accuracy-header">
<span style="font-size: 12px; color: #9ca3af;">매수 정확도</span>
<span style="font-size: 14px; font-weight: 600; color: #8b5cf6;">78%</span>
</div>
<div class="accuracy-bar-bg">
<div class="accuracy-bar" style="width: 78%; background: #8b5cf6;"></div>
</div>
</div>
<div class="accuracy-section">
<div class="accuracy-header">
<span style="font-size: 12px; color: #9ca3af;">매도 정확도</span>
<span style="font-size: 14px; font-weight: 600; color: #f59e0b;">65%</span>
</div>
<div class="accuracy-bar-bg">
<div class="accuracy-bar" style="width: 65%; background: #f59e0b;"></div>
</div>
</div>
<div class="trade-list-header">최근 거래</div>
<div class="trade-entry">
<div class="trade-entry-icon sell">S</div>
<div class="trade-entry-details">
<div class="trade-entry-price">₩58,450,000</div>
<div class="trade-entry-time">2024.03.15 14:00</div>
</div>
<div class="trade-entry-result">
<div class="trade-entry-pnl profit">+₩520,000</div>
<div class="trade-entry-return">+4.8%</div>
</div>
</div>
<div class="trade-entry">
<div class="trade-entry-icon buy">B</div>
<div class="trade-entry-details">
<div class="trade-entry-price">₩55,800,000</div>
<div class="trade-entry-time">2024.03.12 09:00</div>
</div>
<div class="trade-entry-result">
<div class="trade-entry-pnl">진행중</div>
<div class="trade-entry-return">-</div>
</div>
</div>
<div class="trade-entry">
<div class="trade-entry-icon sell">S</div>
<div class="trade-entry-details">
<div class="trade-entry-price">₩54,200,000</div>
<div class="trade-entry-time">2024.03.08 16:00</div>
</div>
<div class="trade-entry-result">
<div class="trade-entry-pnl loss">-₩180,000</div>
<div class="trade-entry-return">-1.8%</div>
</div>
</div>
<div class="trade-entry">
<div class="trade-entry-icon buy">B</div>
<div class="trade-entry-details">
<div class="trade-entry-price">₩55,200,000</div>
<div class="trade-entry-time">2024.03.05 11:00</div>
</div>
<div class="trade-entry-result">
<div class="trade-entry-pnl">-</div>
<div class="trade-entry-return">-</div>
</div>
</div>
</div>
</aside>
</div>
</body>
</html>
@@ -0,0 +1,911 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>투자전략 테스트 UI - V3 탭 기반 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background: #f8fafc;
min-height: 100vh;
color: #1e293b;
}
/* 헤더 */
.header {
background: #fff;
border-bottom: 1px solid #e2e8f0;
padding: 16px 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo {
font-size: 22px;
font-weight: 700;
color: #0f172a;
display: flex;
align-items: center;
gap: 10px;
}
.logo svg {
width: 32px;
height: 32px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #64748b;
}
.breadcrumb a {
color: #3b82f6;
text-decoration: none;
}
.version-badge {
background: #dbeafe;
color: #1d4ed8;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
/* 컨테이너 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* 설정 카드 */
.settings-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 24px;
margin-bottom: 24px;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.settings-title {
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #475569;
margin-bottom: 8px;
}
.form-group select, .form-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 14px;
background: #f8fafc;
color: #0f172a;
}
.form-group select:focus, .form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.btn-primary {
padding: 12px 28px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 10px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* 결과 요약 스트립 */
.summary-strip {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.summary-item {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
position: relative;
overflow: hidden;
}
.summary-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
}
.summary-item.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
.summary-item.red::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.summary-item.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.summary-item.purple::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.summary-item.orange::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.summary-label {
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
}
.summary-value {
font-size: 24px;
font-weight: 700;
}
.summary-value.positive { color: #10b981; }
.summary-value.negative { color: #ef4444; }
.summary-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
/* 메인 탭 네비게이션 */
.main-tabs {
background: #fff;
border-radius: 16px 16px 0 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
display: flex;
border-bottom: 1px solid #e2e8f0;
}
.main-tab {
padding: 18px 28px;
font-size: 14px;
font-weight: 500;
color: #64748b;
background: none;
border: none;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
gap: 8px;
transition: color 0.2s;
}
.main-tab:hover {
color: #3b82f6;
}
.main-tab.active {
color: #3b82f6;
font-weight: 600;
}
.main-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 3px;
background: #3b82f6;
border-radius: 3px 3px 0 0;
}
.tab-badge {
background: #dbeafe;
color: #2563eb;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
/* 탭 콘텐츠 */
.tab-content {
background: #fff;
border-radius: 0 0 16px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
min-height: 500px;
}
/* 차트 탭 */
.chart-content {
padding: 24px;
}
.chart-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.chart-types {
display: flex;
gap: 8px;
}
.chart-type-btn {
padding: 8px 16px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 8px;
font-size: 13px;
color: #64748b;
cursor: pointer;
}
.chart-type-btn.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.chart-timeframes {
display: flex;
gap: 4px;
background: #f1f5f9;
padding: 4px;
border-radius: 8px;
}
.tf-btn {
padding: 6px 14px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 12px;
color: #64748b;
cursor: pointer;
}
.tf-btn.active {
background: #fff;
color: #0f172a;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.chart-area {
height: 400px;
background: #fafbfc;
border: 1px solid #e2e8f0;
border-radius: 12px;
position: relative;
overflow: hidden;
}
/* 모의 차트 그리드 */
.chart-grid {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px),
linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px);
background-size: 50px 30px;
}
.chart-y-axis {
position: absolute;
left: 0;
top: 0;
bottom: 40px;
width: 70px;
background: rgba(255,255,255,0.9);
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 8px;
font-size: 11px;
color: #64748b;
text-align: right;
}
.chart-x-axis {
position: absolute;
bottom: 0;
left: 70px;
right: 0;
height: 40px;
background: rgba(255,255,255,0.9);
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 11px;
color: #64748b;
}
.chart-candles {
position: absolute;
top: 20px;
left: 90px;
right: 20px;
bottom: 60px;
display: flex;
align-items: flex-end;
justify-content: space-around;
gap: 4px;
}
.candle {
width: 14px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.candle-body {
width: 100%;
border-radius: 2px;
}
.candle-body.bull { background: #10b981; }
.candle-body.bear { background: #ef4444; }
.signal-marker {
position: absolute;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.signal-marker.buy {
bottom: -40px;
background: #dcfce7;
color: #16a34a;
border: 2px solid #16a34a;
}
.signal-marker.sell {
top: -40px;
background: #fef2f2;
color: #dc2626;
border: 2px solid #dc2626;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 32px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #64748b;
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 4px;
}
/* 거래 내역 탭 */
.trades-content {
padding: 24px;
}
.trades-filter {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.filter-btn {
padding: 8px 16px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 8px;
font-size: 13px;
color: #64748b;
cursor: pointer;
}
.filter-btn.active {
background: #f0fdf4;
border-color: #10b981;
color: #059669;
}
.trades-table {
width: 100%;
border-collapse: collapse;
}
.trades-table th {
text-align: left;
padding: 14px 16px;
font-size: 12px;
font-weight: 600;
color: #64748b;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.trades-table td {
padding: 16px;
font-size: 14px;
border-bottom: 1px solid #f1f5f9;
}
.trades-table tr:hover {
background: #fafbfc;
}
.trade-type-badge {
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.trade-type-badge.buy {
background: #dcfce7;
color: #16a34a;
}
.trade-type-badge.sell {
background: #fef2f2;
color: #dc2626;
}
.profit-badge {
font-weight: 600;
}
.profit-badge.positive { color: #10b981; }
.profit-badge.negative { color: #ef4444; }
/* 분석 탭 */
.analysis-content {
padding: 24px;
}
.analysis-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.analysis-card {
background: #f8fafc;
border-radius: 12px;
padding: 24px;
}
.analysis-card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
/* 정확도 차트 */
.accuracy-chart {
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
}
.accuracy-ring {
width: 160px;
height: 160px;
border-radius: 50%;
background: conic-gradient(
#10b981 0deg 259.2deg,
#e2e8f0 259.2deg 360deg
);
display: flex;
align-items: center;
justify-content: center;
}
.accuracy-inner {
width: 120px;
height: 120px;
background: #f8fafc;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.accuracy-value {
font-size: 36px;
font-weight: 700;
color: #10b981;
}
.accuracy-label {
font-size: 12px;
color: #64748b;
}
.accuracy-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.accuracy-detail-item {
display: flex;
align-items: center;
gap: 12px;
}
.accuracy-detail-bar {
width: 100px;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.accuracy-detail-fill {
height: 100%;
border-radius: 4px;
}
/* 리스크 분석 */
.risk-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.risk-item {
background: #fff;
padding: 16px;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.risk-item-label {
font-size: 12px;
color: #64748b;
margin-bottom: 6px;
}
.risk-item-value {
font-size: 20px;
font-weight: 700;
}
/* 반응형 */
@media (max-width: 1200px) {
.summary-strip {
grid-template-columns: repeat(3, 1fr);
}
.analysis-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.summary-strip {
grid-template-columns: repeat(2, 1fr);
}
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-left">
<div class="logo">
<svg viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#3b82f6"/>
<path d="M8 22L14 14L18 18L24 10" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
투자전략 테스터
</div>
<div class="breadcrumb">
<a href="#">대시보드</a>
<span>/</span>
<span>백테스트</span>
</div>
</div>
<span class="version-badge">V3 - 탭 기반 스타일</span>
</header>
<main class="container">
<!-- 설정 카드 -->
<div class="settings-card">
<div class="settings-header">
<h2 class="settings-title">
⚙️ 백테스트 설정
</h2>
<button class="btn-primary">
<span>▶</span> 백테스트 실행
</button>
</div>
<div class="settings-grid">
<div class="form-group">
<label>종목 선택</label>
<select>
<option>BTC/KRW (비트코인)</option>
<option>ETH/KRW (이더리움)</option>
<option>XRP/KRW (리플)</option>
</select>
</div>
<div class="form-group">
<label>투자전략</label>
<select>
<option>MACD + Stochastic 복합전략</option>
<option>RSI 과매수/과매도</option>
<option>이동평균선 크로스</option>
</select>
</div>
<div class="form-group">
<label>시간봉</label>
<select>
<option>1시간봉</option>
<option>4시간봉</option>
<option>일봉</option>
</select>
</div>
<div class="form-group">
<label>분석 기간</label>
<select>
<option>최근 1개월</option>
<option>최근 3개월</option>
<option>최근 6개월</option>
</select>
</div>
<div class="form-group">
<label>초기 투자금</label>
<input type="text" value="₩10,000,000">
</div>
</div>
</div>
<!-- 결과 요약 -->
<div class="summary-strip">
<div class="summary-item green">
<div class="summary-label">💰 총 수익률</div>
<div class="summary-value positive">+21.5%</div>
<div class="summary-sub">₩2,150,000 수익</div>
</div>
<div class="summary-item green">
<div class="summary-label">📊 최종 자산</div>
<div class="summary-value">₩12.15M</div>
<div class="summary-sub">원금 ₩10,000,000</div>
</div>
<div class="summary-item blue">
<div class="summary-label">🎯 승률</div>
<div class="summary-value" style="color: #3b82f6;">72%</div>
<div class="summary-sub">18승 7패</div>
</div>
<div class="summary-item purple">
<div class="summary-label">🔄 총 거래</div>
<div class="summary-value" style="color: #8b5cf6;">25</div>
<div class="summary-sub">매수 13 / 매도 12</div>
</div>
<div class="summary-item orange">
<div class="summary-label">📉 최대 낙폭</div>
<div class="summary-value negative">-7.8%</div>
<div class="summary-sub">2024.02.12 기록</div>
</div>
<div class="summary-item green">
<div class="summary-label">⚡ 평균 수익</div>
<div class="summary-value positive">+₩86K</div>
<div class="summary-sub">거래당 평균</div>
</div>
</div>
<!-- 메인 탭 -->
<nav class="main-tabs">
<button class="main-tab active">
📈 차트 분석
</button>
<button class="main-tab">
📋 거래 내역
<span class="tab-badge">25</span>
</button>
<button class="main-tab">
📊 상세 분석
</button>
<button class="main-tab">
🎯 전략 정확도
</button>
</nav>
<!-- 탭 콘텐츠 -->
<div class="tab-content">
<!-- 차트 탭 (활성) -->
<div class="chart-content">
<div class="chart-controls">
<div class="chart-types">
<button class="chart-type-btn active">캔들스틱</button>
<button class="chart-type-btn">라인차트</button>
<button class="chart-type-btn">수익곡선</button>
</div>
<div class="chart-timeframes">
<button class="tf-btn">15분</button>
<button class="tf-btn active">1시간</button>
<button class="tf-btn">4시간</button>
<button class="tf-btn">1일</button>
</div>
</div>
<div class="chart-area">
<div class="chart-grid"></div>
<div class="chart-y-axis">
<span>62,000,000</span>
<span>60,000,000</span>
<span>58,000,000</span>
<span>56,000,000</span>
<span>54,000,000</span>
</div>
<div class="chart-x-axis">
<span>03/08</span>
<span>03/09</span>
<span>03/10</span>
<span>03/11</span>
<span>03/12</span>
<span>03/13</span>
<span>03/14</span>
<span>03/15</span>
</div>
<div class="chart-candles">
<div class="candle"><div class="candle-body bull" style="height: 40px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 55px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 35px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 50px;"></div><div class="signal-marker buy">B</div></div>
<div class="candle"><div class="candle-body bull" style="height: 60px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 75px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 65px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 40px;"></div><div class="signal-marker sell">S</div></div>
<div class="candle"><div class="candle-body bear" style="height: 55px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 45px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 30px;"></div><div class="signal-marker buy">B</div></div>
<div class="candle"><div class="candle-body bull" style="height: 50px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 70px;"></div></div>
<div class="candle"><div class="candle-body bull" style="height: 80px;"></div></div>
<div class="candle"><div class="candle-body bear" style="height: 35px;"></div></div>
</div>
</div>
<div class="chart-legend">
<div class="legend-item">
<div class="legend-dot" style="background: #10b981;"></div>
상승 캔들
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #ef4444;"></div>
하락 캔들
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #dcfce7; border: 2px solid #16a34a;"></div>
매수 시점
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #fef2f2; border: 2px solid #dc2626;"></div>
매도 시점
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #3b82f6;"></div>
MA(20)
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #f59e0b;"></div>
MA(60)
</div>
</div>
</div>
<!-- 거래 내역 탭 (숨김) - 실제로는 탭 전환 시 표시 -->
<div class="trades-content" style="display: none;">
<div class="trades-filter">
<button class="filter-btn active">전체</button>
<button class="filter-btn">매수</button>
<button class="filter-btn">매도</button>
<button class="filter-btn">수익</button>
<button class="filter-btn">손실</button>
</div>
<table class="trades-table">
<thead>
<tr>
<th>거래유형</th>
<th>일시</th>
<th>가격</th>
<th>수량</th>
<th>금액</th>
<th>손익</th>
<th>수익률</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="trade-type-badge sell">SELL</span></td>
<td>2024.03.15 14:00</td>
<td>₩58,450,000</td>
<td>0.1850 BTC</td>
<td>₩10,813,250</td>
<td class="profit-badge positive">+₩520,000</td>
<td class="profit-badge positive">+5.1%</td>
</tr>
<tr>
<td><span class="trade-type-badge buy">BUY</span></td>
<td>2024.03.12 09:00</td>
<td>₩55,580,000</td>
<td>0.1850 BTC</td>
<td>₩10,282,300</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td><span class="trade-type-badge sell">SELL</span></td>
<td>2024.03.08 16:00</td>
<td>₩54,200,000</td>
<td>0.1900 BTC</td>
<td>₩10,298,000</td>
<td class="profit-badge negative">-₩180,000</td>
<td class="profit-badge negative">-1.7%</td>
</tr>
</tbody>
</table>
</div>
<!-- 분석 탭 (숨김) -->
<div class="analysis-content" style="display: none;">
<div class="analysis-grid">
<div class="analysis-card">
<h3 class="analysis-card-title">🎯 전략 정확도</h3>
<div class="accuracy-chart">
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-value">72%</div>
<div class="accuracy-label">전체 정확도</div>
</div>
</div>
<div class="accuracy-details">
<div class="accuracy-detail-item">
<span style="width: 60px; font-size: 13px; color: #64748b;">매수</span>
<div class="accuracy-detail-bar">
<div class="accuracy-detail-fill" style="width: 78%; background: #10b981;"></div>
</div>
<span style="font-weight: 600; color: #10b981;">78%</span>
</div>
<div class="accuracy-detail-item">
<span style="width: 60px; font-size: 13px; color: #64748b;">매도</span>
<div class="accuracy-detail-bar">
<div class="accuracy-detail-fill" style="width: 65%; background: #ef4444;"></div>
</div>
<span style="font-weight: 600; color: #ef4444;">65%</span>
</div>
</div>
</div>
</div>
<div class="analysis-card">
<h3 class="analysis-card-title">📊 리스크 분석</h3>
<div class="risk-metrics">
<div class="risk-item">
<div class="risk-item-label">최대 낙폭 (MDD)</div>
<div class="risk-item-value" style="color: #ef4444;">-7.8%</div>
</div>
<div class="risk-item">
<div class="risk-item-label">샤프 비율</div>
<div class="risk-item-value" style="color: #10b981;">1.85</div>
</div>
<div class="risk-item">
<div class="risk-item-label">최대 연속 손실</div>
<div class="risk-item-value">3회</div>
</div>
<div class="risk-item">
<div class="risk-item-label">평균 보유 기간</div>
<div class="risk-item-value">2.3일</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,726 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>투자전략 테스트 UI - V4 모바일 친화 스타일</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background: #1a1a2e;
min-height: 100vh;
color: #fff;
}
/* 모바일 네비게이션 */
.mobile-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-around;
padding: 8px 0 24px;
z-index: 100;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #6b7280;
font-size: 10px;
cursor: pointer;
padding: 8px 16px;
border-radius: 12px;
transition: all 0.2s;
}
.nav-item.active {
color: #8b5cf6;
}
.nav-item-icon {
font-size: 22px;
}
/* 스크롤 컨테이너 */
.scroll-container {
padding: 16px;
padding-bottom: 100px;
max-width: 500px;
margin: 0 auto;
}
/* 헤더 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 22px;
font-weight: 700;
}
.version-badge {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
padding: 4px 10px;
border-radius: 12px;
font-size: 10px;
}
/* 종목 선택 카드 */
.symbol-card {
background: linear-gradient(135deg, #2d1b69 0%, #1a1a3e 100%);
border-radius: 20px;
padding: 20px;
margin-bottom: 16px;
}
.symbol-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.symbol-info {
display: flex;
align-items: center;
gap: 12px;
}
.symbol-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #f7931a 0%, #ffab00 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
}
.symbol-name {
font-size: 18px;
font-weight: 600;
}
.symbol-market {
font-size: 12px;
color: #9ca3af;
}
.change-btn {
padding: 8px 16px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 10px;
color: #fff;
font-size: 12px;
cursor: pointer;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.current-price {
font-size: 28px;
font-weight: 700;
}
.price-change {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.price-change-value {
font-size: 16px;
font-weight: 600;
}
.price-change-value.up { color: #10b981; }
.price-change-value.down { color: #ef4444; }
.price-change-pct {
font-size: 12px;
color: #10b981;
}
/* 빠른 설정 */
.quick-settings {
display: flex;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
padding-bottom: 8px;
}
.quick-setting-chip {
flex-shrink: 0;
padding: 10px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
font-size: 13px;
color: #9ca3af;
cursor: pointer;
white-space: nowrap;
}
.quick-setting-chip.active {
background: rgba(139, 92, 246, 0.2);
border-color: #8b5cf6;
color: #a78bfa;
}
/* 성과 그리드 */
.performance-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.perf-card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 16px;
position: relative;
overflow: hidden;
}
.perf-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
}
.perf-card.profit::after { background: linear-gradient(180deg, #10b981, #059669); }
.perf-card.loss::after { background: linear-gradient(180deg, #ef4444, #dc2626); }
.perf-card.neutral::after { background: linear-gradient(180deg, #8b5cf6, #7c3aed); }
.perf-card.info::after { background: linear-gradient(180deg, #3b82f6, #2563eb); }
.perf-label {
font-size: 11px;
color: #9ca3af;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.perf-value {
font-size: 22px;
font-weight: 700;
}
.perf-value.up { color: #10b981; }
.perf-value.down { color: #ef4444; }
.perf-sub {
font-size: 10px;
color: #6b7280;
margin-top: 4px;
}
/* 대형 성과 카드 */
.big-perf-card {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.05) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 20px;
padding: 24px;
margin-bottom: 16px;
text-align: center;
}
.big-perf-title {
font-size: 14px;
color: #9ca3af;
margin-bottom: 8px;
}
.big-perf-value {
font-size: 48px;
font-weight: 700;
color: #10b981;
margin-bottom: 8px;
}
.big-perf-sub {
font-size: 14px;
color: #10b981;
}
/* 차트 카드 */
.chart-card {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 16px;
margin-bottom: 16px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chart-title {
font-size: 14px;
font-weight: 600;
}
.chart-period-selector {
display: flex;
gap: 4px;
background: rgba(0,0,0,0.2);
padding: 4px;
border-radius: 8px;
}
.period-btn {
padding: 6px 10px;
border: none;
background: transparent;
color: #6b7280;
font-size: 11px;
border-radius: 6px;
cursor: pointer;
}
.period-btn.active {
background: #8b5cf6;
color: #fff;
}
/* 미니 캔들차트 */
.mini-chart {
height: 180px;
background: rgba(0,0,0,0.2);
border-radius: 12px;
position: relative;
overflow: hidden;
padding: 16px;
}
.mini-candles {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 100%;
gap: 4px;
}
.mini-candle {
width: 10px;
border-radius: 2px;
position: relative;
}
.mini-candle.up { background: #10b981; }
.mini-candle.down { background: #ef4444; }
.mini-signal {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
}
.mini-signal.buy {
bottom: -28px;
left: 50%;
transform: translateX(-50%);
background: rgba(16, 185, 129, 0.3);
color: #10b981;
border: 1.5px solid #10b981;
}
.mini-signal.sell {
top: -28px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.3);
color: #ef4444;
border: 1.5px solid #ef4444;
}
/* 정확도 섹션 */
.accuracy-section {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 20px;
margin-bottom: 16px;
}
.accuracy-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.accuracy-title {
font-size: 14px;
font-weight: 600;
}
.accuracy-value {
font-size: 24px;
font-weight: 700;
color: #10b981;
}
.accuracy-bars {
display: flex;
flex-direction: column;
gap: 12px;
}
.accuracy-bar-item {
display: flex;
align-items: center;
gap: 12px;
}
.accuracy-bar-label {
width: 50px;
font-size: 12px;
color: #9ca3af;
}
.accuracy-bar-bg {
flex: 1;
height: 10px;
background: rgba(255,255,255,0.1);
border-radius: 5px;
overflow: hidden;
}
.accuracy-bar-fill {
height: 100%;
border-radius: 5px;
transition: width 0.5s ease;
}
.accuracy-bar-fill.buy { background: linear-gradient(90deg, #10b981, #34d399); }
.accuracy-bar-fill.sell { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.accuracy-bar-pct {
width: 40px;
text-align: right;
font-size: 13px;
font-weight: 600;
}
/* 거래 내역 */
.trades-section {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 16px;
margin-bottom: 16px;
}
.trades-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.trades-title {
font-size: 14px;
font-weight: 600;
}
.trades-view-all {
font-size: 12px;
color: #8b5cf6;
cursor: pointer;
}
.trade-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.03);
border-radius: 12px;
margin-bottom: 8px;
}
.trade-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.trade-icon.buy {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.trade-icon.sell {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.trade-details {
flex: 1;
}
.trade-type {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.trade-date {
font-size: 11px;
color: #6b7280;
}
.trade-result {
text-align: right;
}
.trade-price {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.trade-pnl {
font-size: 12px;
font-weight: 600;
}
.trade-pnl.profit { color: #10b981; }
.trade-pnl.loss { color: #ef4444; }
/* 실행 버튼 */
.run-button {
position: fixed;
bottom: 100px;
left: 16px;
right: 16px;
max-width: 468px;
margin: 0 auto;
padding: 18px;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
border: none;
border-radius: 16px;
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 30px rgba(139, 92, 246, 0.4);
z-index: 99;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.run-button:active {
transform: scale(0.98);
}
/* 섹션 제목 */
.section-title {
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="scroll-container">
<!-- 헤더 -->
<header class="page-header">
<h1 class="page-title">백테스트</h1>
<span class="version-badge">V4 모바일</span>
</header>
<!-- 종목 선택 카드 -->
<div class="symbol-card">
<div class="symbol-header">
<div class="symbol-info">
<div class="symbol-icon">₿</div>
<div>
<div class="symbol-name">BTC/KRW</div>
<div class="symbol-market">비트코인 · 업비트</div>
</div>
</div>
<button class="change-btn">변경 ▼</button>
</div>
<div class="price-row">
<div class="current-price">₩58,450,000</div>
<div class="price-change">
<div class="price-change-value up">+₩1,340,000</div>
<div class="price-change-pct">+2.34%</div>
</div>
</div>
</div>
<!-- 빠른 설정 -->
<div class="quick-settings">
<div class="quick-setting-chip active">MACD+RSI</div>
<div class="quick-setting-chip">볼린저밴드</div>
<div class="quick-setting-chip">이평선크로스</div>
<div class="quick-setting-chip">Stochastic</div>
</div>
<div class="quick-settings">
<div class="quick-setting-chip">15분</div>
<div class="quick-setting-chip active">1시간</div>
<div class="quick-setting-chip">4시간</div>
<div class="quick-setting-chip">일봉</div>
</div>
<!-- 대형 수익률 카드 -->
<div class="big-perf-card">
<div class="big-perf-title">💰 총 수익률</div>
<div class="big-perf-value">+23.5%</div>
<div class="big-perf-sub">₩10,000,000 → ₩12,350,000</div>
</div>
<!-- 성과 그리드 -->
<div class="performance-grid">
<div class="perf-card neutral">
<div class="perf-label">🎯 승률</div>
<div class="perf-value">68%</div>
<div class="perf-sub">17승 8패</div>
</div>
<div class="perf-card info">
<div class="perf-label">🔄 총 거래</div>
<div class="perf-value">25</div>
<div class="perf-sub">매수 13 / 매도 12</div>
</div>
<div class="perf-card profit">
<div class="perf-label">⚡ 평균 수익</div>
<div class="perf-value up">+₩94K</div>
<div class="perf-sub">거래당</div>
</div>
<div class="perf-card loss">
<div class="perf-label">📉 최대 낙폭</div>
<div class="perf-value down">-8.2%</div>
<div class="perf-sub">2024.01.15</div>
</div>
</div>
<!-- 미니 차트 -->
<div class="section-title">📈 차트 분석</div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">가격 & 매매 시점</span>
<div class="chart-period-selector">
<button class="period-btn">1W</button>
<button class="period-btn active">1M</button>
<button class="period-btn">3M</button>
</div>
</div>
<div class="mini-chart">
<div class="mini-candles">
<div class="mini-candle up" style="height: 40px;"></div>
<div class="mini-candle up" style="height: 55px;"></div>
<div class="mini-candle down" style="height: 35px;"></div>
<div class="mini-candle down" style="height: 50px;"><div class="mini-signal buy">B</div></div>
<div class="mini-candle up" style="height: 60px;"></div>
<div class="mini-candle up" style="height: 80px;"></div>
<div class="mini-candle up" style="height: 70px;"></div>
<div class="mini-candle down" style="height: 45px;"><div class="mini-signal sell">S</div></div>
<div class="mini-candle down" style="height: 55px;"></div>
<div class="mini-candle up" style="height: 50px;"></div>
<div class="mini-candle down" style="height: 35px;"><div class="mini-signal buy">B</div></div>
<div class="mini-candle up" style="height: 60px;"></div>
<div class="mini-candle up" style="height: 75px;"></div>
<div class="mini-candle up" style="height: 85px;"></div>
<div class="mini-candle down" style="height: 40px;"></div>
</div>
</div>
</div>
<!-- 정확도 섹션 -->
<div class="section-title">🎯 전략 정확도</div>
<div class="accuracy-section">
<div class="accuracy-header">
<span class="accuracy-title">예측 정확도</span>
<span class="accuracy-value">70%</span>
</div>
<div class="accuracy-bars">
<div class="accuracy-bar-item">
<span class="accuracy-bar-label">매수</span>
<div class="accuracy-bar-bg">
<div class="accuracy-bar-fill buy" style="width: 76%;"></div>
</div>
<span class="accuracy-bar-pct" style="color: #10b981;">76%</span>
</div>
<div class="accuracy-bar-item">
<span class="accuracy-bar-label">매도</span>
<div class="accuracy-bar-bg">
<div class="accuracy-bar-fill sell" style="width: 64%;"></div>
</div>
<span class="accuracy-bar-pct" style="color: #f59e0b;">64%</span>
</div>
</div>
</div>
<!-- 거래 내역 -->
<div class="section-title">📋 최근 거래</div>
<div class="trades-section">
<div class="trades-header">
<span class="trades-title">거래 내역</span>
<span class="trades-view-all">전체보기 →</span>
</div>
<div class="trade-item">
<div class="trade-icon sell">S</div>
<div class="trade-details">
<div class="trade-type">매도</div>
<div class="trade-date">2024.03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon buy">B</div>
<div class="trade-details">
<div class="trade-type">매수</div>
<div class="trade-date">2024.03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">진행중</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon sell">S</div>
<div class="trade-details">
<div class="trade-type">매도</div>
<div class="trade-date">2024.03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54,200,000</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-icon buy">B</div>
<div class="trade-details">
<div class="trade-type">매수</div>
<div class="trade-date">2024.03.05 11:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,350,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
</div>
</div>
<!-- 실행 버튼 -->
<button class="run-button">
<span>▶</span> 백테스트 실행
</button>
<!-- 모바일 네비게이션 -->
<nav class="mobile-nav">
<div class="nav-item">
<span class="nav-item-icon">🏠</span>
<span>홈</span>
</div>
<div class="nav-item">
<span class="nav-item-icon">📊</span>
<span>차트</span>
</div>
<div class="nav-item active">
<span class="nav-item-icon">🎯</span>
<span>백테스트</span>
</div>
<div class="nav-item">
<span class="nav-item-icon">📋</span>
<span>거래내역</span>
</div>
<div class="nav-item">
<span class="nav-item-icon">⚙️</span>
<span>설정</span>
</div>
</nav>
</body>
</html>
@@ -0,0 +1,508 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1024">
<title>Tablet-01 그리드 대시보드</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
width: 1024px;
min-height: 768px;
}
.header {
padding: 16px 24px;
background: rgba(255,255,255,0.05);
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
gap: 12px;
align-items: center;
}
.version-badge {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
color: #fff;
}
.container {
padding: 24px;
}
/* 설정 바 */
.config-bar {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.config-field {
flex: 1;
min-width: 140px;
}
.config-label {
font-size: 11px;
color: #9ca3af;
margin-bottom: 6px;
}
.config-select {
width: 100%;
padding: 10px 12px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
color: #fff;
font-size: 13px;
}
.run-btn {
padding: 10px 24px;
background: linear-gradient(135deg, #10b981, #059669);
border: none;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
/* 메인 그리드 */
.main-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto auto 1fr auto;
gap: 16px;
}
/* 결과 배너 */
.result-banner {
grid-column: span 4;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.05));
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 16px;
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-symbol {
font-size: 22px;
font-weight: 700;
}
.result-meta {
font-size: 13px;
color: #9ca3af;
margin-top: 4px;
}
.result-value {
font-size: 36px;
font-weight: 700;
color: #10b981;
}
.result-amount {
font-size: 13px;
color: #10b981;
}
/* 메트릭 카드 */
.metric-card {
background: rgba(255,255,255,0.05);
border-radius: 14px;
padding: 18px;
}
.metric-icon {
font-size: 18px;
margin-bottom: 10px;
}
.metric-label {
font-size: 11px;
color: #9ca3af;
margin-bottom: 6px;
}
.metric-value {
font-size: 22px;
font-weight: 600;
}
.metric-value.green { color: #10b981; }
.metric-value.red { color: #ef4444; }
/* 차트 영역 */
.chart-card {
grid-column: span 3;
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
}
.tabs {
display: flex;
gap: 4px;
background: rgba(255,255,255,0.1);
padding: 4px;
border-radius: 8px;
}
.tab {
padding: 6px 12px;
background: transparent;
border: none;
color: #9ca3af;
font-size: 11px;
border-radius: 6px;
cursor: pointer;
}
.tab.active { background: #8b5cf6; color: #fff; }
.chart-area {
height: 240px;
background: rgba(0,0,0,0.2);
border-radius: 12px;
position: relative;
overflow: hidden;
}
.chart-candles {
position: absolute;
inset: 20px 40px 40px 20px;
display: flex;
align-items: flex-end;
justify-content: space-around;
}
.candle {
width: 10px;
border-radius: 2px;
position: relative;
}
.candle.up { background: #10b981; }
.candle.down { background: #ef4444; }
.signal {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.signal.buy { bottom: -26px; left: 50%; transform: translateX(-50%); background: rgba(16, 185, 129, 0.3); color: #10b981; border: 2px solid #10b981; }
.signal.sell { top: -26px; left: 50%; transform: translateX(-50%); background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 2px solid #ef4444; }
/* 정확도 */
.accuracy-card {
grid-row: span 2;
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 20px;
text-align: center;
}
.accuracy-ring {
width: 120px;
height: 120px;
margin: 16px auto;
border-radius: 50%;
background: conic-gradient(#8b5cf6 0deg 252deg, rgba(255,255,255,0.1) 252deg 360deg);
display: flex;
align-items: center;
justify-content: center;
}
.accuracy-inner {
width: 90px;
height: 90px;
background: #1a1a2e;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.accuracy-pct {
font-size: 26px;
font-weight: 700;
color: #8b5cf6;
}
.accuracy-text {
font-size: 10px;
color: #9ca3af;
}
.acc-bar {
margin-bottom: 12px;
text-align: left;
}
.acc-bar-header {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 4px;
}
.acc-bar-bg {
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
overflow: hidden;
}
.acc-bar-fill {
height: 100%;
border-radius: 3px;
}
.acc-bar-fill.green { background: #10b981; }
.acc-bar-fill.purple { background: #8b5cf6; }
/* 거래 */
.trades-card {
grid-column: span 4;
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 20px;
}
.trades-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.trade-item {
background: rgba(255,255,255,0.05);
border-radius: 10px;
padding: 14px;
display: flex;
align-items: center;
gap: 12px;
}
.trade-badge {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.trade-badge.buy { background: rgba(16, 185, 129, 0.2); color: #10b981; }
.trade-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 12px; font-weight: 500; }
.trade-time { font-size: 10px; color: #6b7280; }
.trade-result { text-align: right; }
.trade-price { font-size: 11px; }
.trade-pnl { font-size: 11px; font-weight: 600; }
.trade-pnl.profit { color: #10b981; }
.trade-pnl.loss { color: #ef4444; }
</style>
</head>
<body>
<header class="header">
<div class="logo">📊 백테스트</div>
<div class="header-right">
<span class="version-badge">Tablet-01 그리드</span>
<button class="btn btn-primary">+ 새 테스트</button>
</div>
</header>
<div class="container">
<div class="config-bar">
<div class="config-field">
<div class="config-label">종목</div>
<select class="config-select"><option>BTC/KRW</option></select>
</div>
<div class="config-field">
<div class="config-label">전략</div>
<select class="config-select"><option>MACD+RSI</option></select>
</div>
<div class="config-field">
<div class="config-label">시간봉</div>
<select class="config-select"><option>1시간</option></select>
</div>
<div class="config-field">
<div class="config-label">기간</div>
<select class="config-select"><option>3개월</option></select>
</div>
<div class="config-field">
<div class="config-label">초기자본</div>
<input type="text" class="config-select" value="₩10M">
</div>
<button class="run-btn">▶ 실행</button>
</div>
<div class="main-grid">
<div class="result-banner">
<div>
<div class="result-symbol">BTC/KRW</div>
<div class="result-meta">MACD+RSI · 1H · 3개월</div>
</div>
<div style="text-align: right;">
<div class="result-value">+23.5%</div>
<div class="result-amount">₩2,350,000 수익</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon">💰</div>
<div class="metric-label">최종 자산</div>
<div class="metric-value">₩12.35M</div>
</div>
<div class="metric-card">
<div class="metric-icon">🎯</div>
<div class="metric-label">승률</div>
<div class="metric-value green">68%</div>
</div>
<div class="metric-card">
<div class="metric-icon">🔄</div>
<div class="metric-label">총 거래</div>
<div class="metric-value">25회</div>
</div>
<div class="metric-card">
<div class="metric-icon">📉</div>
<div class="metric-label">최대 낙폭</div>
<div class="metric-value red">-8.2%</div>
</div>
<div class="chart-card">
<div class="card-header">
<span class="card-title">📈 가격 차트</span>
<div class="tabs">
<button class="tab active">캔들</button>
<button class="tab">라인</button>
</div>
</div>
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 40px;"></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle down" style="height: 35px;"></div>
<div class="candle down" style="height: 50px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle up" style="height: 85px;"></div>
<div class="candle up" style="height: 75px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 60px;"></div>
<div class="candle up" style="height: 50px;"></div>
<div class="candle down" style="height: 35px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 80px;"></div>
</div>
</div>
</div>
<div class="accuracy-card">
<div class="card-title">🎯 정확도</div>
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">전체</div>
</div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #9ca3af;">매수</span>
<span style="color: #10b981;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #9ca3af;">매도</span>
<span style="color: #8b5cf6;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill purple" style="width: 64%;"></div></div>
</div>
</div>
<div class="trades-card">
<div class="card-header">
<span class="card-title">📋 최근 거래</span>
<span style="font-size: 11px; color: #8b5cf6; cursor: pointer;">전체보기 →</span>
</div>
<div class="trades-grid">
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58.4M</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55.5M</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54.2M</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.05 11:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55.3M</div>
<div class="trade-pnl">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=768">
<title>Tablet-02 카드 스택</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', sans-serif;
background: #f1f5f9;
color: #1e293b;
width: 768px;
min-height: 1024px;
}
.header {
background: #fff;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 20px;
font-weight: 700;
color: #1e40af;
}
.version-badge {
background: #dbeafe;
color: #1d4ed8;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
}
.container {
padding: 20px;
}
/* 카드 */
.card {
background: #fff;
border-radius: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
margin-bottom: 16px;
overflow: hidden;
}
.card-header {
padding: 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.card-body {
padding: 20px;
}
/* 설정 카드 */
.config-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.config-field {
margin-bottom: 0;
}
.config-label {
font-size: 12px;
color: #64748b;
margin-bottom: 6px;
}
.config-input {
width: 100%;
padding: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 14px;
}
.config-input:focus { outline: none; border-color: #3b82f6; }
.run-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none;
border-radius: 12px;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-top: 12px;
}
/* 결과 카드 */
.result-card {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: #fff;
}
.result-card .card-body {
display: flex;
justify-content: space-between;
align-items: center;
}
.result-symbol {
font-size: 24px;
font-weight: 700;
}
.result-meta {
font-size: 13px;
opacity: 0.9;
margin-top: 4px;
}
.result-value {
font-size: 40px;
font-weight: 700;
}
.result-amount {
font-size: 14px;
opacity: 0.9;
text-align: right;
}
/* 메트릭 */
.metrics-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.metric-item {
background: #f8fafc;
border-radius: 12px;
padding: 16px;
text-align: center;
}
.metric-icon {
font-size: 24px;
margin-bottom: 8px;
}
.metric-value {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.metric-value.green { color: #16a34a; }
.metric-value.red { color: #dc2626; }
.metric-label {
font-size: 11px;
color: #64748b;
}
/* 차트 */
.chart-tabs {
display: flex;
gap: 8px;
}
.chart-tab {
padding: 8px 16px;
background: #f1f5f9;
border: none;
border-radius: 8px;
font-size: 13px;
color: #64748b;
cursor: pointer;
}
.chart-tab.active { background: #3b82f6; color: #fff; }
.chart-area {
height: 280px;
background: #fafbfc;
border: 1px solid #e2e8f0;
border-radius: 16px;
position: relative;
overflow: hidden;
}
.chart-candles {
position: absolute;
inset: 30px 50px 50px 20px;
display: flex;
align-items: flex-end;
justify-content: space-around;
}
.candle {
width: 12px;
border-radius: 2px;
position: relative;
}
.candle.up { background: #16a34a; }
.candle.down { background: #dc2626; }
.signal {
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.signal.buy { bottom: -28px; left: 50%; transform: translateX(-50%); background: #dcfce7; color: #16a34a; border: 2px solid #16a34a; }
.signal.sell { top: -28px; left: 50%; transform: translateX(-50%); background: #fee2e2; color: #dc2626; border: 2px solid #dc2626; }
/* 정확도 + 거래 */
.two-col {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 16px;
}
.accuracy-ring {
width: 140px;
height: 140px;
margin: 0 auto 16px;
border-radius: 50%;
background: conic-gradient(#16a34a 0deg 252deg, #e2e8f0 252deg 360deg);
display: flex;
align-items: center;
justify-content: center;
}
.accuracy-inner {
width: 110px;
height: 110px;
background: #fff;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.accuracy-pct {
font-size: 32px;
font-weight: 700;
color: #16a34a;
}
.accuracy-text {
font-size: 11px;
color: #64748b;
}
.acc-bar {
margin-bottom: 12px;
}
.acc-bar-header {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 4px;
}
.acc-bar-bg {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.acc-bar-fill {
height: 100%;
border-radius: 4px;
}
.acc-bar-fill.green { background: linear-gradient(90deg, #16a34a, #22c55e); }
.acc-bar-fill.yellow { background: linear-gradient(90deg, #ca8a04, #eab308); }
/* 거래 리스트 */
.trade-item {
display: flex;
align-items: center;
padding: 14px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 10px;
}
.trade-badge {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
margin-right: 14px;
}
.trade-badge.buy { background: #dcfce7; color: #16a34a; }
.trade-badge.sell { background: #fee2e2; color: #dc2626; }
.trade-info { flex: 1; }
.trade-type { font-size: 14px; font-weight: 500; }
.trade-time { font-size: 12px; color: #64748b; }
.trade-result { text-align: right; }
.trade-price { font-size: 14px; font-weight: 500; }
.trade-pnl { font-size: 13px; font-weight: 600; }
.trade-pnl.profit { color: #16a34a; }
.trade-pnl.loss { color: #dc2626; }
</style>
</head>
<body>
<header class="header">
<span class="logo">📊 백테스트</span>
<span class="version-badge">Tablet-02 카드</span>
</header>
<div class="container">
<!-- 설정 -->
<div class="card">
<div class="card-header">
<span class="card-title">⚙️ 테스트 설정</span>
</div>
<div class="card-body">
<div class="config-grid">
<div class="config-field">
<div class="config-label">종목</div>
<select class="config-input"><option>BTC/KRW</option></select>
</div>
<div class="config-field">
<div class="config-label">전략</div>
<select class="config-input"><option>MACD+RSI</option></select>
</div>
<div class="config-field">
<div class="config-label">시간봉</div>
<select class="config-input"><option>1시간</option></select>
</div>
<div class="config-field">
<div class="config-label">분석기간</div>
<select class="config-input"><option>3개월</option></select>
</div>
<div class="config-field">
<div class="config-label">초기자본</div>
<input type="text" class="config-input" value="₩10,000,000">
</div>
</div>
<button class="run-btn">▶ 백테스트 실행</button>
</div>
</div>
<!-- 결과 -->
<div class="card result-card">
<div class="card-body">
<div>
<div class="result-symbol">BTC/KRW</div>
<div class="result-meta">MACD+RSI · 1시간봉 · 3개월</div>
</div>
<div>
<div class="result-value">+23.5%</div>
<div class="result-amount">₩2,350,000 수익</div>
</div>
</div>
</div>
<!-- 메트릭 -->
<div class="card">
<div class="card-body">
<div class="metrics-row">
<div class="metric-item">
<div class="metric-icon">💰</div>
<div class="metric-value">₩12.35M</div>
<div class="metric-label">최종 자산</div>
</div>
<div class="metric-item">
<div class="metric-icon">🎯</div>
<div class="metric-value green">68%</div>
<div class="metric-label">승률</div>
</div>
<div class="metric-item">
<div class="metric-icon">🔄</div>
<div class="metric-value">25회</div>
<div class="metric-label">총 거래</div>
</div>
<div class="metric-item">
<div class="metric-icon">📉</div>
<div class="metric-value red">-8.2%</div>
<div class="metric-label">최대 낙폭</div>
</div>
</div>
</div>
</div>
<!-- 차트 -->
<div class="card">
<div class="card-header">
<span class="card-title">📈 가격 차트</span>
<div class="chart-tabs">
<button class="chart-tab active">캔들</button>
<button class="chart-tab">라인</button>
</div>
</div>
<div class="card-body">
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 45px;"></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle down" style="height: 40px;"></div>
<div class="candle down" style="height: 55px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 90px;"></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle down" style="height: 50px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 65px;"></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle down" style="height: 40px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 60px;"></div>
</div>
</div>
</div>
</div>
<!-- 정확도 + 거래 -->
<div class="two-col">
<div class="card">
<div class="card-header">
<span class="card-title">🎯 정확도</span>
</div>
<div class="card-body">
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">전체 정확도</div>
</div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #64748b;">매수</span>
<span style="color: #16a34a; font-weight: 600;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #64748b;">매도</span>
<span style="color: #ca8a04; font-weight: 600;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill yellow" style="width: 64%;"></div></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">📋 최근 거래</span>
</div>
<div class="card-body" style="padding-top: 12px;">
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">2024.03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">2024.03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">2024.03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54,200,000</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1024">
<title>Tablet-03 가로 분할</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #0c0c14; color: #e4e4e7; width: 1024px; height: 768px; overflow: hidden; }
.layout { display: flex; flex-direction: column; height: 100%; }
.header { padding: 12px 20px; background: #161622; border-bottom: 1px solid #27272a; display: flex; justify-content: space-between; align-items: center; }
.logo { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.version-badge { background: rgba(59, 130, 246, 0.2); color: #60a5fa; padding: 4px 10px; border-radius: 10px; font-size: 10px; }
.btn { padding: 8px 16px; background: #3b82f6; border: none; border-radius: 6px; color: #fff; font-size: 12px; font-weight: 500; cursor: pointer; }
/* 상단: 설정 + 결과 */
.top-section { display: flex; border-bottom: 1px solid #27272a; }
.config-panel { width: 400px; padding: 16px; border-right: 1px solid #27272a; }
.config-row { display: flex; gap: 12px; margin-bottom: 12px; }
.config-field { flex: 1; }
.config-label { font-size: 10px; color: #71717a; margin-bottom: 4px; }
.config-select { width: 100%; padding: 8px 10px; background: #1f1f2e; border: 1px solid #27272a; border-radius: 6px; color: #fff; font-size: 12px; }
.run-btn { width: 100%; padding: 10px; background: linear-gradient(135deg, #3b82f6, #2563eb); border: none; border-radius: 8px; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
.result-panel { flex: 1; padding: 16px; display: flex; gap: 20px; align-items: center; }
.result-main { flex: 1; }
.result-symbol { font-size: 20px; font-weight: 700; }
.result-meta { font-size: 11px; color: #71717a; margin-top: 2px; }
.result-value { font-size: 36px; font-weight: 700; color: #22c55e; }
.result-amount { font-size: 12px; color: #22c55e; }
.metrics-strip { display: flex; gap: 20px; }
.metric-mini { text-align: center; padding: 12px 16px; background: rgba(255,255,255,0.05); border-radius: 10px; }
.metric-mini-value { font-size: 18px; font-weight: 600; }
.metric-mini-value.green { color: #22c55e; }
.metric-mini-value.red { color: #ef4444; }
.metric-mini-label { font-size: 10px; color: #71717a; margin-top: 2px; }
/* 하단: 차트 + 정보 */
.bottom-section { flex: 1; display: flex; overflow: hidden; }
/* 차트 */
.chart-section { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #27272a; }
.chart-header { padding: 12px 16px; border-bottom: 1px solid #27272a; display: flex; justify-content: space-between; align-items: center; }
.chart-title { font-size: 13px; font-weight: 600; }
.tabs { display: flex; gap: 4px; }
.tab { padding: 6px 12px; background: transparent; border: 1px solid #27272a; border-radius: 6px; color: #71717a; font-size: 11px; cursor: pointer; }
.tab.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.chart-area { flex: 1; position: relative; background: linear-gradient(90deg, rgba(39, 39, 42, 0.2) 1px, transparent 1px), linear-gradient(rgba(39, 39, 42, 0.2) 1px, transparent 1px); background-size: 40px 30px; }
.chart-candles { position: absolute; inset: 20px 50px 40px 20px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 10px; border-radius: 2px; position: relative; }
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal { position: absolute; width: 18px; height: 18px; border-radius: 50%; font-size: 9px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.signal.buy { bottom: -24px; left: 50%; transform: translateX(-50%); background: rgba(34, 197, 94, 0.3); color: #22c55e; border: 1.5px solid #22c55e; }
.signal.sell { top: -24px; left: 50%; transform: translateX(-50%); background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 1.5px solid #ef4444; }
/* 사이드 패널 */
.side-panel { width: 280px; display: flex; flex-direction: column; }
.panel-section { padding: 16px; border-bottom: 1px solid #27272a; }
.section-title { font-size: 12px; font-weight: 600; margin-bottom: 12px; }
.accuracy-row { display: flex; align-items: center; gap: 16px; }
.accuracy-ring { width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(#3b82f6 0deg 252deg, #27272a 252deg 360deg); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.accuracy-inner { width: 60px; height: 60px; background: #0c0c14; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.accuracy-pct { font-size: 18px; font-weight: 700; color: #3b82f6; }
.accuracy-text { font-size: 8px; color: #71717a; }
.accuracy-bars { flex: 1; }
.acc-bar { margin-bottom: 8px; }
.acc-bar-header { display: flex; justify-content: space-between; font-size: 10px; margin-bottom: 3px; }
.acc-bar-bg { height: 5px; background: #27272a; border-radius: 3px; overflow: hidden; }
.acc-bar-fill { height: 100%; border-radius: 3px; }
.acc-bar-fill.blue { background: #3b82f6; }
.acc-bar-fill.green { background: #22c55e; }
.trades-list { flex: 1; overflow-y: auto; padding: 12px 16px; }
.trade-item { display: flex; align-items: center; padding: 10px; background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 6px; }
.trade-badge { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; margin-right: 10px; }
.trade-badge.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 11px; font-weight: 500; }
.trade-time { font-size: 9px; color: #52525b; }
.trade-result { text-align: right; }
.trade-price { font-size: 10px; }
.trade-pnl { font-size: 10px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
</style>
</head>
<body>
<div class="layout">
<header class="header">
<div class="logo">📊 백테스트 시뮬레이터</div>
<div style="display: flex; gap: 12px; align-items: center;">
<span class="version-badge">Tablet-03 가로분할</span>
<button class="btn">+ 새 테스트</button>
</div>
</header>
<div class="top-section">
<div class="config-panel">
<div class="config-row">
<div class="config-field">
<div class="config-label">종목</div>
<select class="config-select"><option>BTC/KRW</option></select>
</div>
<div class="config-field">
<div class="config-label">전략</div>
<select class="config-select"><option>MACD+RSI</option></select>
</div>
</div>
<div class="config-row">
<div class="config-field">
<div class="config-label">시간봉</div>
<select class="config-select"><option>1시간</option></select>
</div>
<div class="config-field">
<div class="config-label">기간</div>
<select class="config-select"><option>3개월</option></select>
</div>
<div class="config-field">
<div class="config-label">초기자본</div>
<input type="text" class="config-select" value="₩10M">
</div>
</div>
<button class="run-btn">▶ 실행</button>
</div>
<div class="result-panel">
<div class="result-main">
<div class="result-symbol">BTC/KRW</div>
<div class="result-meta">MACD+RSI · 1H · 3개월</div>
</div>
<div style="text-align: right;">
<div class="result-value">+23.5%</div>
<div class="result-amount">₩2,350,000</div>
</div>
<div class="metrics-strip">
<div class="metric-mini">
<div class="metric-mini-value">₩12.35M</div>
<div class="metric-mini-label">최종자산</div>
</div>
<div class="metric-mini">
<div class="metric-mini-value green">68%</div>
<div class="metric-mini-label">승률</div>
</div>
<div class="metric-mini">
<div class="metric-mini-value">25</div>
<div class="metric-mini-label">거래</div>
</div>
<div class="metric-mini">
<div class="metric-mini-value red">-8.2%</div>
<div class="metric-mini-label">MDD</div>
</div>
</div>
</div>
</div>
<div class="bottom-section">
<div class="chart-section">
<div class="chart-header">
<span class="chart-title">📈 가격 차트 & 매매 시점</span>
<div class="tabs">
<button class="tab active">캔들</button>
<button class="tab">라인</button>
<button class="tab">수익곡선</button>
</div>
</div>
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 40px;"></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle down" style="height: 35px;"></div>
<div class="candle down" style="height: 50px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle up" style="height: 85px;"></div>
<div class="candle up" style="height: 75px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 60px;"></div>
<div class="candle up" style="height: 50px;"></div>
<div class="candle down" style="height: 35px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle down" style="height: 40px;"></div>
</div>
</div>
</div>
<div class="side-panel">
<div class="panel-section">
<div class="section-title">🎯 전략 정확도</div>
<div class="accuracy-row">
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">정확도</div>
</div>
</div>
<div class="accuracy-bars">
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #71717a;">매수</span>
<span style="color: #22c55e;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #71717a;">매도</span>
<span style="color: #3b82f6;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill blue" style="width: 64%;"></div></div>
</div>
</div>
</div>
</div>
<div class="section-title" style="padding: 12px 16px 0;">📋 거래 내역</div>
<div class="trades-list">
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58.4M</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55.5M</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54.2M</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.05 11:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55.3M</div>
<div class="trade-pnl">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=768">
<title>Tablet-04 슬라이드 패널</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #18181b; color: #fafafa; width: 768px; min-height: 1024px; }
/* 헤더 */
.header { padding: 16px 20px; background: #09090b; display: flex; justify-content: space-between; align-items: center; }
.logo { font-size: 18px; font-weight: 700; }
.version-badge { background: rgba(168, 85, 247, 0.2); color: #a78bfa; padding: 4px 10px; border-radius: 10px; font-size: 10px; }
/* 탭 네비게이션 */
.tab-nav { display: flex; background: #09090b; border-bottom: 1px solid #27272a; padding: 0 20px; }
.tab-nav-item { padding: 14px 20px; font-size: 14px; color: #71717a; cursor: pointer; border-bottom: 2px solid transparent; }
.tab-nav-item.active { color: #a78bfa; border-bottom-color: #a78bfa; font-weight: 500; }
.container { padding: 20px; }
/* 설정 패널 */
.config-panel { background: #27272a; border-radius: 16px; padding: 20px; margin-bottom: 20px; }
.config-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.config-field { }
.config-label { font-size: 11px; color: #71717a; margin-bottom: 6px; }
.config-input { width: 100%; padding: 12px; background: #18181b; border: 1px solid #3f3f46; border-radius: 8px; color: #fff; font-size: 13px; }
.run-btn { grid-column: span 3; padding: 14px; background: linear-gradient(135deg, #a855f7, #7c3aed); border: none; border-radius: 10px; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 8px; }
/* 결과 카드 */
.result-card { background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(124, 58, 237, 0.1)); border: 1px solid rgba(168, 85, 247, 0.3); border-radius: 20px; padding: 24px; margin-bottom: 20px; }
.result-row { display: flex; justify-content: space-between; align-items: center; }
.result-symbol { font-size: 22px; font-weight: 700; }
.result-meta { font-size: 12px; color: #a1a1aa; margin-top: 4px; }
.result-return { text-align: right; }
.result-value { font-size: 40px; font-weight: 700; color: #22c55e; }
.result-amount { font-size: 13px; color: #22c55e; }
/* 슬라이드 카드 */
.slide-cards { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; margin-bottom: 20px; }
.slide-card { flex-shrink: 0; width: 160px; background: #27272a; border-radius: 14px; padding: 18px; text-align: center; }
.slide-card-icon { font-size: 28px; margin-bottom: 10px; }
.slide-card-value { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.slide-card-value.green { color: #22c55e; }
.slide-card-value.red { color: #ef4444; }
.slide-card-label { font-size: 11px; color: #71717a; }
/* 차트 */
.chart-section { background: #27272a; border-radius: 16px; padding: 20px; margin-bottom: 20px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.section-title { font-size: 15px; font-weight: 600; }
.chart-tabs { display: flex; gap: 4px; }
.chart-tab { padding: 6px 14px; background: #3f3f46; border: none; border-radius: 6px; color: #a1a1aa; font-size: 12px; cursor: pointer; }
.chart-tab.active { background: #a855f7; color: #fff; }
.chart-area { height: 260px; background: #18181b; border-radius: 12px; position: relative; overflow: hidden; }
.chart-candles { position: absolute; inset: 25px 40px 45px 20px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 12px; border-radius: 2px; position: relative; }
.candle.up { background: #22c55e; }
.candle.down { background: #ef4444; }
.signal { position: absolute; width: 20px; height: 20px; border-radius: 50%; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.signal.buy { bottom: -26px; left: 50%; transform: translateX(-50%); background: rgba(34, 197, 94, 0.3); color: #22c55e; border: 2px solid #22c55e; }
.signal.sell { top: -26px; left: 50%; transform: translateX(-50%); background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 2px solid #ef4444; }
/* 2컬럼 */
.two-col { display: grid; grid-template-columns: 1fr 1.2fr; gap: 16px; }
.panel { background: #27272a; border-radius: 16px; padding: 20px; }
/* 정확도 */
.accuracy-ring { width: 130px; height: 130px; margin: 0 auto 16px; border-radius: 50%; background: conic-gradient(#a855f7 0deg 252deg, #3f3f46 252deg 360deg); display: flex; align-items: center; justify-content: center; }
.accuracy-inner { width: 100px; height: 100px; background: #27272a; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.accuracy-pct { font-size: 28px; font-weight: 700; color: #a855f7; }
.accuracy-text { font-size: 10px; color: #71717a; }
.acc-bar { margin-bottom: 10px; }
.acc-bar-header { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; }
.acc-bar-bg { height: 6px; background: #3f3f46; border-radius: 3px; overflow: hidden; }
.acc-bar-fill { height: 100%; border-radius: 3px; }
.acc-bar-fill.green { background: #22c55e; }
.acc-bar-fill.purple { background: #a855f7; }
/* 거래 */
.trade-list { max-height: 280px; overflow-y: auto; }
.trade-item { display: flex; align-items: center; padding: 12px; background: #3f3f46; border-radius: 10px; margin-bottom: 8px; }
.trade-badge { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 12px; }
.trade-badge.buy { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.trade-badge.sell { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.trade-info { flex: 1; }
.trade-type { font-size: 13px; font-weight: 500; }
.trade-time { font-size: 11px; color: #71717a; }
.trade-result { text-align: right; }
.trade-price { font-size: 12px; }
.trade-pnl { font-size: 12px; font-weight: 600; }
.trade-pnl.profit { color: #22c55e; }
.trade-pnl.loss { color: #ef4444; }
</style>
</head>
<body>
<header class="header">
<span class="logo">📊 백테스트</span>
<span class="version-badge">Tablet-04 슬라이드</span>
</header>
<nav class="tab-nav">
<div class="tab-nav-item active">백테스트</div>
<div class="tab-nav-item">거래내역</div>
<div class="tab-nav-item">분석</div>
<div class="tab-nav-item">설정</div>
</nav>
<div class="container">
<div class="config-panel">
<div class="config-grid">
<div class="config-field">
<div class="config-label">종목</div>
<select class="config-input"><option>BTC/KRW</option></select>
</div>
<div class="config-field">
<div class="config-label">전략</div>
<select class="config-input"><option>MACD+RSI</option></select>
</div>
<div class="config-field">
<div class="config-label">시간봉</div>
<select class="config-input"><option>1시간</option></select>
</div>
<div class="config-field">
<div class="config-label">기간</div>
<select class="config-input"><option>3개월</option></select>
</div>
<div class="config-field">
<div class="config-label">초기자본</div>
<input type="text" class="config-input" value="₩10,000,000">
</div>
<button class="run-btn">▶ 백테스트 실행</button>
</div>
</div>
<div class="result-card">
<div class="result-row">
<div>
<div class="result-symbol">BTC/KRW</div>
<div class="result-meta">MACD+RSI · 1시간봉 · 3개월</div>
</div>
<div class="result-return">
<div class="result-value">+23.5%</div>
<div class="result-amount">₩2,350,000</div>
</div>
</div>
</div>
<div class="slide-cards">
<div class="slide-card">
<div class="slide-card-icon">💰</div>
<div class="slide-card-value">₩12.35M</div>
<div class="slide-card-label">최종 자산</div>
</div>
<div class="slide-card">
<div class="slide-card-icon">🎯</div>
<div class="slide-card-value green">68%</div>
<div class="slide-card-label">승률</div>
</div>
<div class="slide-card">
<div class="slide-card-icon">🔄</div>
<div class="slide-card-value">25회</div>
<div class="slide-card-label">총 거래</div>
</div>
<div class="slide-card">
<div class="slide-card-icon">📉</div>
<div class="slide-card-value red">-8.2%</div>
<div class="slide-card-label">최대 낙폭</div>
</div>
<div class="slide-card">
<div class="slide-card-icon">⚡</div>
<div class="slide-card-value green">+₩94K</div>
<div class="slide-card-label">평균 수익</div>
</div>
</div>
<div class="chart-section">
<div class="section-header">
<span class="section-title">📈 가격 차트</span>
<div class="chart-tabs">
<button class="chart-tab active">캔들</button>
<button class="chart-tab">라인</button>
</div>
</div>
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 45px;"></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle down" style="height: 40px;"></div>
<div class="candle down" style="height: 55px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle up" style="height: 90px;"></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle down" style="height: 50px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 65px;"></div>
<div class="candle up" style="height: 55px;"></div>
<div class="candle down" style="height: 40px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 60px;"></div>
</div>
</div>
</div>
<div class="two-col">
<div class="panel">
<div class="section-title" style="margin-bottom: 12px;">🎯 정확도</div>
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">전체 정확도</div>
</div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #71717a;">매수</span>
<span style="color: #22c55e;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #71717a;">매도</span>
<span style="color: #a855f7;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill purple" style="width: 64%;"></div></div>
</div>
</div>
<div class="panel">
<div class="section-header" style="margin-bottom: 12px;">
<span class="section-title">📋 거래 내역</span>
<span style="font-size: 11px; color: #a855f7; cursor: pointer;">전체보기 →</span>
</div>
<div class="trade-list">
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58,450,000</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55,520,000</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54,200,000</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=834">
<title>Tablet-05 컴팩트 프로</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans KR', sans-serif; background: #fff; color: #111827; width: 834px; min-height: 1194px; }
.header { padding: 16px 24px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; }
.logo { font-size: 18px; font-weight: 700; color: #111827; }
.header-right { display: flex; gap: 12px; align-items: center; }
.version-badge { background: #f3f4f6; color: #6b7280; padding: 4px 12px; border-radius: 8px; font-size: 11px; }
.btn { padding: 10px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid #e5e7eb; background: #fff; }
.btn-primary { background: #111827; color: #fff; border-color: #111827; }
.container { padding: 24px; }
/* 설정 */
.config-bar { display: flex; gap: 12px; margin-bottom: 24px; align-items: flex-end; flex-wrap: wrap; }
.config-field { flex: 1; min-width: 120px; }
.config-label { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
.config-input { width: 100%; padding: 10px 12px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; }
.run-btn { padding: 10px 24px; background: #111827; color: #fff; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; }
/* 결과 헤더 */
.result-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding: 24px; background: #f9fafb; border-radius: 16px; }
.result-left h2 { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.result-left p { font-size: 13px; color: #6b7280; }
.result-return { text-align: right; }
.return-value { font-size: 42px; font-weight: 700; color: #16a34a; }
.return-amount { font-size: 14px; color: #16a34a; }
/* 메트릭 */
.metrics-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 24px; }
.metric-box { background: #f9fafb; border-radius: 12px; padding: 16px; text-align: center; }
.metric-value { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.metric-value.green { color: #16a34a; }
.metric-value.red { color: #dc2626; }
.metric-label { font-size: 11px; color: #6b7280; }
/* 메인 그리드 */
.main-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
/* 차트 */
.chart-card { background: #f9fafb; border-radius: 16px; padding: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-title { font-size: 15px; font-weight: 600; }
.tabs { display: flex; gap: 4px; }
.tab { padding: 6px 14px; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 12px; color: #6b7280; cursor: pointer; }
.tab.active { background: #111827; border-color: #111827; color: #fff; }
.chart-area { height: 300px; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; position: relative; overflow: hidden; }
.chart-candles { position: absolute; inset: 25px 50px 50px 20px; display: flex; align-items: flex-end; justify-content: space-around; }
.candle { width: 12px; border-radius: 2px; position: relative; }
.candle.up { background: #16a34a; }
.candle.down { background: #dc2626; }
.signal { position: absolute; width: 22px; height: 22px; border-radius: 50%; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.signal.buy { bottom: -28px; left: 50%; transform: translateX(-50%); background: #dcfce7; color: #16a34a; border: 2px solid #16a34a; }
.signal.sell { top: -28px; left: 50%; transform: translateX(-50%); background: #fee2e2; color: #dc2626; border: 2px solid #dc2626; }
/* 사이드 */
.side-col { display: flex; flex-direction: column; gap: 16px; }
.side-card { background: #f9fafb; border-radius: 16px; padding: 20px; }
/* 정확도 */
.accuracy-center { text-align: center; }
.accuracy-ring { width: 120px; height: 120px; margin: 0 auto 16px; border-radius: 50%; background: conic-gradient(#16a34a 0deg 252deg, #e5e7eb 252deg 360deg); display: flex; align-items: center; justify-content: center; }
.accuracy-inner { width: 94px; height: 94px; background: #f9fafb; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.accuracy-pct { font-size: 26px; font-weight: 700; color: #16a34a; }
.accuracy-text { font-size: 10px; color: #6b7280; }
.acc-bar { margin-bottom: 10px; text-align: left; }
.acc-bar-header { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; }
.acc-bar-bg { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
.acc-bar-fill { height: 100%; border-radius: 3px; }
.acc-bar-fill.green { background: #16a34a; }
.acc-bar-fill.orange { background: #ea580c; }
/* 거래 */
.trade-list { max-height: 320px; overflow-y: auto; }
.trade-item { display: flex; align-items: center; padding: 12px; background: #fff; border: 1px solid #f3f4f6; border-radius: 10px; margin-bottom: 8px; }
.trade-badge { width: 34px; height: 34px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 12px; }
.trade-badge.buy { background: #dcfce7; color: #16a34a; }
.trade-badge.sell { background: #fee2e2; color: #dc2626; }
.trade-info { flex: 1; }
.trade-type { font-size: 13px; font-weight: 500; }
.trade-time { font-size: 11px; color: #9ca3af; }
.trade-result { text-align: right; }
.trade-price { font-size: 12px; }
.trade-pnl { font-size: 12px; font-weight: 600; }
.trade-pnl.profit { color: #16a34a; }
.trade-pnl.loss { color: #dc2626; }
</style>
</head>
<body>
<header class="header">
<span class="logo">📊 BackTest Pro</span>
<div class="header-right">
<span class="version-badge">Tablet-05 컴팩트</span>
<button class="btn">내보내기</button>
<button class="btn btn-primary">+ 새 테스트</button>
</div>
</header>
<div class="container">
<div class="config-bar">
<div class="config-field">
<div class="config-label">종목</div>
<select class="config-input"><option>BTC/KRW</option></select>
</div>
<div class="config-field">
<div class="config-label">전략</div>
<select class="config-input"><option>MACD+RSI</option></select>
</div>
<div class="config-field">
<div class="config-label">시간봉</div>
<select class="config-input"><option>1시간</option></select>
</div>
<div class="config-field">
<div class="config-label">기간</div>
<select class="config-input"><option>3개월</option></select>
</div>
<div class="config-field">
<div class="config-label">초기자본</div>
<input type="text" class="config-input" value="₩10,000,000">
</div>
<button class="run-btn">▶ 실행</button>
</div>
<div class="result-header">
<div class="result-left">
<h2>BTC/KRW</h2>
<p>MACD+RSI 전략 · 1시간봉 · 2024.01 - 03</p>
</div>
<div class="result-return">
<div class="return-value">+23.5%</div>
<div class="return-amount">₩2,350,000 수익</div>
</div>
</div>
<div class="metrics-row">
<div class="metric-box">
<div class="metric-value">₩12.35M</div>
<div class="metric-label">최종 자산</div>
</div>
<div class="metric-box">
<div class="metric-value green">68%</div>
<div class="metric-label">승률</div>
</div>
<div class="metric-box">
<div class="metric-value">25회</div>
<div class="metric-label">총 거래</div>
</div>
<div class="metric-box">
<div class="metric-value green">17회</div>
<div class="metric-label">수익</div>
</div>
<div class="metric-box">
<div class="metric-value red">8회</div>
<div class="metric-label">손실</div>
</div>
<div class="metric-box">
<div class="metric-value red">-8.2%</div>
<div class="metric-label">MDD</div>
</div>
</div>
<div class="main-grid">
<div class="chart-card">
<div class="card-header">
<span class="card-title">📈 가격 차트 & 매매 시점</span>
<div class="tabs">
<button class="tab active">캔들</button>
<button class="tab">라인</button>
<button class="tab">수익곡선</button>
</div>
</div>
<div class="chart-area">
<div class="chart-candles">
<div class="candle up" style="height: 50px;"></div>
<div class="candle up" style="height: 70px;"></div>
<div class="candle down" style="height: 45px;"></div>
<div class="candle down" style="height: 60px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 80px;"></div>
<div class="candle up" style="height: 100px;"></div>
<div class="candle up" style="height: 90px;"></div>
<div class="candle down" style="height: 55px;"><div class="signal sell">S</div></div>
<div class="candle down" style="height: 70px;"></div>
<div class="candle up" style="height: 60px;"></div>
<div class="candle down" style="height: 45px;"><div class="signal buy">B</div></div>
<div class="candle up" style="height: 65px;"></div>
<div class="candle up" style="height: 85px;"></div>
</div>
</div>
</div>
<div class="side-col">
<div class="side-card accuracy-center">
<div class="card-title" style="margin-bottom: 12px;">🎯 전략 정확도</div>
<div class="accuracy-ring">
<div class="accuracy-inner">
<div class="accuracy-pct">70%</div>
<div class="accuracy-text">정확도</div>
</div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #6b7280;">매수</span>
<span style="color: #16a34a; font-weight: 600;">76%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill green" style="width: 76%;"></div></div>
</div>
<div class="acc-bar">
<div class="acc-bar-header">
<span style="color: #6b7280;">매도</span>
<span style="color: #ea580c; font-weight: 600;">64%</span>
</div>
<div class="acc-bar-bg"><div class="acc-bar-fill orange" style="width: 64%;"></div></div>
</div>
</div>
<div class="side-card">
<div class="card-header" style="margin-bottom: 12px;">
<span class="card-title">📋 최근 거래</span>
</div>
<div class="trade-list">
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.15 14:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩58.4M</div>
<div class="trade-pnl profit">+5.2%</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge buy">B</div>
<div class="trade-info">
<div class="trade-type">매수</div>
<div class="trade-time">03.12 09:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩55.5M</div>
<div class="trade-pnl">-</div>
</div>
</div>
<div class="trade-item">
<div class="trade-badge sell">S</div>
<div class="trade-info">
<div class="trade-type">매도</div>
<div class="trade-time">03.08 16:00</div>
</div>
<div class="trade-result">
<div class="trade-price">₩54.2M</div>
<div class="trade-pnl loss">-2.1%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+523
View File
@@ -0,0 +1,523 @@
import React, { useState } from 'react';
import {
ThemeProvider,
CssBaseline,
Box,
AppBar,
Toolbar,
Typography,
Button,
Container,
IconButton,
Tooltip,
Chip,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Divider,
Badge,
} from '@mui/material';
import {
Dashboard as DashboardIcon,
ShowChart,
CloudDownload,
Schedule,
Settings as SettingsIcon,
Analytics,
LightMode,
DarkMode,
Login as LoginIcon,
Logout as LogoutIcon,
AccountCircle,
Person,
Rule as RuleIcon,
AccountBalance as SimulationIcon,
TrendingUp as TrendingUpIcon,
Fullscreen,
FullscreenExit,
Notifications as NotificationsIcon,
ListAlt as OrderbookIcon,
FormatListBulleted as AlertListIcon,
AutoAwesome as AutoTradingIcon,
GridView as MultiChartIcon,
ClearAll as ClearAllIcon,
CompareArrows as CompareIcon,
} from '@mui/icons-material';
import { useAppTheme } from './contexts/ThemeContext';
import { useIndicatorPopup } from './contexts/IndicatorPopupContext';
import { useAuth } from './contexts/AuthContext';
import { useAlertNotification } from './contexts/AlertNotificationContext';
import { useNavigation } from './contexts/NavigationContext';
import { useIsMobile } from './hooks/useIsMobile';
import { MobileAppShell } from './components/MobileAppShell';
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 CandlestickTestPage3 from './pages/CandlestickTestPage3';
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 ChartValidationPage from './pages/admin/ChartValidationPage';
import LoginDialog from './components/LoginDialog';
import SignUpDialog from './components/SignUpDialog';
import IndicatorConfigPanel from './components/IndicatorConfigPanel';
import IndicatorPopupWindow from './components/IndicatorPopupWindow';
import AlertSnackbar from './components/alert/AlertSnackbar';
interface MenuItemType {
shortText: string;
fullText: string;
icon: React.ReactNode;
page: string;
}
const menuItems: MenuItemType[] = [
{ shortText: '대시보드', fullText: '대시보드', icon: <DashboardIcon />, page: 'dashboard' },
{ shortText: '멀티차트', fullText: '멀티차트', icon: <MultiChartIcon />, page: 'multi-chart' },
{ shortText: '멀티2', fullText: '멀티차트2', icon: <MultiChartIcon />, page: 'multi-chart-2' },
{ shortText: '추세', fullText: '상승종목 추세분석', icon: <TrendingUpIcon />, page: 'rising-stocks' },
{ shortText: '패턴', fullText: '패턴검색', icon: <ShowChart />, page: 'pattern-search' },
{ shortText: '투자', fullText: '모의투자', icon: <SimulationIcon />, page: 'simulation' },
{ shortText: '전략', fullText: '전략설정', icon: <RuleIcon />, page: 'strategy-tree' },
{ shortText: '분석', fullText: '투자분석', icon: <Analytics />, page: 'candlestick-test' },
{ shortText: '분석3', fullText: '투자분석3 (프론트엔드 지표)', icon: <Analytics />, page: 'candlestick-test-3' },
{ shortText: '알림', fullText: '알림 설정', icon: <NotificationsIcon />, page: 'alerts' },
{ shortText: '알림목록', fullText: '알림목록', icon: <AlertListIcon />, page: 'alert-list' },
{ shortText: '호가', fullText: '실시간 호가', icon: <OrderbookIcon />, page: 'orderbook' },
{ shortText: '자동매매', fullText: '자동매매', icon: <AutoTradingIcon />, page: 'auto-trading' },
{ shortText: '검증', fullText: '차트 비교검증', icon: <CompareIcon />, page: 'chart-validation' },
{ shortText: '데이터', fullText: '데이터 수집', icon: <CloudDownload />, page: 'data-collection' },
{ shortText: '스케쥴', fullText: '스케쥴러', icon: <Schedule />, page: 'scheduler' },
{ shortText: '설정', fullText: '설정', icon: <SettingsIcon />, page: 'settings' },
];
function MainContent() {
const isMobile = useIsMobile();
const { currentPage, navigateToPage } = useNavigation();
const { themeMode, setThemeMode, theme } = useAppTheme();
const { isAuthenticated, user, logout } = useAuth();
const { isPopupOpen, closePopup, chartData, chartMargin } = useIndicatorPopup();
const { unreadCount, notifications, dismissAllNotifications } = useAlertNotification();
// 로그인/회원가입 다이얼로그 상태
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signUpDialogOpen, setSignUpDialogOpen] = useState(false);
// 사용자 메뉴 상태
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
// 전체화면 상태
const [isFullscreen, setIsFullscreen] = useState(false);
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchor(event.currentTarget);
};
const handleUserMenuClose = () => {
setUserMenuAnchor(null);
};
const handleLogout = () => {
logout();
handleUserMenuClose();
};
const renderPage = () => {
// 투자분석/투자분석3: 상태 유지를 위해 항상 마운트하되 css display로 노출 여부 제어
if (currentPage === 'candlestick-test') return null;
if (currentPage === 'candlestick-test-3') return null;
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 'chart-validation':
return <ChartValidationPage />;
case 'settings':
return <SettingsPage />;
default:
return <DashboardPage />;
}
};
const handleThemeToggle = () => {
setThemeMode(themeMode === 'dark' ? 'light' : 'dark');
};
// 전체화면 토글 함수
const handleFullscreenToggle = () => {
if (!document.fullscreenElement) {
// 전체화면으로 전환
document.documentElement.requestFullscreen().catch((err) => {
console.error('전체화면 전환 실패:', err);
});
} else {
// 전체화면 해제
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
};
// 전체화면 상태 변경 감지
React.useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
// 다크 모드 AppBar 스타일
const darkAppBarStyle = {
background: 'linear-gradient(90deg, #0a0a0f 0%, #12121a 50%, #1a1a2e 100%)',
borderBottom: '1px solid rgba(100, 149, 237, 0.2)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
};
// 라이트 모드 AppBar 스타일
const lightAppBarStyle = {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.15)',
};
// 모바일 접속 시 모바일 앱 UI (전체 화면 모바일 최적화)
if (isMobile) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<MobileAppShell />
</ThemeProvider>
);
}
// 데스크톱: 기존 레이아웃 유지
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
{/* Top Navigation Bar */}
<AppBar
position="fixed"
sx={{
...(themeMode === 'dark' ? darkAppBarStyle : lightAppBarStyle),
zIndex: (theme: any) => theme.zIndex.drawer + 100,
}}
>
<Toolbar sx={{ justifyContent: 'space-between' }}>
{/* Logo & Title */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Analytics sx={{ fontSize: 32, color: themeMode === 'dark' ? 'primary.main' : '#ffffff', mr: 1.5 }} />
<Typography
variant="h6"
noWrap
component="div"
sx={{
color: themeMode === 'dark' ? 'primary.main' : '#ffffff',
fontWeight: 700,
letterSpacing: '0.5px',
}}
>
Golden Analysis
</Typography>
{/* <Typography
variant="body2"
sx={{
ml: 2,
color: themeMode === 'dark' ? 'text.secondary' : 'rgba(255, 255, 255, 0.8)',
display: { xs: 'none', sm: 'block' },
}}
>
투자전략 성능 분석 시스템
</Typography> */}
</Box>
{/* Navigation Menu */}
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{menuItems.map((item) => (
<Tooltip
key={item.page}
title={item.fullText}
arrow
placement="bottom"
enterDelay={200}
leaveDelay={0}
>
<IconButton
onClick={() => navigateToPage(item.page)}
sx={{
color: themeMode === 'dark'
? (currentPage === item.page ? 'primary.main' : 'text.secondary')
: (currentPage === item.page ? '#ffffff' : 'rgba(255, 255, 255, 0.7)'),
backgroundColor: currentPage === item.page
? (themeMode === 'dark' ? 'rgba(100, 149, 237, 0.15)' : 'rgba(255, 255, 255, 0.2)')
: 'transparent',
borderBottom: currentPage === item.page ? '2px solid' : '2px solid transparent',
borderBottomColor: currentPage === item.page
? (themeMode === 'dark' ? 'primary.main' : '#ffffff')
: 'transparent',
borderRadius: 0,
'&:hover': {
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.1)' : 'rgba(255, 255, 255, 0.15)',
color: themeMode === 'dark' ? 'primary.light' : '#ffffff',
},
transition: 'all 0.2s ease-in-out',
}}
>
{item.page === 'alerts' && unreadCount > 0 ? (
<Badge badgeContent={unreadCount} color="error">
{item.icon}
</Badge>
) : (
item.icon
)}
</IconButton>
</Tooltip>
))}
{/* Theme Toggle Button */}
<Tooltip title={themeMode === 'dark' ? '라이트 모드로 전환' : '다크 모드로 전환'}>
<IconButton
onClick={handleThemeToggle}
sx={{
ml: 2,
color: themeMode === 'dark' ? 'primary.main' : '#ffffff',
'&:hover': {
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.1)' : 'rgba(255, 255, 255, 0.15)',
},
}}
>
{themeMode === 'dark' ? <LightMode /> : <DarkMode />}
</IconButton>
</Tooltip>
{/* Fullscreen Toggle Button */}
<Tooltip title={isFullscreen ? '전체화면 해제' : '전체화면으로 전환'}>
<IconButton
onClick={handleFullscreenToggle}
sx={{
ml: 1,
color: themeMode === 'dark' ? 'primary.main' : '#ffffff',
'&:hover': {
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.1)' : 'rgba(255, 255, 255, 0.15)',
},
}}
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{/* 알림팝업 전체 닫기 버튼 */}
<Tooltip title="알림팝업 전체 닫기">
<span>
<IconButton
onClick={dismissAllNotifications}
disabled={notifications.length === 0}
sx={{
ml: 1,
color: themeMode === 'dark' ? 'primary.main' : '#ffffff',
'&:hover': {
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.1)' : 'rgba(255, 255, 255, 0.15)',
},
'&.Mui-disabled': {
color: themeMode === 'dark' ? 'text.disabled' : 'rgba(255, 255, 255, 0.4)',
},
}}
aria-label="알림팝업 전체 닫기"
>
<ClearAllIcon />
</IconButton>
</span>
</Tooltip>
{/* 로그인/사용자 버튼 */}
{isAuthenticated ? (
<>
<Chip
icon={<AccountCircle />}
label={user?.name || user?.username || '사용자'}
onClick={handleUserMenuOpen}
sx={{
ml: 2,
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.2)' : 'rgba(255, 255, 255, 0.2)',
color: themeMode === 'dark' ? 'primary.main' : '#ffffff',
cursor: 'pointer',
'&:hover': {
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.3)' : 'rgba(255, 255, 255, 0.3)',
},
}}
/>
<Menu
anchorEl={userMenuAnchor}
open={Boolean(userMenuAnchor)}
onClose={handleUserMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem disabled>
<ListItemIcon>
<Person fontSize="small" />
</ListItemIcon>
<ListItemText
primary={user?.username}
secondary={user?.email}
/>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="로그아웃" />
</MenuItem>
</Menu>
</>
) : (
<Button
variant="outlined"
startIcon={<LoginIcon />}
onClick={() => setLoginDialogOpen(true)}
sx={{
ml: 2,
borderColor: themeMode === 'dark' ? 'primary.main' : '#ffffff',
color: themeMode === 'dark' ? 'primary.main' : '#ffffff',
'&:hover': {
borderColor: themeMode === 'dark' ? 'primary.light' : '#ffffff',
backgroundColor: themeMode === 'dark' ? 'rgba(100, 149, 237, 0.1)' : 'rgba(255, 255, 255, 0.15)',
},
}}
>
로그인
</Button>
)}
</Box>
{/* Version */}
<Typography
variant="caption"
sx={{
color: themeMode === 'dark' ? 'text.secondary' : 'rgba(255, 255, 255, 0.7)',
display: { xs: 'none', md: 'block' },
}}
>
v1.1.0
</Typography>
</Toolbar>
</AppBar>
{/* Main Content - Full Width */}
<Box
component="main"
sx={{
flexGrow: 1,
bgcolor: 'background.default',
mt: '64px', // AppBar height offset
minHeight: 'calc(100vh - 64px)',
}}
>
<Container
maxWidth={false}
sx={{
py: 2,
px: { xs: 2, sm: 3 },
height: 'calc(100vh - 64px)',
overflow: 'auto',
}}
>
{/* 상태 유지가 필요한 투자분석 화면 */}
<Box sx={{ display: currentPage === 'candlestick-test' ? 'block' : 'none', height: '100%' }}>
<CandlestickTestPage />
</Box>
{/* 투자분석3 (프론트엔드 지표 계산 버전) — 상태 유지 */}
<Box sx={{ display: currentPage === 'candlestick-test-3' ? 'block' : 'none', height: '100%' }}>
<CandlestickTestPage3 />
</Box>
{renderPage()}
</Container>
</Box>
</Box>
{/* 로그인 다이얼로그 */}
<LoginDialog
open={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onSwitchToSignUp={() => {
setLoginDialogOpen(false);
setSignUpDialogOpen(true);
}}
/>
{/* 회원가입 다이얼로그 */}
<SignUpDialog
open={signUpDialogOpen}
onClose={() => setSignUpDialogOpen(false)}
onSwitchToLogin={() => {
setSignUpDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
{/* 지표 설정 패널 (우측 하단 FAB) */}
<IndicatorConfigPanel />
{/* 보조지표 팝업 윈도우 */}
<IndicatorPopupWindow
open={isPopupOpen}
onClose={closePopup}
chartData={chartData}
chartMargin={chartMargin}
/>
{/* 알림 스낵바 (모든 화면에서 표시) */}
<AlertSnackbar />
</ThemeProvider>
);
}
function App() {
// Context providers are now in AppRouter.tsx
return <MainContent />;
}
export default App;
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import App from './App';
import MobileChartPage from './pages/MobileChartPage';
import RealtimeGaugeTestPage from './pages/RealtimeGaugeTestPage';
import { AppThemeProvider } from './contexts/ThemeContext';
import { ChartColorProvider } from './contexts/ChartColorContext';
import { IndicatorVisibilityProvider } from './contexts/IndicatorVisibilityContext';
import { IndicatorSettingsProvider } from './contexts/IndicatorSettingsContext';
import { IndicatorPopupProvider } from './contexts/IndicatorPopupContext';
import { AuthProvider } from './contexts/AuthContext';
import { UpbitDataProvider } from './contexts/UpbitDataContext';
import { UIThemeProvider } from './contexts/UIThemeContext';
import { AlertNotificationProvider } from './contexts/AlertNotificationContext';
import { NavigationProvider } from './contexts/NavigationContext';
/**
* 앱 라우터
* - 데스크톱: / (기본 앱)
* - 모바일: /mobile/chart (모바일 차트 페이지)
*/
const AppRouter: React.FC = () => {
return (
<BrowserRouter>
<AppThemeProvider>
<ChartColorProvider>
<IndicatorSettingsProvider>
<IndicatorVisibilityProvider>
<IndicatorPopupProvider>
<AuthProvider>
<UIThemeProvider>
<UpbitDataProvider>
<AlertNotificationProvider>
<NavigationProvider>
<Routes>
{/* 데스크톱 버전 */}
<Route path="/" element={<App />} />
{/* 모바일 차트 페이지 */}
<Route path="/mobile/chart" element={<MobileChartPage />} />
{/* 실시간 게이지 테스트 페이지 */}
<Route path="/gauge-test" element={<RealtimeGaugeTestPage />} />
{/* 기본 리다이렉트 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</NavigationProvider>
</AlertNotificationProvider>
</UpbitDataProvider>
</UIThemeProvider>
</AuthProvider>
</IndicatorPopupProvider>
</IndicatorVisibilityProvider>
</IndicatorSettingsProvider>
</ChartColorProvider>
</AppThemeProvider>
</BrowserRouter>
);
};
export default AppRouter;
@@ -0,0 +1,321 @@
/**
* 실시간 업데이트 통합 테스트
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRealtimeCandles } from '../hooks/useRealtimeCandles';
import { marketSocket } from '../services/marketSocket';
// Mock marketSocket
jest.mock('../services/marketSocket', () => ({
marketSocket: {
connect: jest.fn().mockResolvedValue(undefined),
subscribe: jest.fn().mockReturnValue(() => {}),
unsubscribe: jest.fn(),
onConnectionStatusChange: jest.fn().mockReturnValue(() => {}),
onError: jest.fn().mockReturnValue(() => {}),
},
}));
describe('useRealtimeCandles Hook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should connect and subscribe when enabled', async () => {
const { result } = renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: true,
})
);
await waitFor(() => {
expect(marketSocket.connect).toHaveBeenCalled();
expect(marketSocket.subscribe).toHaveBeenCalledWith(
'KRW-BTC',
'1m',
expect.any(Function)
);
});
});
it('should not connect when disabled', () => {
renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: false,
})
);
expect(marketSocket.connect).not.toHaveBeenCalled();
expect(marketSocket.subscribe).not.toHaveBeenCalled();
});
it('should update candles on snapshot', async () => {
const onUpdate = jest.fn();
const { result } = renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: true,
onUpdate,
})
);
// Simulate snapshot update
const mockUpdate = {
market: 'KRW-BTC',
interval: '1m',
candles: [
{ time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 },
{ time: '2026-03-07T01:01', open: 105, high: 115, low: 95, close: 110, volume: 1200 },
],
timestamp: Date.now(),
type: 'snapshot' as const,
};
// Get the callback that was passed to subscribe
const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0];
const callback = subscribeCall[2];
act(() => {
callback(mockUpdate);
});
await waitFor(() => {
expect(result.current.candles).toHaveLength(2);
expect(result.current.updateCount).toBe(1);
expect(onUpdate).toHaveBeenCalledWith(mockUpdate.candles);
});
});
it('should update existing candle on incremental update', async () => {
const onUpdate = jest.fn();
const { result } = renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: true,
onUpdate,
})
);
// Initial snapshot
const snapshotUpdate = {
market: 'KRW-BTC',
interval: '1m',
candles: [
{ time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 },
],
timestamp: Date.now(),
type: 'snapshot' as const,
};
const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0];
const callback = subscribeCall[2];
act(() => {
callback(snapshotUpdate);
});
// Incremental update (same time, different values)
const incrementalUpdate = {
market: 'KRW-BTC',
interval: '1m',
candles: [
{ time: '2026-03-07T01:00', open: 100, high: 115, low: 85, close: 108, volume: 1500 },
],
timestamp: Date.now(),
type: 'update' as const,
};
act(() => {
callback(incrementalUpdate);
});
await waitFor(() => {
expect(result.current.candles).toHaveLength(1);
expect(result.current.candles[0].close).toBe(108);
expect(result.current.candles[0].volume).toBe(1500);
expect(result.current.updateCount).toBe(2);
});
});
it('should add new candle on incremental update', async () => {
const onUpdate = jest.fn();
const { result } = renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: true,
onUpdate,
})
);
// Initial snapshot
const snapshotUpdate = {
market: 'KRW-BTC',
interval: '1m',
candles: [
{ time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 },
],
timestamp: Date.now(),
type: 'snapshot' as const,
};
const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0];
const callback = subscribeCall[2];
act(() => {
callback(snapshotUpdate);
});
// New candle
const newCandleUpdate = {
market: 'KRW-BTC',
interval: '1m',
candles: [
{ time: '2026-03-07T01:01', open: 105, high: 115, low: 95, close: 110, volume: 1200 },
],
timestamp: Date.now(),
type: 'update' as const,
};
act(() => {
callback(newCandleUpdate);
});
await waitFor(() => {
expect(result.current.candles).toHaveLength(2);
expect(result.current.candles[1].time).toBe('2026-03-07T01:01');
expect(result.current.updateCount).toBe(2);
});
});
it('should cleanup on unmount', () => {
const { unmount } = renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: true,
})
);
const unsubscribeFn = jest.fn();
(marketSocket.subscribe as jest.Mock).mockReturnValue(unsubscribeFn);
unmount();
// Note: In actual implementation, unsubscribe is called in cleanup
// This test verifies the hook structure
});
});
describe('Real-time Chart Integration', () => {
it('should handle timeframe switching', async () => {
const { result, rerender } = renderHook(
({ market, interval }) =>
useRealtimeCandles({
market,
interval,
enabled: true,
}),
{
initialProps: { market: 'KRW-BTC', interval: '1m' },
}
);
// Initial subscription
expect(marketSocket.subscribe).toHaveBeenCalledWith(
'KRW-BTC',
'1m',
expect.any(Function)
);
// Change interval
rerender({ market: 'KRW-BTC', interval: '5m' });
await waitFor(() => {
// Should resubscribe with new interval
expect(marketSocket.subscribe).toHaveBeenCalledWith(
'KRW-BTC',
'5m',
expect.any(Function)
);
});
});
it('should handle market switching', async () => {
const { result, rerender } = renderHook(
({ market, interval }) =>
useRealtimeCandles({
market,
interval,
enabled: true,
}),
{
initialProps: { market: 'KRW-BTC', interval: '1m' },
}
);
// Change market
rerender({ market: 'KRW-ETH', interval: '1m' });
await waitFor(() => {
// Should resubscribe with new market
expect(marketSocket.subscribe).toHaveBeenCalledWith(
'KRW-ETH',
'1m',
expect.any(Function)
);
});
});
});
describe('Indicator Recalculation', () => {
it('should trigger indicator recalculation on candle update', async () => {
// This test would require mocking the full CandlestickTestPage component
// For now, we verify the hook behavior
const onUpdate = jest.fn();
const { result } = renderHook(() =>
useRealtimeCandles({
market: 'KRW-BTC',
interval: '1m',
enabled: true,
onUpdate,
})
);
const mockUpdate = {
market: 'KRW-BTC',
interval: '1m',
candles: [
{ time: '2026-03-07T01:00', open: 100, high: 110, low: 90, close: 105, volume: 1000 },
],
timestamp: Date.now(),
type: 'update' as const,
};
const subscribeCall = (marketSocket.subscribe as jest.Mock).mock.calls[0];
const callback = subscribeCall[2];
act(() => {
callback(mockUpdate);
});
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled();
expect(onUpdate).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
time: '2026-03-07T01:00',
close: 105,
}),
])
);
});
});
});
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;

Some files were not shown because too many files have changed in this diff Show More