commit a4ea7762b5ff8b51ffb90cef61cd299c8c37de93 Author: aidev Date: Sat May 23 15:11:48 2026 +0900 goldenChat base source add diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..50a9efd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +frontend_golden/ +node_modules/ +.git/ +deploy-home.sh +# ta4j-master 는 backend 빌드에 필요하므로 포함 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd2ead5 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# ───────────────────────────────────────────────────────────── +# GoldenChart Docker Compose 환경 변수 예시 +# .env 파일로 복사 후 값 수정 +# ───────────────────────────────────────────────────────────── + +# MySQL +MYSQL_ROOT_PASSWORD=rootpassword +MYSQL_DATABASE=stockAnalyzer +MYSQL_USER=stock +MYSQL_PASSWORD=analyzer +MYSQL_PORT=3306 + +# Backend +BACKEND_PORT=8080 + +# Frontend (외부 노출 포트) +FRONTEND_PORT=80 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b66118c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Dependencies & build +**/node_modules/ +**/dist/ +**/target/ +**/.gradle/ + +# Environment (secrets) +.env +.env.local +.env.*.local + +# IDE / OS +.idea/ +.vscode/ +.cursor/ +**/.DS_Store +**/__MACOSX/ + +# Logs & cache +*.log +.npm/ +.cache/ + +# Deploy / local +*.tar.gz diff --git a/Golden_Analysis_Blueprint.pdf b/Golden_Analysis_Blueprint.pdf new file mode 100644 index 0000000..b524b0f Binary files /dev/null and b/Golden_Analysis_Blueprint.pdf differ diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..77d0c1f --- /dev/null +++ b/USER_MANUAL.md @@ -0,0 +1,1133 @@ +# GoldenChart 사용자 매뉴얼 + +> 버전: 2026-05-21 기준 +> 스택: Spring Boot + Ta4j (백엔드) · React + TypeScript + lightweight-charts (프론트엔드) + +--- + +## 목차 + +1. [앱 개요 및 시작](#1-앱-개요-및-시작) +2. [상단 메뉴바](#2-상단-메뉴바) +3. [실시간 차트 화면](#3-실시간-차트-화면) + - 3.1 [상단 툴바](#31-상단-툴바) + - 3.2 [드로잉 툴바 (좌측)](#32-드로잉-툴바-좌측) + - 3.3 [차트 본체 및 지표](#33-차트-본체-및-지표) + - 3.4 [멀티 차트 레이아웃](#34-멀티-차트-레이아웃) + - 3.5 [좌측 마켓 패널](#35-좌측-마켓-패널) + - 3.6 [우측 패널](#36-우측-패널) + - 3.7 [백테스팅 (차트 내)](#37-백테스팅-차트-내) + - 3.8 [실시간 전략 체크](#38-실시간-전략-체크) + - 3.9 [매매 알림 팝업](#39-매매-알림-팝업) + - 3.10 [기타 플로팅 패널](#310-기타-플로팅-패널) + - 3.11 [화면 내 설정·모달](#311-화면-내-설정모달) +4. [투자전략 화면](#4-투자전략-화면) + - 4.1 [화면 내 설정](#41-화면-내-설정) +5. [백테스팅 이력 화면](#5-백테스팅-이력-화면) +6. [설정 화면](#6-설정-화면) + - 6.0 [설정 화면 개요 및 저장 범위](#60-설정-화면-개요-및-저장-범위) + - 6.1 [일반 설정](#61-일반-설정) + - 6.2 [차트 설정](#62-차트-설정) + - 6.3 [백테스팅 설정](#63-백테스팅-설정) + - 6.4 [전략 설정](#64-전략-설정) + - 6.5 [알림 설정](#65-알림-설정) + - 6.6 [네트워크 설정](#66-네트워크-설정) +7. [드로잉 툴 상세 사용법](#7-드로잉-툴-상세-사용법) +8. [키보드 단축키 모음](#8-키보드-단축키-모음) +9. [미구현 기능 안내](#9-미구현-기능-안내) + +--- + +## 1. 앱 개요 및 시작 + +GoldenChart는 업비트 암호화폐 거래소의 실시간 시세를 기반으로 차트 분석·투자전략 수립·백테스팅을 지원하는 웹 기반 트레이딩 도구입니다. + +### 지원 데이터 소스 + +| 구분 | 방식 | 설명 | +|------|------|------| +| **업비트 마켓** | REST (초기 300봉) + WebSocket (실시간 틱) | KRW·BTC 마켓 | +| **기타 심볼** | 시뮬레이션 데이터 | 테스트용 | + +### 초기 화면 + +앱 진입 시 **실시간 차트** 화면이 기본으로 표시됩니다. 상단 메뉴에서 다른 화면으로 전환할 수 있습니다. + +--- + +## 2. 상단 메뉴바 + +화면 최상단에 항상 표시되는 글로벌 내비게이션 바입니다. + +| 메뉴 항목 | 기능 | +|-----------|------| +| **GoldenChart** (로고) | 홈(실시간 차트)으로 이동 | +| **대시보드** | 미구현 (메뉴만 존재) | +| **실시간차트** | 실시간 차트 화면으로 이동 | +| **투자전략** | 투자전략 빌더 화면으로 이동 | +| **백테스팅** | 백테스팅 이력 대시보드로 이동 | +| **설정** | 설정 화면으로 이동 | +| **테마 토글** (☀/🌙) | 다크 → 블루 → 라이트 테마 순환, DB에 자동 저장 | +| **로그인** | UI 버튼만 구현 (실제 인증 미연동) | + +--- + +## 3. 실시간 차트 화면 + +앱의 핵심 화면으로, 차트·지표·드로잉·백테스팅·실시간 전략 기능을 모두 포함합니다. + +--- + +### 3.1 상단 툴바 + +#### 좌측 컨트롤 + +| 버튼 | 기능 | +|------|------| +| **종목 검색** | 업비트 마켓 코드 검색 및 선택 (예: KRW-BTC) | +| **워치리스트** | 관심종목 패널 펼치기/접기 | +| **타임프레임** | `1m / 3m / 5m / 15m / 30m / 1h / 4h / 1D / 1W / 1M` 선택 | +| **차트 유형** | 캔들스틱 / 바 / 라인 / 영역 / 기준선 | +| **지표 추가 (Ind)** | 지표 선택 패널 열기 → 카테고리 탐색 또는 검색 후 클릭으로 추가 | +| **Undo / Redo** | 드로잉 되돌리기 / 다시 실행 | +| **자석 모드** | 크로스헤어 스냅 ON/OFF — ON 시 OHLCV 값에 자동 흡착 | +| **드로잉 전체 삭제** | 차트 위 모든 드로잉 오브젝트 제거 | +| **통계 패널** | 가격 통계 플로팅 창 열기 | +| **자동 피보나치** | 고점·저점 자동 피보나치 라인 표시 토글 | +| **그리드** | 차트 격자 표시 토글 | + +#### 차트 모드 전환 + +| 버튼 | 기능 | +|------|------| +| **차트뷰** | 기본 분석 모드 — 드로잉 툴 비활성, 스크롤/줌 최적화 | +| **트레이딩뷰** | 드로잉 모드 — 좌측 드로잉 툴바 활성화 | + +#### 백테스팅 버튼 그룹 + +> ⚠️ 싱글 차트 레이아웃 + 실시간 차트 페이지에서만 사용 가능합니다. + +| 버튼 | 기능 | +|------|------| +| **백테스팅** | 전략 드롭다운 열기 — 전략 이름과 **실행** 버튼 목록 표시 | +| **설정** (⚙) | 백테스팅 옵션 설정 모달 열기 | +| **결과** (📊) | 마지막 백테스트 결과 대시보드 팝업 | +| **마커 삭제** (🗑) | 차트에 표시된 매수/매도 마커 모두 제거 | + +> - **전략 실행**: 드롭다운에서 전략 우측 **실행** 버튼 클릭 → 현재 차트 캔들 데이터와 함께 백엔드로 전송 → 결과 마커 표시 +> - **결과 자동 팝업**: 설정 → 백테스팅에서 ON 시 실행 후 결과 팝업이 자동 열림 + +#### 실시간 전략 체크 버튼 + +| 버튼 | 기능 | +|------|------| +| **LiveStrategy** (⚡) | 실시간 전략 체크 패널 열기/닫기 | + +#### 우측 컨트롤 + +| 버튼 | 기능 | +|------|------| +| **좌측 패널 토글** | 마켓 패널 접기/펼치기 | +| **돋보기** | 차트 돋보기(확대경) 오버레이 팝업 | +| **레이아웃 (⊞)** | 멀티 차트 레이아웃 선택 및 동기화 설정 | +| **맞춤 (F)** | 차트 전체 데이터 범위에 맞게 뷰 조정 | +| **실시간 스크롤** | 최신 캔들로 자동 따라가기 ON/OFF | +| **가격 알림** (🔔) | 가격 알림 추가·관리 | +| **스크린샷** | 차트 영역 이미지 저장 | +| **전체화면** | 브라우저 전체화면 전환 | + +--- + +### 3.2 드로잉 툴바 (좌측) + +> **트레이딩뷰 모드**에서만 활성화됩니다. + +툴바는 그룹별로 구성되며, 각 그룹 아이콘을 **호버**하면 하위 툴 플라이아웃이 펼쳐집니다. + +#### 드로잉 공통 조작 + +| 조작 | 설명 | +|------|------| +| **클릭** | 앵커 포인트 배치 | +| **더블클릭 / Enter** | 드로잉 완료 확정 (다중 점 툴) | +| **Escape** | 현재 드로잉 취소 | +| **클릭 후 드래그** | 완성된 드로잉 이동 | +| **앵커 포인트 드래그** | 형태 조절 | +| **우클릭** | 설정·삭제 컨텍스트 메뉴 | + +#### 그룹 목록 및 하위 툴 + +| 그룹 | 주요 하위 툴 | +|------|-------------| +| **커서** | 선택/이동 (기본 탐색 모드) | +| **라인** | 추세선, 확장선(Ray), 화살표, 수평선, 수직선, 수평 채널, 오름/내림 채널, 선형 회귀, 예측 조정 | +| **피보나치** | 되돌림, 확장, 채널, 타임존, 피치포크(중간·수정·쉬프), 갠 박스, 갠 팬 | +| **패턴** | XABCD, 헤드앤숄더, 엘리엇 파동(ABC/WXYXZ), 사이퍼, 사이클 삼각형 | +| **프로젝션** | 롱/숏 포지션, 예측, 앵커드 VWAP, 고정 볼륨 프로파일, 바 패턴, 지그재그, 고스트 피드 | +| **도형** | 사각형, 회전 사각형, 원, 타원, 삼각형, 평행사변형, 다각형, 호 | +| **브러시** | 자유 붓, 하이라이터, 방향 화살표(위/아래) | +| **텍스트 & 노트** | 텍스트, 노트(핀), 가격 노트, 콜아웃(말풍선), 고정 텍스트, 가격 라벨 | +| **측정** | 날짜·가격 범위, 데이터 창 | +| **확대/축소** | 돋보기(확대 뷰) | + +#### 하단 고정 버튼 + +| 버튼 | 기능 | +|------|------| +| **자석** | 크로스헤어 스냅 강도 (끄기 / 약 / 강 / 지표 스냅) | +| **잠금** | 완성된 드로잉 이동 불가 고정 | +| **눈 (가시성)** | 드로잉 숨김 / 지표 숨김 / 전체 숨김 토글 | +| **되돌리기** | 마지막 드로잉 작업 취소 (Ctrl+Z) | +| **삭제** | 드로잉 삭제 / 지표 삭제 / 전체 삭제 | +| **레이어** | 오브젝트 트리 팝업 열기 (드로잉 목록 관리) | + +--- + +### 3.3 차트 본체 및 지표 + +#### 기본 차트 조작 + +| 조작 | 설명 | +|------|------| +| **스크롤 좌우** | 시간축 이동 (왼쪽: 과거 데이터 자동 추가 로드) | +| **마우스 휠** | 줌인/줌아웃 | +| **오른쪽 드래그** | 가격축 스케일 조절 | +| **F 키** | 데이터 전체 맞춤 | +| **L 키** | 로그 스케일 전환 | + +#### 무한 스크롤 (과거 데이터) + +차트를 왼쪽으로 스크롤하여 맨 첫 번째 캔들에 도달하면 자동으로 과거 데이터를 요청합니다. +- 인메모리(최근 300봉) 범위 → 즉시 반환 +- 인메모리 초과 → 업비트 REST API 프록시 조회 + +#### 지표 추가 방법 + +1. 상단 툴바 **Ind** 버튼 클릭 +2. 카테고리 탐색 또는 검색창에 지표 이름 입력 +3. 원하는 지표 클릭 → 차트에 즉시 추가 + +#### 지표 설정 + +| 방법 | 설명 | +|------|------| +| **pane 호버 → 설정 아이콘** | 지표 파라미터·색상·선 스타일 설정 | +| **지표 pane 우클릭** | 설정·숨김·삭제 메뉴 | +| **더블클릭 (지표 pane)** | 설정 모달 바로 열기 | + +#### 지표 설정 항목 + +- **기간**: 이동평균 기간, 오실레이터 기간 등 +- **색상**: 선 색상, 히스토그램 색상 +- **선 스타일**: 실선, 점선, 대시 +- **선 굵기**: 1~4px +- **과열선 / 중앙선 / 침체선**: 기준값·색상·스타일·굵기 개별 설정 + +> 지표 설정은 DB에 저장되어 앱 재실행 시에도 유지됩니다. + +#### 지원 지표 카테고리 (126개+) + +| 카테고리 | 대표 지표 | +|----------|-----------| +| **이동평균** | SMA(5/8/20/60/120), EMA(12/26), WMA, DEMA, TEMA, HMA, VWMA 등 | +| **오실레이터** | RSI, CCI, Stochastic, MACD, MFI, CMO, Williams %R, DPO, Aroon 등 | +| **추세** | ADX, DMI, Parabolic SAR, 일목균형표, TRIX 등 | +| **변동성** | 볼린저 밴드, ATR, 켈트너 채널, Donchian 채널, Mass Index 등 | +| **거래량** | OBV, Volume Oscillator, VWAP, Force Index, Chaikin MF 등 | +| **캔들 패턴** | 도지, 해머, 역망치, 봉선, 십자성, 삼병 등 44종 | + +#### 차트 범례 (Legend) + +크로스헤어를 차트 위에 올리면 각 pane에 현재 바의 값이 표시됩니다. +- **메인 pane**: 날짜·시간, O/H/L/C, 거래량, 등락률 +- **지표 pane**: 지표명, 계산된 값 + +--- + +### 3.4 멀티 차트 레이아웃 + +상단 툴바 우측 **레이아웃(⊞)** 버튼에서 분할 방식을 선택합니다. + +| 레이아웃 | 설명 | +|----------|------| +| **1분할** | 단일 차트 (기본) | +| **2분할 (가로/세로)** | 좌우 또는 상하 2개 | +| **3~8분할** | 다양한 그리드 구성 | + +#### 슬롯 관리 + +- 분할된 각 차트 슬롯을 **클릭하여 활성화** (파란 테두리 표시) +- 활성 슬롯에서 종목·타임프레임·지표 변경 가능 +- 슬롯 **전체보기 버튼**: 해당 슬롯을 싱글 화면으로 확대 + +#### 동기화 옵션 + +| 옵션 | 설명 | +|------|------| +| **심볼 동기화** | 모든 슬롯이 같은 종목을 표시 | +| **인터벌 동기화** | 모든 슬롯이 같은 타임프레임 유지 | +| **크로스헤어 동기화** | 한 슬롯의 크로스헤어가 모든 슬롯에 연동 | +| **시간축 동기화** | 모든 슬롯의 시간 범위 동기화 | + +--- + +### 3.5 좌측 마켓 패널 + +종목 목록과 실시간 호가를 표시합니다. + +#### 마켓 탭 + +| 탭 | 표시 내용 | +|----|-----------| +| **원화 (KRW)** | KRW 마켓 전체 종목, 현재가·등락률·거래량 | +| **BTC** | BTC 마켓 (USD 환산 가격 포함) | +| **보유** | localStorage에 저장된 보유 종목 | +| **관심** | 즐겨찾기(★) 등록 종목 | + +- **종목 클릭**: 활성 슬롯의 심볼 변경 +- **★ 버튼**: 관심종목 등록/해제 (DB 동기화) +- **가격 플래시**: 가격 변동 시 녹색(상승)/빨강(하락) 배경 깜빡임 + +#### 호가창 (OrderbookPanel) + +패널 하단에 실시간 업비트 WebSocket 호가 데이터 표시. + +| 영역 | 내용 | +|------|------| +| **매도 호가** | 가격(빨강), 잔량 바 | +| **현재가** | 중앙 강조 표시 | +| **매수 호가** | 가격(파랑), 잔량 바 | +| **스프레드** | 최우선 매도/매수 가격 차이 | + +--- + +### 3.6 우측 패널 + +우측 접기/펼치기 버튼으로 개폐합니다. + +| 탭 | 상태 | +|----|------| +| **알림** | UI 구조만 구현 (기능 미구현) | +| **매수** | UI 구조만 구현 (실제 주문 미연동) | +| **매도** | UI 구조만 구현 (실제 주문 미연동) | + +--- + +### 3.7 백테스팅 (차트 내) + +투자전략 빌더에서 만든 전략을 현재 차트 데이터로 즉시 테스트합니다. + +#### 실행 방법 + +1. 상단 툴바 **백테스팅** 버튼 클릭 → 전략 드롭다운 표시 +2. 원하는 전략 우측 **실행** 버튼 클릭 +3. 차트에 매수(▲ 초록), 매도(▼ 빨강) 마커 표시 +4. 결과 뱃지(시그널 수·승률·수익·MDD)가 상단에 표시 + +#### 결과 확인 + +| 방법 | 설명 | +|------|------| +| **상단 뱃지** | 간략 결과 (거래 수, 승률, 총 수익률, MDD) | +| **결과 버튼(📊)** | 상세 대시보드 팝업 (종합 지표·거래 이력·차트) | +| **백테스팅 이력 메뉴** | 과거 실행 결과 이력 대시보드 | + +#### 상세 결과 지표 + +| 지표 | 설명 | +|------|------| +| 총 거래 수 | 전체 매수·매도 쌍의 수 | +| 승률 | 수익 발생 거래 비율 | +| 총 수익률 | 초기 자본 대비 최종 수익 | +| 최대 낙폭 (MDD) | 고점 대비 최대 손실 | +| 평균 수익/손실 | 수익/손실 거래의 평균 | +| Profit Factor | 총 이익 / 총 손실 | +| Sharpe Ratio | 위험 조정 수익률 | +| 보유 기간 | 평균 포지션 유지 봉 수 | + +#### 마커 표시 옵션 + +- **매수 마커**: "매수" 한글 표시 (설정에서 금액 추가 표시 ON 시: "매수 : 1,000,000") +- **매도 마커**: "매도" 한글 표시 +- **마커 삭제**: 툴바 🗑 버튼으로 전체 제거 + +--- + +### 3.8 실시간 전략 체크 + +선택한 전략 조건을 실시간으로 모니터링하여 매수/매도 시그널을 차트에 표시합니다. + +#### 패널 열기 + +상단 툴바 **⚡** 버튼 클릭 → `LiveStrategyPanel` 팝업 표시 + +#### 설정 항목 + +| 항목 | 설명 | +|------|------| +| **실시간 체크 ON/OFF** | 토글 — ON 시 STOMP WebSocket 구독 시작 | +| **전략 선택** | 드롭다운에서 모니터링할 전략 선택 | +| **체크 방식** | **봉 마감 (CANDLE_CLOSE)**: 캔들 확정 시 판정 / **실시간 틱 (REALTIME_TICK)**: 3초 주기 판정 | +| **시그널 모드** | **보유 자산 기준 (LONG_ONLY)**: 매수 이력 있을 때만 매도 허용 / **순수 지표 기준 (SIGNAL_ONLY)**: 포지션 무관, 지표 충족 시 즉시 매도 | + +#### 마커 특징 + +- **중복 방지**: 동일 타임스탬프에 마커 무한 생성 방지 (Lock 메커니즘) +- **REALTIME_TICK**: 같은 봉 내 동일 시그널 중복 표시 안 함 + +#### LONG_ONLY vs SIGNAL_ONLY + +| 모드 | 매수 조건 | 매도 조건 | +|------|-----------|-----------| +| **LONG_ONLY** | 진입 규칙 충족 + 포지션 없을 때 | 청산 규칙 충족 + 포지션 있을 때 | +| **SIGNAL_ONLY** | 진입 규칙 충족 (포지션 무관) | 청산 규칙 충족 (포지션 무관) | + +> SIGNAL_ONLY 모드에서는 매도 시그널이 이전 매수 없이도 발생하므로 더 많은 마커가 표시됩니다. + +--- + +### 3.9 매매 알림 팝업 + +실시간 전략 체크에서 BUY/SELL 시그널 발생 시 화면에 팝업을 표시합니다. + +#### 활성화 조건 + +설정화면 → 전략 설정 → **매매 알림 팝업** ON + +#### 팝업 구성 + +| 영역 | 내용 | +|------|------| +| **헤더** | 시그널 유형(매수/매도), 종목, 시각 | +| **요약** | 가격, 전략명, 실행 방식 | +| **매수 탭** | 주문 유형(지정가/시장가/예약), 가격·수량 입력, 비율 버튼 | +| **매도 탭** | 동일 구성 | +| **간편주문 탭** | 미구현 안내 | +| **거래내역 탭** | 미구현 안내 | + +> ⚠️ 주문 탭의 **주문 버튼은 실제 거래소 API와 연동되지 않습니다.** 프로토타입 UI입니다. + +팝업은 화면 어디서나 **드래그하여 이동** 가능합니다. + +--- + +### 3.10 기타 플로팅 패널 + +#### 오브젝트 트리 + +드로잉 툴바 하단 **레이어** 버튼 → 드로잉 오브젝트 목록 팝업 + +- 드로잉 이름 목록 표시 +- 눈 아이콘으로 개별 표시/숨김 +- 휴지통 아이콘으로 개별 삭제 + +#### 통계 패널 (StatsPanel) + +상단 툴바 **통계** 버튼 → 가격 통계 플로팅 창 + +- 선택 기간 OHLCV 통계 표시 + +#### 관심종목 (WatchList) + +- 즐겨찾기 등록 종목 목록 +- 현재가·등락률 표시 +- 클릭 시 차트 심볼 변경 +- DB에 동기화 + +#### 가격 알림 (AlertManager) + +상단 툴바 **🔔** → 알림 패널 + +- 특정 가격 도달 시 알림 설정 +- 현재 알림 목록 관리 (추가/삭제) +- 차트에 수평선 표시 + +--- + +### 3.11 화면 내 설정·모달 + +실시간 차트 화면에서 **설정 화면(메뉴 → 설정)** 이 아닌, 차트 위에서 바로 열리는 설정 UI입니다. + +#### 설정 열기 방법 요약 + +| 설정 종류 | 열기 방법 | +|-----------|-----------| +| **메인 차트(캔들) 색상** | 차트 우클릭 → 설정, 또는 메인 pane 설정 | +| **지표** | 지표 pane 호버 → ⚙ / 우클릭 → 설정 / pane 더블클릭 | +| **드로잉** | 완성된 드로잉 우클릭 → 설정 | +| **피보나치 타임존** | 피보나치 TZ 드로잉 우클릭 → 설정 | +| **백테스팅 (빠른)** | 툴바 백테스팅 ⚙ → 모달 (설정 화면과 동일 DB 연동) | +| **실시간 전략** | 툴바 ⚡ → `LiveStrategyPanel` (설정 화면 전략 탭과 DB 공유) | +| **가격 알림** | 툴바 🔔 → `AlertManager` | +| **멀티 레이아웃 동기화** | 툴바 ⊞ → 레이아웃 팝업 내 동기화 체크박스 | + +--- + +#### 메인 차트 설정 (`MainChartSettingsModal`) + +| 항목 | 설명 | 저장 | +|------|------|------| +| **몸통 (Body)** | 상승봉·하락봉 몸통 색 (좌=상승, 우=하락) | DB (`gc_app_settings.mainChartStyle`) | +| **테두리 (Borders)** | 캔들 테두리 색 | 동일 | +| **심지 (Wick)** | 꼬리(심지) 색 | 동일 | +| **기본값** | 앱 기본 캔들 색으로 복원 | — | +| **확인 / 취소** | 적용 또는 변경 취소 | — | + +> 설정 화면 **차트 설정**의 색상 항목과 별도입니다. 차트 설정 탭은 현재 로컬 상태만이며, 실제 차트에 반영되는 캔들 색은 이 모달·DB 값이 우선합니다. + +--- + +#### 지표 설정 (`IndicatorSettingsModal`) + +탭: **입력값** · **스타일** · **가시성** + +| 탭 | 항목 | 설명 | 저장 | +|----|------|------|------| +| **입력값** | 기간·소스 등 | 지표별 `params` (예: RSI 기간 14, 소스 close) | DB (지표별 설정) | +| **입력값** | SMA 전용 | MA1~MA8 각각 ON/OFF + 기간, 공통 소스 | DB | +| **입력값** | 일목 전용 | 전환선·기준선·선행스팬 기간 등 | DB | +| **스타일** | 플롯 | 선 색·굵기·유형(선/히스토그램/영역 등) | DB | +| **스타일** | 수평선 | **과열선·중앙선·침체선** 가격·색·선형·굵기·표시 여부 | DB | +| **스타일** | 일목 구름 | 상승/하락 구름 색·투명도 | DB | +| **가시성** | 플롯별 | 개별 선/히스토그램 표시 ON/OFF | DB | +| **기본값** | — | 지표 레지스트리 기본값으로 복원 | — | + +**소스(src) 옵션**: `close`, `open`, `high`, `low`, `hl2`, `hlc3`, `ohlc4` + +> 전략 빌더에서 지표를 추가할 때, **현재 차트에 적용된 지표 설정값**(기간·과열/침체선 포함)이 전략 조건의 기본값으로 복사됩니다. + +--- + +#### 드로잉 설정 (`DrawingSettingsModal`) + +| 항목 | 설명 | 비고 | +|------|------|------| +| **색상** | 프리셋 20색 + 커스텀 컬러 피커 | 모든 드로잉 공통 | +| **선 굵기** | 1~4px | text·measure 유형 제외 | +| **선 스타일** | 실선 / 파선 / 점선 | text·measure 제외 | +| **텍스트** | 표시 문구 (text 타입만) | — | + +드로잉 데이터는 차트·종목·타임프레임별로 저장됩니다. + +--- + +#### 피보나치 타임존 설정 (`FibTZSettingsModal`) + +| 항목 | 설명 | +|------|------| +| **레벨 목록** | 각 피보나치 비율(%)·표시 여부·색·선형·굵기 | +| **기준선** | 0%/100% 기준선 스타일 | +| **확장** | TZ 구간 연장 옵션 | + +--- + +#### 백테스팅 빠른 설정 (`BacktestSettingsModal`) + +차트 툴바 ⚙ 로 열리는 모달입니다. **설정 화면 → 백테스팅**과 동일한 `BacktestSettingsDto`를 DB에 저장합니다. 각 필드에 **?** 도움말(상세 설명)이 붙어 있습니다. + +| 구역 | 항목 | 요약 | +|------|------|------| +| 표시 | 결과 자동 팝업 | 실행 후 결과 대시보드 자동 오픈 (`AppSettings`, DB) | +| 표시 | 매수/매도 금액 표시 | 마커 문구에 금액 포함 여부 (`AppSettings`, DB) | +| 자본 | 초기 자본·거래 규모 | 시뮬레이션 자금·비율/고정금액 | +| 비용 | 수수료·슬리피지 | LINEAR/ZERO, 체결 가격 미끄럼 | +| 가격 | 진입/청산/방향 | 종가 vs 다음봉 시가, LONG/SHORT/BOTH | +| 리스크 | 손절·익절·트레일링 | Ta4j 규칙 연동 | +| 기타 | 재진입·분할청산·포지션 모드 | [6.3절](#63-백테스팅-설정) 참고 | + +--- + +#### 실시간 전략 패널 (`LiveStrategyPanel`) + +설정 화면 **전략 설정 → 실시간 전략 체크**와 **동일 DB**(`gc_live_strategy_settings`, 종목별)를 사용합니다. 한쪽에서 변경하면 다른 쪽에도 반영됩니다. + +| 항목 | 설명 | +|------|------| +| 실시간 체크 | ON 시 백엔드 STOMP 구독·시그널 평가 시작 | +| 전략 선택 | 투자전략 빌더에 저장된 전략 ID | +| 실행 방식 | 봉 마감 직후 / 실시간 틱(3초) — 설정 화면의 **체크 방식**과 동일 | +| 시그널 모드 | Long-Only / Signal-Only — 설정 화면 **포지션 종속성 모드**와 동일 | + +> 설정 변경 시 기존 차트 마커는 자동 초기화됩니다. + +--- + +#### 가격 알림 (`AlertManager`) + +| 항목 | 설명 | 저장 | +|------|------|------| +| **가격** | 알림을 받을 목표가 (숫자) | 세션/앱 상태 | +| **메모** | 선택 라벨 | 동일 | +| **추가** | 목록에 알림 등록, 차트 수평선 표시 | — | +| **삭제** | 항목별 ✕ | — | + +설정 화면 **알림 설정**의 가격 알림 토글과는 아직 연동되지 않았습니다. 실제 알림 발송은 미구현입니다. + +--- + +#### 멀티 차트 레이아웃 동기화 + +툴바 **⊞** 팝업에서 설정합니다. `AppSettings.syncOptions`에 DB 저장됩니다. + +| 옵션 | 동작 | +|------|------| +| 심볼 동기화 | 모든 슬롯 종목 통일 | +| 인터벌 동기화 | 타임프레임 통일 | +| 크로스헤어 동기화 | 커서 위치 연동 | +| 시간축 동기화 | 보이는 시간 범위 연동 | + +--- + +#### 테마 + +상단 메뉴바 **☀/🌙** 클릭 시 **다크 → 블루 → 라이트** 순환. `AppSettings.defaultTheme`에 DB 저장됩니다. + +--- + +## 4. 투자전략 화면 + +전략 조건을 시각적으로 구성하고 DB에 저장하는 빌더 화면입니다. + +### 화면 구성 + +``` +[좌: 전략 목록] | [중: 조건 편집기] | [우: 팔레트/미리보기] +``` + +### 전략 목록 (좌측) + +| 기능 | 설명 | +|------|------| +| **새 전략** | 빈 전략 생성 | +| **전략 선택** | 클릭 시 편집기에 로드 | +| **정렬** | 이름 / 날짜 정렬 | +| **삭제** | 체크박스로 선택 후 삭제 | +| **저장** | 편집 내용 DB 저장 | + +> 전략은 DB에 저장되며, 로컬스토리지(`gc_strat_v2`) 백업도 유지됩니다. + +### 조건 편집기 (중앙) + +#### 논리 트리 구조 + +| 노드 타입 | 설명 | +|-----------|------| +| **AND** | 모든 하위 조건 동시 충족 | +| **OR** | 하위 조건 중 하나라도 충족 | +| **NOT** | 하위 조건 부정 | +| **CONDITION** | 실제 지표 비교 조건 | + +#### 조건 타입 + +| 타입 | 설명 | +|------|------| +| 비교 (GREATER/LESS/EQUAL) | A > B, A < B, A == B | +| 상향 돌파 (CROSS_UP) | A가 B를 아래에서 위로 교차 | +| 하향 돌파 (CROSS_DOWN) | A가 B를 위에서 아래로 교차 | +| N일 유지 | 조건이 N봉 동안 연속 충족 | +| 기울기 (SLOPE_UP/DOWN) | 지표값이 상승/하강 추세 | +| 일목 구름 (KUMO_ABOVE/BELOW) | 가격이 구름 위/아래 | + +#### 조건 편집 + +- **드래그앤드롭**: 조건 순서 변경 +- **노드 추가**: AND/OR/NOT/CONDITION 노드 추가 버튼 +- **우클릭**: 조건 삭제 +- **매수/매도 탭**: 각각 별도 조건 트리 구성 + +### 컴포넌트 팔레트 (우측) + +| 탭 | 내용 | +|----|------| +| **지표 팔레트** | 사용 가능한 지표 목록, 클릭 또는 드래그로 편집기에 추가 | +| **전략 목록** | 저장된 전략 목록 | +| **템플릿** | 골든크로스, RSI 과매도 등 자동 생성 템플릿 | +| **미리보기** | 구성한 조건의 수식 텍스트 미리보기 | + +### 지표 파라미터 + +전략에서 지표를 추가하면 **현재 차트 지표 설정값**이 기본값으로 적용됩니다. +- 예: 차트 CCI 기간이 15이면 전략 CCI 기간도 15로 초기화 +- **과열선/중앙선/침체선** 설정값도 반영 + +--- + +### 4.1 화면 내 설정 + +투자전략 화면 자체에는 별도의 “환경설정” 메뉴가 없습니다. 대신 아래 항목이 **전략 데이터·차트 설정과 연동**됩니다. + +| 기능 | 설정·연동 내용 | 저장 위치 | +|------|----------------|-----------| +| **지표 파라미터** | 조건 노드에 붙은 지표는 차트 지표 설정(DB)을 초기값으로 사용 | `gc_indicator_settings` | +| **전략 저장** | 매수/매도 조건 트리, 이름 | DB + `localStorage` 백업 | +| **템플릿 적용** | 골든크로스·RSI 등 프리셋 조건 일괄 생성 | — (저장 전까지 메모리) | +| **정렬** | 이름/날짜 — UI만, 데이터 변경 없음 | — | + +백테스팅·실시간 체크에 쓰이는 **포지션 모드·수수료·체크 방식** 등은 [6.3](#63-백테스팅-설정)·[6.4](#64-전략-설정)에서 설정합니다. + +--- + +## 5. 백테스팅 이력 화면 + +상단 메뉴 **백테스팅** 클릭 → 백테스팅 실행 이력 대시보드 + +### 화면 구성 + +``` +[좌: 이력 목록] | [우: 선택된 결과 대시보드] +``` + +### 이력 목록 (좌측) + +| 기능 | 설명 | +|------|------| +| **목록 표시** | 실행 일시, 전략명, 종목, 시그널 수, 승률 | +| **체크박스** | 다중 선택 | +| **삭제** | 선택 항목 삭제 | +| **전체 삭제** | 전체 이력 삭제 | +| **새로고침** | 목록 갱신 | + +### 결과 대시보드 (우측) + +이력 목록에서 항목 클릭 시 오른쪽에 상세 결과가 카드 형태로 표시됩니다. + +| 카드 | 표시 내용 | +|------|-----------| +| **기본 정보** | 전략명, 종목, 실행 시각, 기간 | +| **핵심 지표** | 총 거래 수, 승률, 총 수익률 | +| **손익 분석** | 평균 수익, 평균 손실, Profit Factor | +| **리스크 지표** | MDD, Sharpe Ratio, Calmar Ratio | +| **거래 통계** | 최대 연속 이익/손실, 평균 보유 기간 | + +--- + +## 6. 설정 화면 + +상단 메뉴 **설정** 클릭 → macOS 스타일 2열 레이아웃 (**좌: 카테고리** / **우: 설정 패널**). + +| 카테고리 | 주요 내용 | +|----------|-----------| +| 일반 설정 | 언어·시작 화면·UI 동작 | +| 차트 설정 | 캔들 색·격자·자석·가격축 | +| 백테스팅 | 시뮬레이션 자본·비용·리스크·포지션 모드 | +| 전략 설정 | 실시간 체크·알림 팝업·리스크·백테스트 기본값(UI) | +| 알림 설정 | 알림 채널·조건 (미연동) | +| 네트워크 | API·WebSocket·연결 테스트 | + +--- + +### 6.0 설정 화면 개요 및 저장 범위 + +설정값이 **어디에 저장되는지**에 따라 앱을 재시작해도 유지되는 항목이 달라집니다. + +| 저장 위치 | 적용 항목 | 재시작 후 | +|-----------|-----------|-----------| +| **DB** (`gc_app_settings`) | 테마, 백테스트 팝업/마커 표시, 매매 알림 팝업, 멀티차트 동기화, 메인 차트 스타일 | 유지 | +| **DB** (`gc_backtest_settings`) | 백테스팅 시뮬레이션 전 항목 | 유지 | +| **DB** (`gc_live_strategy_settings`, 종목별) | 실시간 체크·전략·체크 방식·포지션 모드 | 유지 | +| **DB** (지표별) | 지표 입력값·스타일 | 유지 | +| **컴포넌트 상태만** (일반·차트·알림·네트워크 탭 대부분) | 언어, 차트 색(설정 탭), 알림 토글 등 | **초기화** | +| **localStorage** | 전략 백업, deviceId, 일부 앱 상태 | 유지 | + +변경 시 **자동 저장**되는 패널: 백테스팅(저장 중/✓ 저장됨 표시), 실시간 전략(토글·선택 즉시 API 호출). + +--- + +### 6.1 일반 설정 + +> ⚠️ 이 탭의 값은 **React 컴포넌트 내부 상태**만 갱신합니다. DB·localStorage에 저장되지 않아 **페이지 새로고침 시 초기값으로 돌아갑니다.** + +#### 언어 및 지역 + +| 항목 | UI 컨트롤 | 선택값 | 상세 설명 | +|------|-----------|--------|-----------| +| **언어** | 드롭다운 | 한국어 / English / 日本語 | UI 문자열 언어(현재 다국어 리소스 연동은 제한적). | +| **날짜 형식** | 드롭다운 | `YYYY-MM-DD`, `DD/MM/YYYY`, `MM/DD/YYYY` | 차트 축·팝업 등 날짜 표기 형식. | +| **시간대** | 드롭다운 | `Asia/Seoul`, `UTC`, `America/New_York` | 차트 시간·알림 기준 시간대. | + +#### 시작 화면 + +| 항목 | 선택값 | 상세 설명 | +|------|--------|-----------| +| **시작 페이지** | 대시보드 / 실시간 차트 / 투자 전략 / 백테스팅 | 앱 최초 진입 시 열릴 화면(라우팅 연동 미완 — UI만). | + +#### UI 동작 + +| 항목 | 컨트롤 | 상세 설명 | +|------|--------|-----------| +| **애니메이션** | 토글 | 화면 전환·차트 업데이트 애니메이션 사용 여부. | +| **툴팁 표시** | 토글 | 버튼·지표에 마우스 오버 시 설명 툴팁 표시. | + +--- + +### 6.2 차트 설정 + +> ⚠️ 캔들 **색상**은 이 탭보다 [3.11 메인 차트 설정 모달](#메인-차트-설정-mainchartsettingsmodal)(DB 저장)이 실제 차트에 반영됩니다. +> **자석 모드**만 앱 전역(`magnetMode`)과 연동되어 즉시 적용됩니다. + +#### 캔들 색상 + +| 항목 | 컨트롤 | 기본값 예 | 상세 설명 | +|------|--------|-----------|-----------| +| **상승 캔들 색상** | 컬러 피커 + HEX | `#26a69a` | 양봉 몸통 색(설정 탭 — 로컬만). | +| **하락 캔들 색상** | 컬러 피커 | `#ef5350` | 음봉 몸통 색. | +| **배경 색상** | 컬러 피커 | `#131722` | 차트 plot 배경. | + +#### 차트 표시 옵션 + +| 항목 | 컨트롤 | 상세 설명 | +|------|--------|-----------| +| **격자선 표시** | 토글 | 수평·수직 격자선 ON/OFF. 툴바 **그리드** 버튼과 유사 목적. | +| **거래량 표시** | 토글 | 하단 volume pane 표시. | +| **크로스헤어 모드** | 드롭다운: 기본 / 마그넷 / 숨기기 | 십자선 표시 방식. | +| **자석모드 (Magnet Mode)** | 토글 | ON → `strong`, OFF → `off`. 십자선이 OHLC에 스냅되어 드로잉·분석 정밀도 향상. 드로잉 툴바 하단 자석(끄기/약/강/지표)과 별도 — 여기는 단순 ON/OFF. | + +#### 가격축 + +| 항목 | 선택값 | 상세 설명 | +|------|--------|-----------| +| **가격축 위치** | 우측 / 좌측 / 양쪽 | 가격 눈금자 배치. | +| **소수점 자리수** | 0 / 2 / 4 / 8자리 | Y축·범례 가격 소수 표시. BTC 등 고가·저가 코인에 8자리 유용. | + +--- + +### 6.3 백테스팅 설정 + +**DB (`gc_backtest_settings`)에 자동 저장**됩니다. 차트 툴바에서 백테스팅 **실행** 시 이 설정이 백엔드 `BacktestingService`에 전달됩니다. 차트 ⚙ 모달과 **동일 데이터**입니다. + +**시스템 기본값** (초기화 시): + +| 항목 | 기본값 | +|------|--------| +| 초기 자본 | 10,000,000원 | +| 수수료 | LINEAR 0.15% (0.0015) | +| 슬리피지 | 0.05% (0.0005) | +| 진입/청산 가격 | 종가(CLOSE) | +| 포지션 방향 | LONG | +| 거래 규모 | 자본 100% | +| 포지션 모드 | LONG_ONLY | + +--- + +#### 표시 설정 + +| 항목 | 저장 | 상세 설명 | +|------|------|-----------| +| **결과 팝업 자동 표시** | `AppSettings.btAutoPopup` (DB) | ON: 실행 완료 후 결과 대시보드 자동 오픈. OFF: 툴바 **결과(📊)** 수동 클릭. | +| **매수/매도 금액 표시** | `AppSettings.btShowPrice` (DB) | ON: 마커 `"매수 : 1,234,000"` 형식. OFF: `"매수"` / `"매도"`만. | + +--- + +#### 자본 · 거래 규모 + +| 항목 | 입력 범위 | 상세 설명 | +|------|-----------|-----------| +| **초기 자본 (원)** | 최소 100,000, step 1,000,000 | 시뮬레이션 시작 자금. 총 수익률·최종 자산 계산 기준. | +| **거래 규모 유형** | `CAPITAL_PCT` / `FIXED_AMOUNT` | 비율: 복리 반영. 고정: 매 거래 동일 원화. | +| **사용 자본 비율 (%)** | 1~100 (비율 모드) | 예: 50 → 가용 자본의 절반만 매수에 사용. | +| **거래당 금액 (원)** | (고정 모드) | 예: 1,000,000 → 매 신호마다 100만원 규모 체결 가정. | + +--- + +#### 비용 모델 + +| 항목 | 상세 설명 | +|------|-----------| +| **수수료 유형 `LINEAR`** | 체결금액 × 수수료율. 매수·매도 각 1회 차감. | +| **수수료 유형 `ZERO`** | 수수료 없음. 순수 전략 성과만 비교할 때. | +| **수수료율 (%)** | UI는 % 입력, 내부는 소수 저장. 업비트 현물 약 **0.05%** 권장. | +| **슬리피지 (%)** | 매수는 더 비싸게, 매도는 더 싸게 체결되는 가정. 변동성·유동성 반영용. | + +--- + +#### 거래 가격 기준 + +| 항목 | 옵션 | 상세 설명 | +|------|------|-----------| +| **진입 가격 기준** | CLOSE / NEXT_OPEN | CLOSE: 신호 봉 종가 즉시 진입(단순, look-ahead 논란 가능). NEXT_OPEN: 다음 봉 시가 — 실매매에 가깝게. | +| **청산 가격 기준** | CLOSE / NEXT_OPEN | 매도 신호 시 청산 가격 동일 논리. | +| **포지션 방향** | LONG / SHORT / BOTH | LONG: 매수→매도. SHORT: 공매도 시뮬. BOTH: 신호에 따라 양방향. | + +--- + +#### 리스크 관리 (Ta4j 규칙) + +전략 매도 조건보다 **먼저** 손절·익절·트레일링이 발동하면 해당 규칙으로 청산됩니다. + +| 항목 | 활성화 시 | 상세 설명 | +|------|-----------|-----------| +| **손절 활성화 + 손절 비율 (%)** | StopLossRule | 진입가 대비 N% 하락 시 강제 청산. 예: 2% → 100만원 매수 후 98만원 이하. | +| **익절 활성화 + 익절 비율 (%)** | StopGainRule | 진입가 대비 N% 상승 시 이익 실현. | +| **트레일링 스탑 + 비율 (%)** | TrailingStop | 보유 중 **최고가** 대비 N% 하락 시 청산. 추세 추종형 손절. | + +--- + +#### 재진입 · 분할 청산 + +| 항목 | 상세 설명 | +|------|-----------| +| **재진입 대기 봉 수** | 매도 후 N봉 동안 신규 매수 금지. 0 = 즉시 재진입 허용. | +| **최대 동시 포지션** | 동시 보유 포지션 상한(현재 엔진은 단일 포지션 중심). | +| **분할 청산 활성화** | 첫 매도 신호에 포지션 일부만 청산. | +| **1차 청산 비율 (%)** | 분할 청산 시 첫 번에 팔 비율(기본 50%). | + +--- + +#### 포지션 종속성 모드 (백테스팅) + +가로 2열 카드 라디오로 선택합니다. + +| 모드 | 매수 시그널 | 매도 시그널 | 권장 용도 | +|------|-------------|-------------|-----------| +| **보유 자산 기준 (Long-Only)** | 진입 규칙 + 포지션 없음 | 청산 규칙 + **포지션 있음** | 실제 롱 포지션 추적, 수익률·승률 해석 | +| **순수 지표 기준 (Signal-Only)** | 진입 규칙 (포지션 무관) | 청산 규칙 (포지션 무관) | 지표 신호 전부 차트에 표시, 알림·분석용 | + +Signal-Only 선택 시 경고 문구: *「매도 시그널이 이전 매수 없이도 발생합니다」* + +> 백테스팅·실시간 체크·`StrategySignalDeterminer`가 동일 개념을 공유합니다. 자세한 동작은 `백테스팅 시그널 동작방식.md` 참고. + +--- + +### 6.4 전략 설정 + +실시간 체크·알림 팝업은 **DB 저장**. 리스크 관리·백테스팅 기본값 카드는 **UI만**(백테스팅 탭 DB 설정과 아직 미연동). + +--- + +#### 실시간 전략 체크 (`gc_live_strategy_settings`, **종목별**) + +| 항목 | 컨트롤 | 상세 설명 | +|------|--------|-----------| +| **실시간 체크** | 토글 | ON: 백엔드가 선택 전략을 지속 평가, STOMP로 BUY/SELL 푸시, 차트 마커 표시. OFF: 평가 중단. 변경 시 **기존 마커 삭제**. | +| **모니터링 종목** | 읽기 전용 텍스트 | 현재 실시간 차트 활성 종목(예: `KRW-BTC`). 종목 바꾸면 해당 종목 설정 레코드 로드. | +| **전략 선택** | 드롭다운 | 투자전략 빌더에 저장된 전략. 미선택 시 모니터링 대기 상태. | +| **체크 방식** | 가로 2열 라디오 | 아래 표 참고. | +| **모니터링 상태 바** | (체크 ON 시) | 선택 전략명 + 펄스 표시. | + +**체크 방식** + +| 값 | 라벨 | 동작 | +|----|------|------| +| `CANDLE_CLOSE` | 봉 마감 직후 | **완성된** 직전 봉 기준, 봉당 최대 1회 판정. 확정 신호에 적합. | +| `REALTIME_TICK` | 실시간 틱 (3초) | **진행 중인** 봉 포함, 약 3초마다 재판정. 같은 봉·같은 방향 중복 마커 방지. | + +**포지션 종속성 모드** (실시간) + +| 값 | 동작 요약 | +|----|-----------| +| `LONG_ONLY` | 매수 이력(포지션) 있을 때만 매도 마커 | +| `SIGNAL_ONLY` | 지표 청산 규칙만 충족해도 매도 마커 (매수 없이도 가능) | + +> ⚡ **LiveStrategyPanel**과 설정·DB가 동일합니다. [3.11절](#실시간-전략-패널-livestrategypanel) 참고. + +--- + +#### 매매 알림 팝업 + +| 항목 | 저장 | 상세 설명 | +|------|------|-----------| +| **알림 팝업** | `AppSettings.tradeAlertPopup` (DB) | ON: 실시간 체크 BUY/SELL 시 `TradeAlertModal` 자동 표시. OFF: 마커만 표시. | + +팝업 내 **주문 탭**은 UI 프로토타입이며 실제 거래소 API 미연동. + +--- + +#### 리스크 관리 (2열 카드) + +> ⚠️ 입력값은 **화면 상태만** 유지. 백테스팅 DB·실시간 엔진에 아직 반영되지 않습니다. + +| 항목 | 설명 | +|------|------| +| **기본 리스크 비율 (%)** | 진입 시 자본 대비 리스크 비율(계획 값). | +| **최대 동시 포지션** | 동시 보유 포지션 수 상한. | +| **트레일링 스탑** | 수익 구간에서 손절선 상향 조정 여부. | +| **재진입 허용** | 청산 후 동일 조건 재진입 허용. | + +--- + +#### 백테스팅 기본값 (2열 카드) + +> ⚠️ **백테스팅** 카테고리(6.3)의 DB 설정과 **별개** UI입니다. 현재 저장 API 없음. + +| 항목 | 설명 | +|------|------| +| **수수료율 (%)** | 표시용 기본 수수료. | +| **슬리피지 (%)** | 표시용 기본 슬리피지. | +| **성과 지표 기준** | 샤프 비율 / 총 손익률 / 승률 / 최대 낙폭 — 전략 비교 기준(미연동). | + +--- + +### 6.5 알림 설정 + +> ⚠️ **UI만 구현**. 토글·이메일·쿨다운 변경은 **저장되지 않으며**, 실제 소리·푸시·이메일 발송 로직 없음. +> 차트 **가격 알림(🔔)** 은 별도 패널로 동작(목록·수평선만, 발송 없음). + +#### 알림 방식 + +| 항목 | 상세 설명 | +|------|-----------| +| **사운드 알림** | 조건 충족 시 효과음(미구현). | +| **브라우저 알림** | `Notification API` OS 푸시(미구현). | +| **이메일 알림** | ON 시 **이메일 주소** 입력 행 표시. SMTP 발송(미구현). | + +#### 알림 조건 + +| 항목 | 상세 설명 | +|------|-----------| +| **가격 알림** | 목표가 도달 알림 허용 플래그(미연동). | +| **매매 신호 알림** | 전략 BUY/SELL 시 알림(미연동 — 팝업은 6.4 **알림 팝업** 사용). | +| **알림 재발송 대기 (초)** | 동일 조건 반복 방지 쿨다운. 0~3600. | + +--- + +### 6.6 네트워크 설정 + +> ⚠️ 입력값은 **컴포넌트 상태**만. 실제 `backendApi` base URL은 빌드·환경 변수에 따르며, 이 화면 변경이 API 클라이언트에 자동 반영되지 않을 수 있습니다. **연결 테스트**만 입력 URL로 `fetch`합니다. + +#### API 서버 + +| 항목 | 기본값 예 | 상세 설명 | +|------|-----------|-----------| +| **서버 주소** | `http://localhost:8080` | REST API 호스트(포트 포함). | +| **연결 상태** | ● 연결됨/끊김 배지 | UI 표시용(자동 갱신 제한적). | +| **연결 테스트** | 버튼 | `{서버주소}/actuator/health` GET → 성공/실패 alert. | + +#### WebSocket + +| 항목 | 기본값 예 | 상세 설명 | +|------|-----------|-----------| +| **WebSocket 주소** | `ws://localhost:8080/ws` | 실시간 시세·전략 시그널 STOMP 엔드포인트. | + +#### 연결 설정 + +| 항목 | 상세 설명 | +|------|-----------| +| **연결 타임아웃 (ms)** | 응답 없을 때 요청 중단 시간. 기본 10000. | +| **재시도 횟수** | 실패 시 재시도 상한. 0~10. | +| **프록시 URL** | 비우면 직접 연결. 예: `http://proxy:8888` | + +--- + +## 7. 드로잉 툴 상세 사용법 + +> 모든 드로잉 툴은 **트레이딩뷰 모드**에서만 동작합니다. + +### 라인 그룹 + +| 툴 | 클릭 횟수 | 설명 | +|----|-----------|------| +| **추세선** | 2클릭 | 두 점을 연결하는 선, 양쪽 무한 연장 없음 | +| **확장선 (Ray)** | 2클릭 | 한 방향으로 무한 연장 | +| **화살표** | 2클릭 | 끝점에 화살촉 표시 | +| **수평선** | 1클릭 | 클릭한 가격에 수평선 | +| **수직선** | 1클릭 | 클릭한 시간에 수직선 | +| **채널 (수평/오름/내림)** | 3클릭 | 평행한 두 선으로 채널 구성 | +| **선형 회귀** | 2클릭 | 지정 구간의 최소자승 추세선 | + +### 피보나치 그룹 + +| 툴 | 클릭 횟수 | 설명 | +|----|-----------|------| +| **피보나치 되돌림** | 2클릭 | 0%~100% 사이 피보나치 레벨 (23.6, 38.2, 50, 61.8, 78.6%) | +| **피보나치 확장** | 3클릭 | 추세 연장 목표가 레벨 | +| **피보나치 채널** | 3클릭 | 대각선 기반 피보나치 채널 | +| **피보나치 타임존** | 2클릭 | 시간축 기반 피보나치 구간 | +| **피치포크** | 3클릭 | 앤드류 피치포크 메디안 라인 | +| **갠 박스** | 2클릭 | 갠 이론 기반 사각형 격자 | +| **갠 팬** | 2클릭 | 갠 각도 팬 라인 (1×1, 1×2, 2×1 등) | + +### 패턴 그룹 + +| 툴 | 클릭 횟수 | 설명 | +|----|-----------|------| +| **XABCD 패턴** | 5클릭 | 하모닉 XABCD 패턴 | +| **헤드앤숄더** | 7클릭 | 머리어깨 패턴 | +| **엘리엇 파동 (ABC)** | 4클릭 | 엘리엇 3파 조정 | +| **엘리엇 파동 (WXYXZ)** | 6클릭 | 엘리엇 복합 조정 | + +### 프로젝션 그룹 + +| 툴 | 설명 | +|----|------| +| **롱 포지션** | 진입가·목표가·손절가로 수익/손실 시각화 (초록 영역/빨강 영역) | +| **숏 포지션** | 숏 방향 포지션 시각화 | +| **앵커드 VWAP** | 지정 시점부터의 거래량 가중 평균 가격 | +| **고정 볼륨 프로파일** | 지정 구간의 가격대별 거래량 분포 | +| **지그재그** | 고점·저점을 연결하는 지그재그 패턴 | + +### 도형 그룹 + +| 툴 | 클릭 횟수 | 설명 | +|----|-----------|------| +| **사각형** | 2클릭 | 대각선 두 점으로 사각형 | +| **회전 사각형** | 3클릭 | 회전 각도 조절 가능 사각형 | +| **원** | 2클릭 | 중심점 + 반지름 원 | +| **타원** | 2클릭 | 타원 | +| **삼각형** | 3클릭 | 삼각형 | +| **평행사변형** | 3클릭 | 평행사변형 | +| **다각형** | N클릭 + 더블클릭 | 자유 다각형 | +| **호** | 3클릭 | 원호 | + +### 텍스트 & 노트 그룹 + +| 툴 | 설명 | +|----|------| +| **텍스트** | 차트 위 자유 텍스트 | +| **노트(핀)** | 핀 아이콘 + 메모 텍스트 | +| **가격 노트** | 가격축에 연결된 노트 | +| **콜아웃** | 말풍선 형태의 주석 | +| **가격 라벨** | 특정 가격을 라벨로 표시 | + +### 측정 그룹 + +| 툴 | 설명 | +|----|------| +| **날짜·가격 범위** | 드래그 구간의 가격 변동량·기간·비율 표시 | +| **데이터 창** | 클릭 지점의 OHLCV 데이터 팝업 표시 | + +### 확대/축소 그룹 + +| 툴 | 설명 | +|----|------| +| **돋보기** | 차트 일부를 확대한 오버레이 팝업 표시 | + +> 돋보기 팝업은 **팝업 어디서나 드래그**하여 이동할 수 있습니다. + +--- + +## 8. 키보드 단축키 모음 + +| 단축키 | 기능 | +|--------|------| +| **F** | 차트 전체 맞춤 (Fit) | +| **L** | 로그 스케일 전환 | +| **Escape** | 현재 드로잉 취소 / 선택 해제 | +| **Ctrl + Z** | 드로잉 되돌리기 (Undo) | +| **Ctrl + Y** | 드로잉 다시 실행 (Redo) | +| **Delete** | 선택된 드로잉 삭제 | +| **Alt + S** | 차트 스크린샷 저장 | +| **Enter** | 다중 점 드로잉 확정 | + +--- + +## 9. 미구현 기능 안내 + +현재 UI는 구성되어 있지만 기능이 완전히 구현되지 않은 항목입니다. + +| 기능 | 상태 | 비고 | +|------|------|------| +| **대시보드 메뉴** | UI 없음 | 상단 메뉴에만 존재 | +| **로그인/회원가입** | 버튼만 | 실제 인증 미연동 | +| **우측 패널 알림/매수/매도** | 플레이스홀더 | 실제 거래소 API 미연동 | +| **TradeAlertModal 주문** | UI 프로토타입 | 실제 주문 API 미연동 | +| **알림 설정 (설정 화면)** | UI만 | 발송·저장 미구현 | +| **일반 설정** | 로컬 상태만 | DB 저장 미구현 | +| **차트 설정 탭(색상 등)** | 로컬 상태만 | 실제 캔들 색은 메인 차트 설정 모달·DB | +| **전략 설정 — 리스크/백테스트 기본값 카드** | UI만 | 백테스팅 DB와 미연동 | +| **네트워크 설정** | UI만 | API 클라이언트 자동 반영 제한적 | +| **멀티차트 백테스팅** | 비활성 | 싱글 레이아웃에서만 동작 | + +--- + +> 문서 최종 업데이트: 2026-05-21 +> 프로젝트: GoldenChart (`/Users/macbook/dev/goldenChart`) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..7071faa --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +target/ +*.log +.mvn/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8fdac02 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,42 @@ +# ── 1단계: 빌드 ────────────────────────────────────────────────────────────── +# ta4j-master 는 Java 25 기준으로 빌드되어 있으므로 Java 25 이미지 사용 +FROM maven:3.9-eclipse-temurin-21 AS builder +WORKDIR /build + +# ── ta4j-master 전체 복사 (라이선스 헤더 파일 등 상대경로 유지) ───────────── +COPY ta4j-master ./ta4j-master + +# 공통 skip 옵션 + 컴파일러 버전을 21로 강제 오버라이드 +ARG SKIP_OPTS="-Denforcer.skip=true -Dlicense.skip=true -Dcheckstyle.skip=true -Dspotbugs.skip=true -DskipTests -Dmaven.compiler.release=21" + +# 부모 POM 설치 (-N: 하위 모듈 빌드 생략) +RUN mvn -f ta4j-master/pom.xml -N install $SKIP_OPTS -q + +# ta4j-core 만 빌드하여 로컬 리포지터리에 설치 +RUN mvn -f ta4j-master/ta4j-core/pom.xml install $SKIP_OPTS -q + +# ── Backend 빌드 ───────────────────────────────────────────────────────────── +# 의존성 레이어 캐시 (pom.xml 변경 시만 재다운로드) +COPY backend/pom.xml ./backend/pom.xml +RUN mvn -f backend/pom.xml dependency:go-offline -q 2>/dev/null || true + +# 소스 빌드 +COPY backend/src ./backend/src +RUN mvn -f backend/pom.xml package -DskipTests -q + +# ── 2단계: 실행 ────────────────────────────────────────────────────────────── +FROM eclipse-temurin:21-jre-alpine AS production +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring +USER spring + +COPY --from=builder /build/backend/target/goldenchart-backend-*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", "app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..c144379 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,168 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.goldenchart + goldenchart-backend + 1.0.0 + jar + GoldenChart Backend + GoldenChart Spring Boot Backend with Ta4j indicators + + + 21 + 0.22.7-SNAPSHOT + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.java-websocket + Java-WebSocket + 1.5.7 + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.ta4j + ta4j-core + ${ta4j.version} + + + + + org.slf4j + slf4j-api + + + org.apache.commons + commons-math3 + 3.6.1 + + + + + org.projectlombok + lombok + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + com.google.guava + guava + 33.3.1-jre + + + + + com.auth0 + java-jwt + 4.4.0 + + + + + com.google.firebase + firebase-admin + 9.2.0 + + + + + org.flywaydb + flyway-mysql + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/backend/src/main/java/com/goldenchart/GoldenChartApplication.java b/backend/src/main/java/com/goldenchart/GoldenChartApplication.java new file mode 100644 index 0000000..25265b9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/GoldenChartApplication.java @@ -0,0 +1,17 @@ +package com.goldenchart; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableAsync +@EnableScheduling +public class GoldenChartApplication { + public static void main(String[] args) { + SpringApplication.run(GoldenChartApplication.class, args); + } +} diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java new file mode 100644 index 0000000..a747814 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -0,0 +1,14 @@ +package com.goldenchart.auth; + +import java.util.List; + +public final class MenuIds { + + private MenuIds() {} + + public static final List ALL = List.of( + "dashboard", "chart", "paper", "strategy", "backtest", "notifications", "settings", + "settings_general", "settings_chart", "settings_indicators", "settings_backtest", + "settings_strategy", "settings_paper", "settings_alert", "settings_network", "settings_admin" + ); +} diff --git a/backend/src/main/java/com/goldenchart/auth/UserRole.java b/backend/src/main/java/com/goldenchart/auth/UserRole.java new file mode 100644 index 0000000..5f8256a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/auth/UserRole.java @@ -0,0 +1,16 @@ +package com.goldenchart.auth; + +public enum UserRole { + ADMIN, + USER, + GUEST; + + public static UserRole fromString(String value) { + if (value == null || value.isBlank()) return USER; + try { + return UserRole.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return USER; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/config/AdminUserInitializer.java b/backend/src/main/java/com/goldenchart/config/AdminUserInitializer.java new file mode 100644 index 0000000..0c00441 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/AdminUserInitializer.java @@ -0,0 +1,22 @@ +package com.goldenchart.config; + +import com.goldenchart.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AdminUserInitializer implements ApplicationRunner { + + private final AuthService authService; + + @Override + public void run(ApplicationArguments args) { + authService.ensureDefaultAdmin(); + log.info("[Auth] default admin user ensured (username=admin)"); + } +} diff --git a/backend/src/main/java/com/goldenchart/config/CorsConfig.java b/backend/src/main/java/com/goldenchart/config/CorsConfig.java new file mode 100644 index 0000000..a010e00 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/CorsConfig.java @@ -0,0 +1,44 @@ +package com.goldenchart.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "goldenchart.cors") +public class CorsConfig { + + private List allowedOrigins = List.of("http://localhost:5173", "http://localhost:3000"); + + public List getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + // setAllowedOriginPatterns 사용: wildcard(*) + allowCredentials 동시 지원 + if (allowedOrigins.contains("*")) { + config.setAllowedOriginPatterns(List.of("*")); + } else { + config.setAllowedOriginPatterns(allowedOrigins); + } + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/com/goldenchart/config/FirebaseConfig.java b/backend/src/main/java/com/goldenchart/config/FirebaseConfig.java new file mode 100644 index 0000000..9d5a3f1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/FirebaseConfig.java @@ -0,0 +1,58 @@ +package com.goldenchart.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.FileInputStream; +import java.io.InputStream; + +@Configuration +@Slf4j +public class FirebaseConfig { + + @Value("${firebase.service-account-file:firebase-service-account.json}") + private String serviceAccountFile; + + @Value("${firebase.enabled:false}") + private boolean firebaseEnabled; + + @PostConstruct + public void initialize() { + if (!firebaseEnabled) { + log.info("[Firebase] disabled (firebase.enabled=false)"); + return; + } + if (!FirebaseApp.getApps().isEmpty()) return; + + try (InputStream in = openServiceAccount()) { + if (in == null) { + log.warn("[Firebase] service account not found — FCM disabled"); + return; + } + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(in)) + .build(); + FirebaseApp.initializeApp(options); + log.info("[Firebase] Admin SDK initialized — FCM enabled"); + } catch (Exception e) { + log.warn("[Firebase] init failed: {}", e.getMessage()); + } + } + + private InputStream openServiceAccount() { + try { + var res = new ClassPathResource(serviceAccountFile); + if (res.exists()) return res.getInputStream(); + } catch (Exception ignored) { } + try { + return new FileInputStream(serviceAccountFile); + } catch (Exception ignored) { } + return null; + } +} diff --git a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java new file mode 100644 index 0000000..be05d50 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java @@ -0,0 +1,52 @@ +package com.goldenchart.config; + +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcWatchlist; +import com.goldenchart.repository.GcWatchlistRepository; +import com.goldenchart.service.AppSettingsService; +import com.goldenchart.service.LiveStrategyEvaluator; +import com.goldenchart.websocket.DynamicSubscriptionManager; +import com.goldenchart.websocket.UpbitWebSocketClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * 서버 기동 시 isLiveCheck=true 인 모든 종목을 업비트 WS·Ta4jStorage 에 고정 구독한다. + * (재시작 후에도 실시간 전략 평가가 끊기지 않도록) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class LiveStrategyStartupRunner implements ApplicationRunner { + + private final AppSettingsService appSettingsService; + private final GcWatchlistRepository watchlistRepo; + private final DynamicSubscriptionManager subscriptionManager; + private final LiveStrategyEvaluator liveStrategyEvaluator; + private final UpbitWebSocketClient upbitWebSocketClient; + + @Override + public void run(ApplicationArguments args) { + // 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독 + int pinned = 0; + for (GcWatchlist w : watchlistRepo.findAll()) { + String deviceId = w.getDeviceId(); + if (deviceId == null) continue; + GcAppSettings app = appSettingsService.getEntity(null, deviceId); + if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { + continue; + } + subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m"); + liveStrategyEvaluator.invalidateCache(w.getSymbol()); + pinned++; + } + if (pinned > 0) { + log.info("[LiveStrategyStartup] 관심종목 기준 마켓 {}개 고정 구독 완료", pinned); + } + // WS 연결 직후 addMarket 이 구독 패킷을 놓치는 경우 대비 + upbitWebSocketClient.resubscribeAll(); + } +} diff --git a/backend/src/main/java/com/goldenchart/config/SecurityConfig.java b/backend/src/main/java/com/goldenchart/config/SecurityConfig.java new file mode 100644 index 0000000..8756c06 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/SecurityConfig.java @@ -0,0 +1,34 @@ +package com.goldenchart.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, CorsConfig corsConfig) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // 개발 단계: 전체 허용 (운영 시 JWT 필터 추가) + .requestMatchers("/**").permitAll() + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/goldenchart/config/UpbitKeyEncryptionMigrator.java b/backend/src/main/java/com/goldenchart/config/UpbitKeyEncryptionMigrator.java new file mode 100644 index 0000000..ece8e21 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/UpbitKeyEncryptionMigrator.java @@ -0,0 +1,49 @@ +package com.goldenchart.config; + +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.repository.GcAppSettingsRepository; +import com.goldenchart.security.SecretCryptoService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 기존 평문 업비트 API 키를 AES 암호문으로 일괄 변환 (1회성·멱등). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class UpbitKeyEncryptionMigrator implements ApplicationRunner { + + private final GcAppSettingsRepository repo; + private final SecretCryptoService secretCrypto; + + @Override + @Transactional + public void run(ApplicationArguments args) { + int migrated = 0; + for (GcAppSettings s : repo.findAll()) { + boolean changed = false; + String access = s.getUpbitAccessKey(); + if (access != null && !access.isBlank() && !secretCrypto.isEncrypted(access)) { + s.setUpbitAccessKey(secretCrypto.encrypt(access)); + changed = true; + } + String secret = s.getUpbitSecretKey(); + if (secret != null && !secret.isBlank() && !secretCrypto.isEncrypted(secret)) { + s.setUpbitSecretKey(secretCrypto.encrypt(secret)); + changed = true; + } + if (changed) { + repo.save(s); + migrated++; + } + } + if (migrated > 0) { + log.info("[UpbitKeyEncryptionMigrator] {} settings row(s) API keys encrypted", migrated); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java b/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java new file mode 100644 index 0000000..8dfec04 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java @@ -0,0 +1,39 @@ +package com.goldenchart.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * STOMP WebSocket 브로커 설정. + * + * 클라이언트 연결 엔드포인트: + * ws(s)://host/ws/trading (SockJS fallback 지원) + * + * 구독 채널 (명세서 5.2): + * /sub/charts/{market}/{type} — 실시간 캔들 배포 + * + * 발행 채널: + * /pub/... — 클라이언트 → 서버 (향후 확장용) + */ +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 인메모리 심플 브로커: /sub 접두사 구독 채널에 메시지 라우팅 + registry.enableSimpleBroker("/sub"); + // 클라이언트 발행 경로 접두사 (서버측 @MessageMapping 핸들러로 라우팅) + registry.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws/trading") + .setAllowedOriginPatterns("*") + .withSockJS(); // SockJS fallback (브라우저 환경 대비) + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/AdminController.java b/backend/src/main/java/com/goldenchart/controller/AdminController.java new file mode 100644 index 0000000..0293d04 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/AdminController.java @@ -0,0 +1,85 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.*; +import com.goldenchart.service.AdminService; +import com.goldenchart.service.RolePermissionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + private final RolePermissionService rolePermissionService; + + private Long requireUserId(@RequestHeader(value = "X-User-Id", required = false) Long userId) { + if (userId == null) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + rolePermissionService.requireAdmin(userId); + return userId; + } + + @PostMapping("/verify-password") + public ResponseEntity> verifyPassword( + @RequestHeader("X-User-Id") Long userId, + @RequestBody AdminVerifyRequest body) { + adminService.verifyAdminPassword(userId, body != null ? body.getPassword() : null); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @GetMapping("/users") + public List listUsers(@RequestHeader("X-User-Id") Long userId) { + requireUserId(userId); + return adminService.listUsers(); + } + + @PostMapping("/users") + public UserDto createUser( + @RequestHeader("X-User-Id") Long userId, + @RequestBody CreateUserRequest body) { + requireUserId(userId); + return adminService.createUser(body); + } + + @PutMapping("/users/{id}") + public UserDto updateUser( + @RequestHeader("X-User-Id") Long userId, + @PathVariable Long id, + @RequestBody UpdateUserRequest body) { + requireUserId(userId); + return adminService.updateUser(id, body, userId); + } + + @DeleteMapping("/users/{id}") + public ResponseEntity deleteUser( + @RequestHeader("X-User-Id") Long userId, + @PathVariable Long id) { + requireUserId(userId); + adminService.deleteUser(id, userId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/role-permissions") + public Map> listRolePermissions( + @RequestHeader("X-User-Id") Long userId) { + requireUserId(userId); + return rolePermissionService.listAllRolePermissions(); + } + + @PutMapping("/role-permissions") + public ResponseEntity updateRolePermissions( + @RequestHeader("X-User-Id") Long userId, + @RequestBody RolePermissionsUpdateRequest body) { + requireUserId(userId); + rolePermissionService.updateRolePermissions(body); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/AppSettingsController.java b/backend/src/main/java/com/goldenchart/controller/AppSettingsController.java new file mode 100644 index 0000000..f9d1a87 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/AppSettingsController.java @@ -0,0 +1,84 @@ +package com.goldenchart.controller; + +import com.goldenchart.service.AppSettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 앱 전역 차트 기본 설정 REST API. + * + *

프론트엔드에 하드코딩된 기본값들(기본 심볼·타임프레임·테마·캔들 색상 등)을 + * 장치/사용자별로 DB 에서 관리한다.

+ * + *
+ * GET  /app-settings   — 현재 기본 설정 로드 (앱 시작 시 호출)
+ * PUT  /app-settings   — 기본 설정 저장
+ * 
+ * + * 반환 구조: + *
+ * {
+ *   "defaultSymbol":    "KRW-BTC",
+ *   "defaultTimeframe": "1D",
+ *   "defaultChartType": "candlestick",
+ *   "defaultTheme":     "dark",
+ *   "defaultLogScale":  false,
+ *   "defaultLayoutId":  "1",
+ *   "mainChartStyle":   { "upColor":"#ff6b6b", "downColor":"#4dabf7", ... },
+ *   "syncOptions":      { "symbol":false, "interval":false, "crosshair":true, ... }
+ * }
+ * 
+ */ +@RestController +@RequestMapping("/app-settings") +@RequiredArgsConstructor +public class AppSettingsController { + + private final AppSettingsService service; + + // ── GET ─────────────────────────────────────────────────────────────────── + + /** + * 앱 시작 시 기본 설정 로드. + * DB 에 데이터가 없으면 엔티티 기본값(hardcoded fallback)을 반환한다. + */ + @GetMapping + public ResponseEntity> get( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + return ResponseEntity.ok(service.get(parseUserId(userIdHeader), deviceId)); + } + + // ── PUT ─────────────────────────────────────────────────────────────────── + + /** + * 기본 설정 저장. + * 변경된 필드만 보내도 되며, 없는 키는 기존 값 유지. + * + *

프론트엔드에서 호출해야 하는 시점: + *

    + *
  • 테마 변경 시 (defaultTheme)
  • + *
  • 캔들 색상 변경 시 (mainChartStyle)
  • + *
  • 레이아웃 변경 시 (defaultLayoutId)
  • + *
  • 기본 심볼/타임프레임 변경 시
  • + *
  • 동기화 옵션 변경 시 (syncOptions)
  • + *
+ */ + @PutMapping + public ResponseEntity> save( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId, + @RequestBody Map data) { + return ResponseEntity.ok(service.save(parseUserId(userIdHeader), deviceId, data)); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/AuthController.java b/backend/src/main/java/com/goldenchart/controller/AuthController.java new file mode 100644 index 0000000..ef90124 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/AuthController.java @@ -0,0 +1,45 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.LoginRequest; +import com.goldenchart.dto.LoginResponse; +import com.goldenchart.dto.MenuPermissionsResponse; +import com.goldenchart.service.AuthService; +import com.goldenchart.service.RolePermissionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final RolePermissionService rolePermissionService; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest body) { + return ResponseEntity.ok(authService.login(body.getUsername(), body.getPassword())); + } + + @GetMapping("/me") + public ResponseEntity me( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader) { + Long userId = parseUserId(userIdHeader); + return authService.me(userId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + /** 로그인 사용자 역할·메뉴 권한 (미로그인 시 GUEST) */ + @GetMapping("/menu-permissions") + public MenuPermissionsResponse menuPermissions( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader) { + return rolePermissionService.resolveForUserId(parseUserId(userIdHeader)); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/BacktestResultController.java b/backend/src/main/java/com/goldenchart/controller/BacktestResultController.java new file mode 100644 index 0000000..657dd13 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/BacktestResultController.java @@ -0,0 +1,46 @@ +package com.goldenchart.controller; + +import com.goldenchart.entity.GcBacktestResult; +import com.goldenchart.repository.GcBacktestResultRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 백테스팅 실행 이력 조회 API. + * + *
+ * GET  /backtest-results?deviceId=xxx     → 이력 목록 (최신순)
+ * GET  /backtest-results/{id}             → 단건 상세 조회
+ * DELETE /backtest-results/{id}           → 삭제
+ * 
+ */ +@RestController +@RequestMapping("/backtest-results") +@RequiredArgsConstructor +public class BacktestResultController { + + private final GcBacktestResultRepository resultRepository; + + @GetMapping + public ResponseEntity> list( + @RequestParam(required = false, defaultValue = "default") String deviceId) { + return ResponseEntity.ok(resultRepository.findByDeviceIdOrderByCreatedAtDesc(deviceId)); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable Long id) { + return resultRepository.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!resultRepository.existsById(id)) return ResponseEntity.notFound().build(); + resultRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java b/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java new file mode 100644 index 0000000..7d872d9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java @@ -0,0 +1,29 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.BacktestSettingsDto; +import com.goldenchart.service.BacktestSettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/backtest-settings") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class BacktestSettingsController { + + private final BacktestSettingsService service; + + @GetMapping + public ResponseEntity get( + @RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId) { + return ResponseEntity.ok(service.get(deviceId)); + } + + @PostMapping + public ResponseEntity save( + @RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId, + @RequestBody BacktestSettingsDto dto) { + return ResponseEntity.ok(service.save(deviceId, dto)); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/BacktestingController.java b/backend/src/main/java/com/goldenchart/controller/BacktestingController.java new file mode 100644 index 0000000..e70783c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/BacktestingController.java @@ -0,0 +1,81 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.BacktestRequest; +import com.goldenchart.dto.BacktestResponse; +import com.goldenchart.service.BacktestingService; +import com.goldenchart.service.IndicatorSettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 백테스팅 실행 REST API. + * + *

POST /backtesting/run 에 BacktestRequest 를 전송하면 + * Ta4j 로 백테스팅을 수행하여 매수/매도 시그널과 통계를 반환합니다. + */ +@RestController +@RequestMapping("/backtesting") +@RequiredArgsConstructor +public class BacktestingController { + + private final BacktestingService backtestingService; + private final IndicatorSettingsService indicatorSettingsService; + + /** + * 백테스팅 실행. + * + *

요청 예시: + *

+     * POST /backtesting/run
+     * {
+     *   "strategyId": 1,          // DB 전략 ID (선택)
+     *   "buyCondition": {...},     // DSL 직접 전달 시 (strategyId 없을 때 사용)
+     *   "sellCondition": {...},
+     *   "bars": [{time, open, high, low, close, volume}, ...],
+     *   "timeframe": "1D"
+     * }
+     * 
+ */ + @PostMapping("/run") + public ResponseEntity run( + @RequestBody BacktestRequest req, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + if (req.getBars() == null || req.getBars().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + Long userId = parseUserId(userIdHeader); + + // DB 저장 파라미터를 기반으로, 요청 params 로 덮어쓰기 + // (요청에 params 없으면 DB 값만 사용 → 사용자가 변경한 파라미터 항상 반영) + if (userId != null || deviceId != null) { + java.util.Map> dbParams = + indicatorSettingsService.getAll(userId, deviceId); + if (!dbParams.isEmpty()) { + java.util.Map> merged = + new java.util.HashMap<>(dbParams); + if (req.getIndicatorParams() != null) { + req.getIndicatorParams().forEach((type, params) -> + merged.merge(type, params, (dbP, reqP) -> { + java.util.Map m = new java.util.HashMap<>(dbP); + m.putAll(reqP); + return m; + }) + ); + } + req.setIndicatorParams(merged); + } + } + + BacktestResponse response = backtestingService.run(req); + return ResponseEntity.ok(response); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/CandleController.java b/backend/src/main/java/com/goldenchart/controller/CandleController.java new file mode 100644 index 0000000..a7f78f4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/CandleController.java @@ -0,0 +1,64 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.service.HistoricalDataService; +import com.goldenchart.websocket.DynamicSubscriptionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 캔들 데이터 REST API 컨트롤러. + * + * context-path: /api + * endpoint: GET /api/candles/history + * + * 명세서 5.1 포맷: + * Query Params: market=KRW-BTC, type=1m, to=2026-05-20T10:17:00Z, count=200 + * Response: CandleBarDto[] (time, open, high, low, close, volume, rsi) + */ +@RestController +@RequestMapping("/candles") +@RequiredArgsConstructor +@Slf4j +public class CandleController { + + private final HistoricalDataService historicalDataService; + private final DynamicSubscriptionManager subscriptionManager; + + /** + * 과거 캔들 데이터 조회. + * + * @param market 업비트 마켓 코드 (e.g. "KRW-BTC") + * @param type 캔들 타입 (e.g. "1m", "5m", "1h", "1d") + * @param to 기준 시간 이전(exclusive) ISO-8601 (e.g. "2026-05-20T10:00:00" 또는 "...Z") + * @param count 요청 캔들 수 (기본값 200, 최대 200) + * @return 시간 오름차순 CandleBarDto 배열 + */ + @GetMapping("/history") + public ResponseEntity> getHistory( + @RequestParam String market, + @RequestParam(defaultValue = "1d") String type, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "200") int count) { + + log.info("[CandleController] history 요청: market={}, type={}, to={}, count={}", market, type, to, count); + List bars = historicalDataService.getHistory(market, type, to, count); + return ResponseEntity.ok(bars); + } + + /** + * 차트 실시간용 — 업비트 WS·Ta4j warm-up 고정 (STOMP 구독과 병행). + */ + @PostMapping("/watch") + public ResponseEntity watch( + @RequestParam String market, + @RequestParam(defaultValue = "1m") String type) { + subscriptionManager.ensureMarketPinned(market, type); + subscriptionManager.ensureMarketPinned(market, "1m"); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/ChartSettingsController.java b/backend/src/main/java/com/goldenchart/controller/ChartSettingsController.java new file mode 100644 index 0000000..98fced2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/ChartSettingsController.java @@ -0,0 +1,146 @@ +package com.goldenchart.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcChartSlot; +import com.goldenchart.entity.GcChartWorkspace; +import com.goldenchart.service.ChartSettingsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 차트 워크스페이스 설정 REST API. + * + * GET /api/chart/workspace - 전체 설정 로드 (프론트 시작 시 호출) + * POST /api/chart/workspace - 전체 설정 저장 + * PUT /api/chart/workspace/layout - 레이아웃만 변경 + * PUT /api/chart/slot/{index} - 특정 슬롯 저장 + */ +@RestController +@RequestMapping("/chart") +@RequiredArgsConstructor +@Slf4j +public class ChartSettingsController { + + private final ChartSettingsService settingsService; + private final ObjectMapper objectMapper; + + /** 헤더에서 userId 또는 deviceId 추출 */ + private Long userId(Map headers) { + String v = headers.get("x-user-id"); + return v != null ? Long.parseLong(v) : null; + } + private String deviceId(Map headers) { + return headers.getOrDefault("x-device-id", "anonymous"); + } + + /** + * 프론트엔드 시작 시 전체 워크스페이스 로드. + * DB에 저장된 설정이 없으면 204 반환 → 프론트는 기본값 사용. + */ + @GetMapping("/workspace") + public ResponseEntity getWorkspace( + @RequestHeader Map headers) { + GcChartWorkspace ws = settingsService.getWorkspaceWithSlots( + userId(headers), deviceId(headers)); + + if (ws == null) return ResponseEntity.noContent().build(); + + return ResponseEntity.ok(toDto(ws)); + } + + /** + * 전체 워크스페이스 저장 (자동 저장 / 명시 저장). + */ + @PostMapping("/workspace") + public ResponseEntity saveWorkspace( + @RequestHeader Map headers, + @RequestBody Map body) { + Long uid = userId(headers); + String did = deviceId(headers); + String layoutId = (String) body.getOrDefault("layoutId", "1"); + String syncJson = body.containsKey("syncOptions") + ? toJson(body.get("syncOptions")) : null; + @SuppressWarnings("unchecked") + List> slotsData = (List>) body.get("slots"); + + GcChartWorkspace ws = settingsService.saveAll(uid, did, layoutId, syncJson, + slotsData != null ? slotsData : List.of()); + return ResponseEntity.ok(toDto(ws)); + } + + /** + * 레이아웃만 변경. + */ + @PutMapping("/workspace/layout") + public ResponseEntity updateLayout( + @RequestHeader Map headers, + @RequestBody Map body) { + String layoutId = (String) body.get("layoutId"); + String syncJson = body.containsKey("syncOptions") ? toJson(body.get("syncOptions")) : null; + GcChartWorkspace ws = settingsService.saveWorkspace( + userId(headers), deviceId(headers), layoutId, syncJson); + return ResponseEntity.ok(Map.of("layoutId", ws.getLayoutId())); + } + + /** + * 특정 슬롯 설정 저장. + */ + @PutMapping("/slot/{slotIndex}") + public ResponseEntity saveSlot( + @RequestHeader Map headers, + @PathVariable int slotIndex, + @RequestBody Map body) { + GcChartSlot slot = settingsService.saveSlot( + userId(headers), deviceId(headers), slotIndex, body); + return ResponseEntity.ok(slotToDto(slot)); + } + + // ── DTO 변환 ──────────────────────────────────────────────────────────────── + + private Map toDto(GcChartWorkspace ws) { + Map dto = new HashMap<>(); + dto.put("id", ws.getId()); + dto.put("layoutId", ws.getLayoutId()); + dto.put("syncOptions", parseJson(ws.getSyncOptionsJson())); + dto.put("slots", ws.getSlots().stream() + .map(this::slotToDto) + .collect(Collectors.toList())); + return dto; + } + + private Map slotToDto(GcChartSlot s) { + Map dto = new HashMap<>(); + dto.put("slotIndex", s.getSlotIndex()); + dto.put("symbol", s.getSymbol()); + dto.put("timeframe", s.getTimeframe()); + dto.put("chartType", s.getChartType()); + dto.put("theme", s.getTheme()); + dto.put("mode", s.getMode()); + dto.put("logScale", s.getLogScale()); + dto.put("drawingsLocked", s.getDrawingsLocked()); + dto.put("drawingsVisible", s.getDrawingsVisible()); + dto.put("indicators", parseJson(s.getIndicatorsJson())); + dto.put("drawings", parseJson(s.getDrawingsJson())); + dto.put("paneLayout", parseJson(s.getPaneLayoutJson())); + dto.put("mainChartStyle", parseJson(s.getMainChartStyleJson())); + return dto; + } + + private Object parseJson(String json) { + if (json == null) return null; + try { return objectMapper.readValue(json, Object.class); } + catch (Exception e) { return null; } + } + + private String toJson(Object obj) { + try { return objectMapper.writeValueAsString(obj); } + catch (Exception e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/DashboardController.java b/backend/src/main/java/com/goldenchart/controller/DashboardController.java new file mode 100644 index 0000000..b5f7452 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/DashboardController.java @@ -0,0 +1,27 @@ +package com.goldenchart.controller; + +import com.goldenchart.service.DashboardService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + + @GetMapping("/summary") + public ResponseEntity> summary(@RequestHeader Map h) { + Long userId = null; + String uid = h.get("x-user-id"); + if (uid != null && !uid.isBlank()) { + try { userId = Long.parseLong(uid); } catch (NumberFormatException ignored) { } + } + String deviceId = h.getOrDefault("x-device-id", "anonymous"); + return ResponseEntity.ok(dashboardService.buildSummary(userId, deviceId)); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/FcmController.java b/backend/src/main/java/com/goldenchart/controller/FcmController.java new file mode 100644 index 0000000..472d2e8 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/FcmController.java @@ -0,0 +1,56 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.FcmTokenDto; +import com.goldenchart.service.FcmPushService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/fcm") +@RequiredArgsConstructor +public class FcmController { + + private final FcmPushService fcmPushService; + + private Long userId(Map h) { + String v = h.get("x-user-id"); + return v != null ? Long.parseLong(v) : null; + } + + private String deviceId(Map h) { + return h.getOrDefault("x-device-id", "anonymous"); + } + + @PostMapping("/token") + public ResponseEntity register(@RequestHeader Map h, + @RequestBody FcmTokenDto body) { + fcmPushService.registerToken( + body.getUserId() != null ? body.getUserId() : userId(h), + body.getDeviceId() != null ? body.getDeviceId() : deviceId(h), + body.getToken()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/token") + public ResponseEntity delete(@RequestHeader Map h) { + fcmPushService.deleteByDevice(deviceId(h)); + return ResponseEntity.ok().build(); + } + + @PostMapping("/test") + public ResponseEntity> test(@RequestHeader Map h) { + fcmPushService.sendTest(userId(h), deviceId(h)); + return ResponseEntity.ok(Map.of( + "ok", true, + "available", fcmPushService.isAvailable() + )); + } + + @GetMapping("/status") + public ResponseEntity> status() { + return ResponseEntity.ok(Map.of("available", fcmPushService.isAvailable())); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/IndicatorController.java b/backend/src/main/java/com/goldenchart/controller/IndicatorController.java new file mode 100644 index 0000000..19dcdf0 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/IndicatorController.java @@ -0,0 +1,101 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.IndicatorRequest; +import com.goldenchart.dto.IndicatorResponse; +import com.goldenchart.service.IndicatorService; +import com.goldenchart.service.IndicatorSettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 지표 계산 REST API. + * + *

파라미터 우선순위 (높은 순): + *

    + *
  1. 요청 body 의 params (프론트엔드가 명시적으로 보낸 값)
  2. + *
  3. DB gc_indicator_settings 에 저장된 사용자 설정값
  4. + *
  5. IndicatorService 내부 코드 기본값 (최후 fallback)
  6. + *
+ * 이 순서로 병합하여 프론트엔드와 백엔드가 항상 동일한 파라미터로 계산된다. + *

+ */ +@RestController +@RequestMapping("/indicators") +@RequiredArgsConstructor +public class IndicatorController { + + private final IndicatorService indicatorService; + private final IndicatorSettingsService settingsService; + + /** + * 지표값 계산. + * + *

헤더: + *

    + *
  • X-Device-Id — 비회원 기기 식별자
  • + *
  • X-User-Id — 회원 ID (선택)
  • + *
+ */ + @PostMapping("/calculate") + public ResponseEntity calculate( + @RequestBody IndicatorRequest req, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + if (req.getBars() == null || req.getBars().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + Long userId = parseUserId(userIdHeader); + + /* + * DB 설정값 + 요청 파라미터 병합. + * - DB 값 기반으로 시작 + * - 요청 params 는 DB 값 위에 덮어쓰기 (명시적 전송이 더 최신) + */ + Map mergedParams = settingsService.mergeWithDb( + userId, deviceId, + req.getParams(), + req.getIndicatorType()); + + IndicatorResponse response = indicatorService.calculate( + req.getBars(), req.getIndicatorType(), mergedParams, req.getTimeframe()); + response.setSymbol(req.getSymbol()); + response.setTimeframe(req.getTimeframe()); + return ResponseEntity.ok(response); + } + + /** + * 지원 지표 목록 반환. + * frontend indicatorRegistry.ts 의 INDICATOR_REGISTRY 와 동기화. + */ + @GetMapping("/list") + public ResponseEntity list() { + return ResponseEntity.ok(java.util.Map.of( + "supported", java.util.List.of( + "SMA", "EMA", "WMA", "HMA", "VWMA", "DEMA", "TEMA", "RMA", "SMMA", "LSMA", "MACross", + "BollingerBands", "KeltnerChannels", "DonchianChannels", "BBPercentB", "BBBandWidth", + "RSI", "Stochastic", "StochRSI", "CCI", "WilliamsPercentRange", + "AwesomeOscillator", "DPO", + "MACD", "Momentum", "ROC", "TSI", + "ADX", "DMI", "Aroon", "IchimokuCloud", "ParabolicSAR", + "Choppiness", "MassIndex", + "ATR", "StandardDeviation", "HistoricalVolatility", + "OBV", "MFI", "ChaikinMF", + "VolumeOscillator", "VR", "Disparity", + "Psychological", "InvestPsychological", + "TRIX", "ADX", "DMI" + ) + )); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/IndicatorSettingsController.java b/backend/src/main/java/com/goldenchart/controller/IndicatorSettingsController.java new file mode 100644 index 0000000..d61fb9d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/IndicatorSettingsController.java @@ -0,0 +1,137 @@ +package com.goldenchart.controller; + +import com.goldenchart.service.IndicatorSettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 전역 지표 파라미터 CRUD REST API. + * + *

프론트엔드 indicatorRegistry.ts 의 defaultParams 를 완전히 대체하는 + * 사용자별 지표 파라미터를 저장/조회한다.

+ * + *
+ * GET    /indicator-settings          — 전체 지표 파라미터 조회
+ * GET    /indicator-settings/{type}   — 특정 지표 파라미터 조회
+ * PUT    /indicator-settings          — 전체 지표 파라미터 저장 (덮어쓰기)
+ * PATCH  /indicator-settings/{type}   — 특정 지표 파라미터 병합 저장
+ * 
+ */ +@RestController +@RequestMapping("/indicator-settings") +@RequiredArgsConstructor +public class IndicatorSettingsController { + + private final IndicatorSettingsService service; + + // ── 헤더 파싱 헬퍼 ──────────────────────────────────────────────────────── + + private Long userId(String userIdHeader) { + if (userIdHeader == null || userIdHeader.isBlank()) return null; + try { return Long.parseLong(userIdHeader); } catch (NumberFormatException e) { return null; } + } + + // ── GET: 전체 파라미터 조회 ──────────────────────────────────────────────── + + /** + * 이 장치/사용자의 모든 지표 파라미터를 반환. + * 프론트엔드 앱 시작 시 호출하여 전역 설정을 로드한다. + */ + @GetMapping + public ResponseEntity>> getAll( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + Map> result = service.getAll(userId(userIdHeader), deviceId); + return ResponseEntity.ok(result); + } + + // ── GET: 특정 지표 파라미터 조회 ────────────────────────────────────────── + + @GetMapping("/{indicatorType}") + public ResponseEntity> getForType( + @PathVariable String indicatorType, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + return ResponseEntity.ok(service.getForType(userId(userIdHeader), deviceId, indicatorType)); + } + + // ── PUT: 전체 파라미터 저장 ──────────────────────────────────────────────── + + /** + * 모든 지표 파라미터를 한 번에 저장(덮어쓰기). + * + * Request body: + * { + * "RSI": {"length": 9, "src": "close"}, + * "MACD": {"fastLength": 12, "slowLength": 26, "signalLength": 9}, + * "BollingerBands":{"length": 20, "mult": 2.0}, + * ... + * } + */ + @PutMapping + public ResponseEntity saveAll( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId, + @RequestBody Map> allParams) { + service.saveAll(userId(userIdHeader), deviceId, allParams); + return ResponseEntity.noContent().build(); + } + + // ── PATCH: 특정 지표 파라미터 병합 저장 ──────────────────────────────────── + + /** + * 특정 지표의 파라미터만 업데이트. + * 나머지 지표 설정은 유지된다. + * + * PATCH /indicator-settings/RSI + * Body: {"length": 14, "src": "close"} + */ + @PatchMapping("/{indicatorType}") + public ResponseEntity saveForType( + @PathVariable String indicatorType, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId, + @RequestBody Map params) { + service.saveForType(userId(userIdHeader), deviceId, indicatorType, params); + return ResponseEntity.noContent().build(); + } + + // ── GET: 전체 시각 설정 조회 ─────────────────────────────────────────────── + + /** + * 모든 지표의 시각 설정(색상·선굵기·수평선)을 반환. + * 프론트엔드 앱 시작 시 호출하여 전역 시각 설정을 로드한다. + * + * GET /indicator-settings/visual + */ + @GetMapping("/visual") + public ResponseEntity>> getAllVisual( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + return ResponseEntity.ok(service.getAllVisual(userId(userIdHeader), deviceId)); + } + + // ── PATCH: 특정 지표 시각 설정 저장 ─────────────────────────────────────── + + /** + * 특정 지표의 시각 설정(색상·선굵기·수평선)을 병합 저장. + * + * PATCH /indicator-settings/RSI/visual + * Body: { + * "plots": [{"id":"plot0","color":"#ff0000","lineWidth":2,"type":"line"}], + * "hlines": [{"price":70,"color":"#EF5350","visible":true}] + * } + */ + @PatchMapping("/{indicatorType}/visual") + public ResponseEntity saveVisualForType( + @PathVariable String indicatorType, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId, + @RequestBody Map visual) { + service.saveVisualForType(userId(userIdHeader), deviceId, indicatorType, visual); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/LiveStrategySettingsController.java b/backend/src/main/java/com/goldenchart/controller/LiveStrategySettingsController.java new file mode 100644 index 0000000..aa08358 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/LiveStrategySettingsController.java @@ -0,0 +1,68 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.LiveStrategyBulkRequest; +import com.goldenchart.dto.LiveStrategySettingsDto; +import com.goldenchart.service.LiveStrategySettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 실시간 전략 체크 설정 REST API. + * + *
+ * GET  /api/strategy/settings?market=KRW-BTC   → 현재 설정 조회
+ * PUT  /api/strategy/settings                  → 설정 저장/갱신
+ * 
+ */ +@RestController +@RequestMapping("/strategy/settings") +@RequiredArgsConstructor +public class LiveStrategySettingsController { + + private final LiveStrategySettingsService service; + + @GetMapping + public ResponseEntity get( + @RequestParam(value = "market", defaultValue = "KRW-BTC") String market, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + return ResponseEntity.ok(service.get(parseUserId(userIdHeader), deviceId, market)); + } + + @PutMapping + public ResponseEntity put( + @RequestBody LiveStrategySettingsDto dto, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + return ResponseEntity.ok(service.save(parseUserId(userIdHeader), deviceId, dto)); + } + + /** 실시간 체크 ON + 전략 지정된 종목 목록 (현재 디바이스/유저) */ + @GetMapping("/active") + public ResponseEntity> listActive( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + return ResponseEntity.ok(service.listActive(parseUserId(userIdHeader), deviceId)); + } + + /** 관심종목 등 여러 마켓에 동일 설정 일괄 저장 */ + @PutMapping("/bulk") + public ResponseEntity> putBulk( + @RequestBody LiveStrategyBulkRequest req, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + return ResponseEntity.ok(service.saveBulk(parseUserId(userIdHeader), deviceId, req)); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/LiveTradingController.java b/backend/src/main/java/com/goldenchart/controller/LiveTradingController.java new file mode 100644 index 0000000..56b5406 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/LiveTradingController.java @@ -0,0 +1,47 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.LiveOrderRequest; +import com.goldenchart.dto.LiveOrderRequest; +import com.goldenchart.dto.LiveSummaryDto; +import com.goldenchart.dto.LiveTradeDto; +import com.goldenchart.service.LiveTradingService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/live") +@RequiredArgsConstructor +public class LiveTradingController { + + private final LiveTradingService liveTradingService; + + @GetMapping("/summary") + public ResponseEntity summary(@RequestHeader Map h) { + return ResponseEntity.ok(liveTradingService.getSummary(userId(h), deviceId(h))); + } + + @GetMapping("/trades") + public ResponseEntity> trades(@RequestHeader Map h) { + return ResponseEntity.ok(liveTradingService.listTrades(userId(h), deviceId(h))); + } + + @PostMapping("/orders") + public ResponseEntity placeOrder(@RequestHeader Map h, + @RequestBody LiveOrderRequest body) { + return ResponseEntity.ok(liveTradingService.placeManualOrder(userId(h), deviceId(h), body)); + } + + private Long userId(Map h) { + String v = h.get("x-user-id"); + if (v == null || v.isBlank()) return null; + try { return Long.parseLong(v); } catch (NumberFormatException e) { return null; } + } + + private String deviceId(Map h) { + return h.getOrDefault("x-device-id", "anonymous"); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java b/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java new file mode 100644 index 0000000..960d183 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java @@ -0,0 +1,58 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.PaperOrderRequest; +import com.goldenchart.dto.PaperSummaryDto; +import com.goldenchart.dto.PaperTradeDto; +import com.goldenchart.service.PaperTradingService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/paper") +@RequiredArgsConstructor +public class PaperTradingController { + + private final PaperTradingService paperTradingService; + + private Long userId(Map h) { + String v = h.get("x-user-id"); + return v != null ? Long.parseLong(v) : null; + } + + private String deviceId(Map h) { + return h.getOrDefault("x-device-id", "anonymous"); + } + + @GetMapping("/summary") + public ResponseEntity summary(@RequestHeader Map h) { + return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), null)); + } + + @PostMapping("/summary") + public ResponseEntity summaryWithMarks( + @RequestHeader Map h, + @RequestBody(required = false) Map markPrices) { + return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices)); + } + + @GetMapping("/trades") + public ResponseEntity> trades(@RequestHeader Map h) { + return ResponseEntity.ok(paperTradingService.listTrades(userId(h), deviceId(h))); + } + + @PostMapping("/orders") + public ResponseEntity placeOrder( + @RequestHeader Map h, + @RequestBody PaperOrderRequest body) { + return ResponseEntity.ok(paperTradingService.placeOrder(userId(h), deviceId(h), body)); + } + + @PostMapping("/reset") + public ResponseEntity reset(@RequestHeader Map h) { + return ResponseEntity.ok(paperTradingService.resetAccount(userId(h), deviceId(h))); + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyController.java b/backend/src/main/java/com/goldenchart/controller/StrategyController.java new file mode 100644 index 0000000..1230b63 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/StrategyController.java @@ -0,0 +1,63 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.StrategyDto; +import com.goldenchart.service.StrategyService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 투자전략 CRUD REST API. + * + *

헤더: + *

    + *
  • X-Device-Id — 비회원 기기 식별자 (필수)
  • + *
  • X-User-Id — 회원 ID (선택)
  • + *
+ */ +@RestController +@RequestMapping("/strategies") +@RequiredArgsConstructor +public class StrategyController { + + private final StrategyService service; + + /** 전략 목록 조회 */ + @GetMapping + public ResponseEntity> list( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + return ResponseEntity.ok(service.list(parseUserId(userIdHeader), deviceId)); + } + + /** 단일 전략 조회 */ + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable Long id) { + return service.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + /** 전략 저장 (id 없으면 생성, 있으면 수정) */ + @PostMapping + public ResponseEntity save( + @RequestBody StrategyDto dto, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId)); + } + + /** 전략 삭제 */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java b/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java new file mode 100644 index 0000000..6620636 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java @@ -0,0 +1,43 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.TradeSignalDto; +import com.goldenchart.service.TradeSignalService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 매매 시그널 이력 REST API. + * + *
+ * GET  /api/trade-signals          → 전체 이력
+ * GET  /api/trade-signals?market=  → 종목별 이력
+ * 
+ */ +@RestController +@RequestMapping("/trade-signals") +@RequiredArgsConstructor +public class TradeSignalController { + + private final TradeSignalService service; + + @GetMapping + public ResponseEntity> list( + @RequestParam(required = false) String market, + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + + Long userId = parseUserId(userIdHeader); + List result = (market != null && !market.isBlank()) + ? service.listByMarket(userId, deviceId, market) + : service.list(userId, deviceId); + return ResponseEntity.ok(result); + } + + private Long parseUserId(String h) { + if (h == null || h.isBlank()) return null; + try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/WatchlistController.java b/backend/src/main/java/com/goldenchart/controller/WatchlistController.java new file mode 100644 index 0000000..83b7bed --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/WatchlistController.java @@ -0,0 +1,133 @@ +package com.goldenchart.controller; + +import com.goldenchart.entity.GcWatchlist; +import com.goldenchart.entity.GcHoldings; +import com.goldenchart.repository.GcHoldingsRepository; +import com.goldenchart.repository.GcWatchlistRepository; +import com.goldenchart.service.WatchlistService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 관심종목 / 보유종목 REST API. + * + *

관심종목 추가/삭제 시 {@link WatchlistService} 가 실시간 전략 체크 대상과 자동 연동한다. + */ +@RestController +@RequiredArgsConstructor +@Transactional +public class WatchlistController { + + private final WatchlistService watchlistService; + private final GcWatchlistRepository watchlistRepo; + private final GcHoldingsRepository holdingsRepo; + + private Long userId(Map h) { + String v = h.get("x-user-id"); return v != null ? Long.parseLong(v) : null; + } + private String deviceId(Map h) { + return h.getOrDefault("x-device-id", "anonymous"); + } + + // ── 관심종목 ────────────────────────────────────────────────────────────── + + @GetMapping("/watchlist") + @Transactional(readOnly = true) + public ResponseEntity> getWatchlist(@RequestHeader Map h) { + return ResponseEntity.ok(watchlistService.list(userId(h), deviceId(h))); + } + + @PostMapping("/watchlist") + public ResponseEntity addWatchlist( + @RequestHeader Map h, + @RequestBody Map body) { + Long uid = userId(h); + String symbol = (String) body.get("symbol"); + GcWatchlist item = watchlistService.add( + uid, + deviceId(h), + symbol, + (String) body.get("koreanName"), + (String) body.get("englishName"), + body.containsKey("displayOrder") ? (Integer) body.get("displayOrder") : null + ); + return ResponseEntity.ok(item); + } + + @DeleteMapping("/watchlist/{symbol}") + public ResponseEntity removeWatchlist( + @RequestHeader Map h, + @PathVariable String symbol) { + watchlistService.remove(userId(h), deviceId(h), symbol); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/watchlist/order") + public ResponseEntity reorderWatchlist( + @RequestHeader Map h, + @RequestBody List> items) { + items.forEach(item -> { + String symbol = (String) item.get("symbol"); + int order = (Integer) item.get("displayOrder"); + Long uid = userId(h); + watchlistService.list(uid, deviceId(h)).stream() + .filter(w -> w.getSymbol().equals(symbol)) + .findFirst() + .ifPresent(w -> { w.setDisplayOrder(order); watchlistRepo.save(w); }); + }); + return ResponseEntity.ok().build(); + } + + // ── 보유종목 ────────────────────────────────────────────────────────────── + + @GetMapping("/holdings") + @Transactional(readOnly = true) + public ResponseEntity> getHoldings(@RequestHeader Map h) { + Long uid = userId(h); + return ResponseEntity.ok(uid != null + ? holdingsRepo.findByUserIdOrderByDisplayOrderAsc(uid) + : holdingsRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId(h))); + } + + @PostMapping("/holdings") + public ResponseEntity addHoldings( + @RequestHeader Map h, + @RequestBody Map body) { + Long uid = userId(h); + String did = uid == null ? deviceId(h) : null; + String symbol = (String) body.get("symbol"); + + GcHoldings item = (uid != null + ? holdingsRepo.findByUserIdOrderByDisplayOrderAsc(uid) + : holdingsRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId(h))) + .stream().filter(hld -> hld.getSymbol().equals(symbol)).findFirst() + .orElseGet(() -> GcHoldings.builder().userId(uid).deviceId(did).symbol(symbol).build()); + + item.setKoreanName((String) body.get("koreanName")); + item.setEnglishName((String) body.get("englishName")); + if (body.containsKey("avgPrice")) + item.setAvgPrice(new BigDecimal(body.get("avgPrice").toString())); + if (body.containsKey("quantity")) + item.setQuantity(new BigDecimal(body.get("quantity").toString())); + if (body.containsKey("displayOrder")) + item.setDisplayOrder((Integer) body.get("displayOrder")); + + return ResponseEntity.ok(holdingsRepo.save(item)); + } + + @DeleteMapping("/holdings/{symbol}") + public ResponseEntity removeHoldings( + @RequestHeader Map h, + @PathVariable String symbol) { + Long uid = userId(h); + if (uid != null) holdingsRepo.deleteByUserIdAndSymbol(uid, symbol); + else holdingsRepo.deleteByDeviceIdAndSymbol(deviceId(h), symbol); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/AdminVerifyRequest.java b/backend/src/main/java/com/goldenchart/dto/AdminVerifyRequest.java new file mode 100644 index 0000000..2f0dab1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/AdminVerifyRequest.java @@ -0,0 +1,8 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class AdminVerifyRequest { + private String password; +} diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java new file mode 100644 index 0000000..349c3a8 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java @@ -0,0 +1,58 @@ +package com.goldenchart.dto; + +import lombok.*; + +/** + * Ta4j AnalysisCriterion 계산 결과 전체를 담는 DTO + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class BacktestAnalysisDto { + + // ── 기본 자본 ───────────────────────────────────────────────────────────── + double initialCapital; + double finalEquity; + + // ── 수익성 지표 ────────────────────────────────────────────────────────── + /** 총 수익률 (소수, e.g. 0.152 = +15.2%) */ + double totalReturnPct; + /** 총 손익 (금액) */ + double totalProfitLoss; + /** 총 수익 (이익 거래 합산) */ + double grossProfit; + /** 총 손실 (손실 거래 합산, 음수) */ + double grossLoss; + /** 평균 거래 수익률 */ + double avgReturnPct; + /** 손익비 (총이익 / |총손실|) */ + double profitLossRatio; + + // ── 거래 통계 ───────────────────────────────────────────────────────────── + int numberOfPositions; + int numberOfWinning; + int numberOfLosing; + int numberOfBreakEven; + /** 승률 0~1 */ + double winRate; + + // ── 리스크 지표 ─────────────────────────────────────────────────────────── + /** 최대 낙폭 (음수, -0.15 = -15%) */ + double maxDrawdownPct; + /** 최대 상승폭 */ + double maxRunupPct; + /** 샤프 비율 */ + double sharpeRatio; + /** 소르티노 비율 */ + double sortinoRatio; + /** 칼마 비율 (총수익률 / |최대낙폭|) */ + double calmarRatio; + /** Value at Risk (95%) */ + double valueAtRisk95; + /** Expected Shortfall / CVaR */ + double expectedShortfall; + + // ── 벤치마크 비교 ───────────────────────────────────────────────────────── + /** 바이앤홀드 수익률 */ + double buyAndHoldReturnPct; + /** 전략 대비 바이앤홀드 배수 (>1 이면 전략이 유리) */ + double vsBuyAndHold; +} diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java b/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java new file mode 100644 index 0000000..9019673 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java @@ -0,0 +1,59 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.*; + +import java.util.List; +import java.util.Map; + +/** + * 백테스팅 요청 DTO. + * + *

+ * {
+ *   "strategyId": 1,                         // DB 전략 ID (선택 — 없으면 dsl 직접 전송)
+ *   "buyCondition":  { ...LogicNode },        // 매수 조건 DSL
+ *   "sellCondition": { ...LogicNode },        // 매도 조건 DSL
+ *   "bars": [ {time, open, high, low, close, volume}, ... ],
+ *   "timeframe": "1D",
+ *   "indicatorParams": { "CCI": {"length":13}, ... }  // 사용자 설정 파라미터 (선택)
+ * }
+ * 
+ */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class BacktestRequest { + + /** DB 저장 전략 ID (선택 — 설정 시 buyCondition/sellCondition 무시) */ + private Long strategyId; + + /** 매수 조건 DSL */ + private JsonNode buyCondition; + + /** 매도 조건 DSL */ + private JsonNode sellCondition; + + /** OHLCV 캔들 데이터 */ + private List bars; + + /** 타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M) */ + private String timeframe; + + /** + * 지표 파라미터 오버라이드. + * key = indicatorType (e.g. "CCI"), value = {length:13, ...} + * 없으면 IndicatorService 기본값 사용. + */ + private Map> indicatorParams; + + /** 백테스팅 설정 옵션 (선택 — 없으면 기본값 사용) */ + private BacktestSettingsDto settings; + + /** 종목 코드 (결과 저장용, e.g. "KRW-BTC") */ + private String symbol; + + /** 전략 이름 (결과 저장용) */ + private String strategyName; + + /** 디바이스 ID (결과 저장용) */ + private String deviceId; +} diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java new file mode 100644 index 0000000..e2098a8 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java @@ -0,0 +1,62 @@ +package com.goldenchart.dto; + +import lombok.*; + +import java.util.List; + +/** + * 백테스팅 결과 응답 DTO. + * + *
+ * {
+ *   "signals": [
+ *     { "time": 1700000000, "type": "BUY",  "price": 42000 },
+ *     { "time": 1700200000, "type": "SELL", "price": 45000 }
+ *   ],
+ *   "stats": { "totalTrades": 8, "winRate": 0.625, ... }
+ * }
+ * 
+ */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class BacktestResponse { + + private List signals; + private Stats stats; + /** Ta4j AnalysisCriterion 전체 결과 */ + private BacktestAnalysisDto analysis; + /** DB 저장 후 부여된 결과 ID */ + private Long resultId; + + @Data @Builder @NoArgsConstructor @AllArgsConstructor + public static class Signal { + /** Unix timestamp (초) */ + private long time; + /** BUY | SELL */ + private String type; + /** 해당 봉 종가 */ + private double price; + /** 진입/청산 인덱스 (0-based) */ + private int barIndex; + } + + @Data @Builder @NoArgsConstructor @AllArgsConstructor + public static class Stats { + private int totalSignals; + private int buySignals; + private int sellSignals; + /** 거래 쌍(매수→매도) 수 */ + private int totalTrades; + /** 수익 거래 수 */ + private int winTrades; + /** 승률 0~1 */ + private double winRate; + /** 총 수익률 (소수, e.g. 0.15 = +15%) */ + private double totalReturn; + /** 최대 낙폭 (소수, 음수, e.g. -0.12 = -12%) */ + private double maxDrawdown; + /** 평균 수익률 per trade */ + private double avgReturn; + /** 최종 자산 (초기 자본 × 복리 수익) */ + private double finalEquity; + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java new file mode 100644 index 0000000..4648755 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java @@ -0,0 +1,102 @@ +package com.goldenchart.dto; + +import lombok.*; + +import java.math.BigDecimal; + +/** + * 백테스팅 설정 DTO. + * Ta4j 의 비용 모델·손절/익절·트레일링 스탑·진입가격·재진입 등 모든 옵션을 담는다. + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class BacktestSettingsDto { + + private Long id; + + // ── 자본 설정 ────────────────────────────────────────────────────────────── + /** 초기 자본 (원) */ + @Builder.Default + private BigDecimal initialCapital = new BigDecimal("10000000.00"); + + // ── 비용 모델 ────────────────────────────────────────────────────────────── + /** LINEAR | ZERO */ + @Builder.Default + private String commissionType = "LINEAR"; + + /** 수수료율 (예: 0.0015 = 0.15%) */ + @Builder.Default + private BigDecimal commissionRate = new BigDecimal("0.00150"); + + /** 슬리피지율 (예: 0.0005 = 0.05%) */ + @Builder.Default + private BigDecimal slippageRate = new BigDecimal("0.00050"); + + // ── 진입/청산 가격 ───────────────────────────────────────────────────────── + /** CLOSE | NEXT_OPEN */ + @Builder.Default + private String entryPriceType = "CLOSE"; + + /** CLOSE | NEXT_OPEN */ + @Builder.Default + private String exitPriceType = "CLOSE"; + + // ── 포지션 방향 ──────────────────────────────────────────────────────────── + /** LONG | SHORT | BOTH */ + @Builder.Default + private String positionDirection = "LONG"; + + // ── 거래 규모 ────────────────────────────────────────────────────────────── + /** CAPITAL_PCT | FIXED_AMOUNT */ + @Builder.Default + private String tradeSizeType = "CAPITAL_PCT"; + + /** 비율(%) 또는 고정 금액(원) */ + @Builder.Default + private BigDecimal tradeSizeValue = new BigDecimal("100.0000"); + + // ── 손절 (StopLossRule) ─────────────────────────────────────────────────── + @Builder.Default + private Boolean stopLossEnabled = false; + + /** 손절 비율 (%) */ + @Builder.Default + private BigDecimal stopLossPct = new BigDecimal("2.000"); + + // ── 익절 (StopGainRule) ─────────────────────────────────────────────────── + @Builder.Default + private Boolean takeProfitEnabled = false; + + /** 익절 비율 (%) */ + @Builder.Default + private BigDecimal takeProfitPct = new BigDecimal("5.000"); + + // ── 트레일링 스탑 (TrailingStopLossRule) ───────────────────────────────── + @Builder.Default + private Boolean trailingStopEnabled = false; + + /** 트레일링 스탑 비율 (%) */ + @Builder.Default + private BigDecimal trailingStopPct = new BigDecimal("2.000"); + + // ── 재진입 제어 ──────────────────────────────────────────────────────────── + /** 매도 후 재진입 대기 봉 수 */ + @Builder.Default + private Integer reentryWaitBars = 0; + + /** 최대 동시 보유 포지션 수 */ + @Builder.Default + private Integer maxOpenTrades = 1; + + // ── 포지션 종속성 모드 ───────────────────────────────────────────────────── + /** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 제어 */ + @Builder.Default + private String positionMode = "LONG_ONLY"; + + // ── 분할 청산 ────────────────────────────────────────────────────────────── + @Builder.Default + private Boolean partialExitEnabled = false; + + /** 분할 청산 비율 (%) */ + @Builder.Default + private BigDecimal partialExitPct = new BigDecimal("50.000"); +} diff --git a/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java b/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java new file mode 100644 index 0000000..d6e7f7f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java @@ -0,0 +1,44 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * REST API / STOMP 용 캔들 데이터 전송 객체. + * + * REST : GET /api/candles/history 응답 배열 요소 + * STOMP : /sub/charts/{market}/{type} 실시간 경량 페이로드 + * + * 명세서 5.1 / 5.2 포맷 준수: + * - time : Unix 초 타임스탬프 + * - o/h/l/c/v : OHLCV + * - rsi : RSI 값 (null 허용) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CandleBarDto { + + /** Unix 초 타임스탬프 (캔들 시작 시간) */ + private long time; + + private double open; + private double high; + private double low; + private double close; + private double volume; + + /** RSI 값. 계산 불가 구간(워밍업)에서는 null */ + private Double rsi; + + /** + * 실시간 전략 체크 시그널. + * "BUY" | "SELL" | "NONE" (null 이면 전략 체크 미설정) + */ + private String signal; +} diff --git a/backend/src/main/java/com/goldenchart/dto/CreateUserRequest.java b/backend/src/main/java/com/goldenchart/dto/CreateUserRequest.java new file mode 100644 index 0000000..e3f223d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/CreateUserRequest.java @@ -0,0 +1,12 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class CreateUserRequest { + private String username; + private String password; + private String displayName; + private String role; + private Boolean enabled; +} diff --git a/backend/src/main/java/com/goldenchart/dto/FcmTokenDto.java b/backend/src/main/java/com/goldenchart/dto/FcmTokenDto.java new file mode 100644 index 0000000..ac06a50 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/FcmTokenDto.java @@ -0,0 +1,10 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class FcmTokenDto { + private String token; + private Long userId; + private String deviceId; +} diff --git a/backend/src/main/java/com/goldenchart/dto/IndicatorRequest.java b/backend/src/main/java/com/goldenchart/dto/IndicatorRequest.java new file mode 100644 index 0000000..ea13a1d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/IndicatorRequest.java @@ -0,0 +1,18 @@ +package com.goldenchart.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class IndicatorRequest { + private String symbol; + private String timeframe; + /** 지표 타입 (RSI, MACD, BollingerBands ...) */ + private String indicatorType; + /** 지표 파라미터 */ + private Map params; + /** 직접 캔들 데이터 전달 (선택: 없으면 DB에서 조회) */ + private List bars; +} diff --git a/backend/src/main/java/com/goldenchart/dto/IndicatorResponse.java b/backend/src/main/java/com/goldenchart/dto/IndicatorResponse.java new file mode 100644 index 0000000..74c7c28 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/IndicatorResponse.java @@ -0,0 +1,34 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 지표 계산 결과 응답. + * frontend PlotData 구조와 1:1 매핑. + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class IndicatorResponse { + private String symbol; + private String timeframe; + private String indicatorType; + /** time 배열 (Unix 초) */ + private List times; + /** + * plot ID → [{time, value, color?}] 배열. + * e.g. MACD: {"histogram":[...], "macd":[...], "signal":[...]} + */ + private Map> values; + + @Data @Builder @NoArgsConstructor @AllArgsConstructor + public static class PlotPoint { + private long time; + private Double value; + private String color; + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveOrderRequest.java b/backend/src/main/java/com/goldenchart/dto/LiveOrderRequest.java new file mode 100644 index 0000000..1daca79 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveOrderRequest.java @@ -0,0 +1,17 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class LiveOrderRequest { + private String market; + /** BUY | SELL */ + private String side; + /** market | limit — 현재 market 만 지원 */ + private String orderKind; + /** 시장가 매수 시 KRW 금액 (미지정 시 예산 % 적용) */ + private Double krwAmount; + /** 시장가 매도 시 수량 (미지정 시 전량) */ + private Double quantity; + private Long strategyId; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveStrategyBulkRequest.java b/backend/src/main/java/com/goldenchart/dto/LiveStrategyBulkRequest.java new file mode 100644 index 0000000..9951829 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveStrategyBulkRequest.java @@ -0,0 +1,22 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** 관심종목 등 여러 마켓에 동일한 실시간 전략 설정을 일괄 적용 */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LiveStrategyBulkRequest { + + /** 적용할 마켓 코드 목록 (e.g. KRW-BTC) */ + private List markets; + + /** 각 마켓에 복사할 설정 (market 필드는 서버에서 markets 항목으로 덮어씀) */ + private LiveStrategySettingsDto template; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java new file mode 100644 index 0000000..3299e9f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveStrategySettingsDto.java @@ -0,0 +1,39 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 실시간 전략 체크 설정 DTO. + * PUT /api/strategy/settings (요청/응답 공용) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LiveStrategySettingsDto { + + /** 업비트 마켓 코드 (e.g. "KRW-BTC") */ + private String market; + + /** 연결된 전략 ID (null = 미연결) */ + private Long strategyId; + + /** 실시간 체크 ON/OFF (JSON 키: isLiveCheck) */ + @JsonProperty("isLiveCheck") + private boolean liveCheck; + + /** "CANDLE_CLOSE" | "REALTIME_TICK" */ + private String executionType; + + /** 전략 평가 분봉: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d */ + @Builder.Default + private String candleType = "1m"; + + /** 매도 시그널 포지션 종속성 모드: "LONG_ONLY" | "SIGNAL_ONLY" */ + @Builder.Default + private String positionMode = "LONG_ONLY"; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveSummaryDto.java b/backend/src/main/java/com/goldenchart/dto/LiveSummaryDto.java new file mode 100644 index 0000000..8a79dfd --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveSummaryDto.java @@ -0,0 +1,30 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LiveSummaryDto { + private boolean enabled; + private boolean configured; + private String tradingMode; + private double krwBalance; + private List positions; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Position { + private String symbol; + private double quantity; + private double avgPrice; + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/LiveTradeDto.java b/backend/src/main/java/com/goldenchart/dto/LiveTradeDto.java new file mode 100644 index 0000000..5e7c46e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LiveTradeDto.java @@ -0,0 +1,22 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LiveTradeDto { + private Long id; + private String symbol; + private String side; + private String source; + private double price; + private double quantity; + private double grossAmount; + private String upbitOrderUuid; + private String createdAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LoginRequest.java b/backend/src/main/java/com/goldenchart/dto/LoginRequest.java new file mode 100644 index 0000000..dba475e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class LoginRequest { + private String username; + private String password; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LoginResponse.java b/backend/src/main/java/com/goldenchart/dto/LoginResponse.java new file mode 100644 index 0000000..874f7dd --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LoginResponse.java @@ -0,0 +1,18 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponse { + private Long userId; + private String username; + private String displayName; + /** ADMIN | USER | GUEST */ + private String role; +} diff --git a/backend/src/main/java/com/goldenchart/dto/MenuPermissionsResponse.java b/backend/src/main/java/com/goldenchart/dto/MenuPermissionsResponse.java new file mode 100644 index 0000000..370c5c7 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/MenuPermissionsResponse.java @@ -0,0 +1,17 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MenuPermissionsResponse { + private String role; + private Map permissions; +} diff --git a/backend/src/main/java/com/goldenchart/dto/OhlcvBar.java b/backend/src/main/java/com/goldenchart/dto/OhlcvBar.java new file mode 100644 index 0000000..558bae0 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/OhlcvBar.java @@ -0,0 +1,21 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * OHLCV 캔들 데이터 DTO. + * frontend OHLCVBar 와 동일 구조. + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class OhlcvBar { + /** Unix timestamp (초) */ + private long time; + private double open; + private double high; + private double low; + private double close; + private double volume; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java new file mode 100644 index 0000000..8b91f1c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java @@ -0,0 +1,18 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class PaperOrderRequest { + private String market; + private String side; // BUY | SELL + private String orderKind; // limit | market + private Double price; + private Double quantity; + /** 수량 미입력 시 매수 예산 비율 (가용현금 %) — 기본값: 설정의 paperAutoTradeBudgetPct */ + private Double budgetPct; + /** 수량 미입력 시 매수 고정 금액 (KRW) — budgetPct보다 우선 */ + private Double krwAmount; + private String source; // MANUAL | STRATEGY (optional) + private Long strategyId; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperPositionDto.java b/backend/src/main/java/com/goldenchart/dto/PaperPositionDto.java new file mode 100644 index 0000000..360be0c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperPositionDto.java @@ -0,0 +1,19 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperPositionDto { + private Long id; + private String symbol; + private String koreanName; + private Double quantity; + private Double avgPrice; + /** 프론트가 현재가를 넣어 평가손익 계산 시 사용 (선택) */ + private Double markPrice; + private Double evalAmount; + private Double profitLoss; + private Double profitLossPct; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java b/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java new file mode 100644 index 0000000..59e9573 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java @@ -0,0 +1,25 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class PaperSummaryDto { + private Boolean enabled; + private Double initialCapital; + private Double cashBalance; + private Double stockEvalAmount; + private Double totalAsset; + private Double unrealizedPnl; + private Double realizedPnl; + private Double totalReturnPct; + private Double feeRatePct; + private Double slippagePct; + private Double minOrderKrw; + private Boolean autoTradeEnabled; + private Double autoTradeBudgetPct; + private List positions; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java b/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java new file mode 100644 index 0000000..00d395a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java @@ -0,0 +1,22 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperTradeDto { + private Long id; + private String symbol; + private String side; + private String orderKind; + private String source; + private Long strategyId; + private Double price; + private Double quantity; + private Double grossAmount; + private Double feeAmount; + private Double netAmount; + private Double cashAfter; + private String createdAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/RolePermissionsUpdateRequest.java b/backend/src/main/java/com/goldenchart/dto/RolePermissionsUpdateRequest.java new file mode 100644 index 0000000..d5134eb --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/RolePermissionsUpdateRequest.java @@ -0,0 +1,11 @@ +package com.goldenchart.dto; + +import lombok.Data; + +import java.util.Map; + +@Data +public class RolePermissionsUpdateRequest { + private String role; + private Map permissions; +} diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyDto.java b/backend/src/main/java/com/goldenchart/dto/StrategyDto.java new file mode 100644 index 0000000..ecefc35 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyDto.java @@ -0,0 +1,29 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 투자전략 CRUD DTO. + * frontend StrategyDto 와 1:1 대응. + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class StrategyDto { + + private Long id; + private String name; + private String description; + + /** 매수 조건 LogicNode 트리 (JSON 그대로 직렬화) */ + private JsonNode buyCondition; + + /** 매도 조건 LogicNode 트리 (JSON 그대로 직렬화) */ + private JsonNode sellCondition; + + private Boolean enabled; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/TradeSignalDto.java b/backend/src/main/java/com/goldenchart/dto/TradeSignalDto.java new file mode 100644 index 0000000..219da3e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TradeSignalDto.java @@ -0,0 +1,28 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매매 시그널 이력 DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TradeSignalDto { + private Long id; + private String market; + private Long strategyId; + private String strategyName; + /** BUY | SELL */ + private String signalType; + private Double price; + private Long candleTime; + private String candleType; + /** CANDLE_CLOSE | REALTIME_TICK */ + private String executionType; + private String createdAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/UpbitApiCredentials.java b/backend/src/main/java/com/goldenchart/dto/UpbitApiCredentials.java new file mode 100644 index 0000000..0b0f0e2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/UpbitApiCredentials.java @@ -0,0 +1,11 @@ +package com.goldenchart.dto; + +import lombok.Builder; + +@Builder +public record UpbitApiCredentials(String accessKey, String secretKey) { + public boolean isComplete() { + return accessKey != null && !accessKey.isBlank() + && secretKey != null && !secretKey.isBlank(); + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/UpdateUserRequest.java b/backend/src/main/java/com/goldenchart/dto/UpdateUserRequest.java new file mode 100644 index 0000000..4e7843b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/UpdateUserRequest.java @@ -0,0 +1,11 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class UpdateUserRequest { + private String password; + private String displayName; + private String role; + private Boolean enabled; +} diff --git a/backend/src/main/java/com/goldenchart/dto/UserDto.java b/backend/src/main/java/com/goldenchart/dto/UserDto.java new file mode 100644 index 0000000..8d7f86e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/UserDto.java @@ -0,0 +1,19 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDto { + private Long id; + private String username; + private String displayName; + private String role; + private Boolean enabled; + private String createdAt; +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java new file mode 100644 index 0000000..88e44d9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -0,0 +1,227 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 앱 전역 차트 기본 설정. + * + *

프론트엔드 코드에 하드코딩된 다음 값들을 DB 로 대체한다: + *

    + *
  • 기본 심볼 (DEFAULT_STATE.symbol → 'KRW-BTC')
  • + *
  • 기본 타임프레임 (DEFAULT_STATE.timeframe → '1D')
  • + *
  • 기본 차트 타입 ('candlestick')
  • + *
  • 기본 테마 ('dark')
  • + *
  • 기본 로그 스케일 (false)
  • + *
  • 기본 레이아웃 ID ('1')
  • + *
  • 캔들 색상 (DEFAULT_MAIN_CHART_STYLE)
  • + *
  • 멀티차트 동기화 옵션 (DEFAULT_SYNC)
  • + *
+ */ +@Entity +@Table(name = "gc_app_settings") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcAppSettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "device_id", length = 100, unique = true) + private String deviceId; + + @Column(name = "user_id", unique = true) + private Long userId; + + @Column(name = "default_symbol", length = 50, nullable = false) + @Builder.Default + private String defaultSymbol = "KRW-BTC"; + + @Column(name = "default_timeframe", length = 10, nullable = false) + @Builder.Default + private String defaultTimeframe = "1D"; + + @Column(name = "default_chart_type", length = 20, nullable = false) + @Builder.Default + private String defaultChartType = "candlestick"; + + @Column(name = "default_theme", length = 20, nullable = false) + @Builder.Default + private String defaultTheme = "dark"; + + @Column(name = "default_log_scale", nullable = false) + @Builder.Default + private Boolean defaultLogScale = false; + + @Column(name = "default_layout_id", length = 20, nullable = false) + @Builder.Default + private String defaultLayoutId = "1"; + + /** 차트·UI 시간 표시 IANA 시간대 */ + @Column(name = "display_timezone", length = 64, nullable = false) + @Builder.Default + private String displayTimezone = "Asia/Seoul"; + + /** 캔들 색상 JSON (frontend MainChartStyle 구조) */ + @Column(name = "main_chart_style_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String mainChartStyleJson; + + /** 멀티차트 동기화 옵션 JSON (frontend SyncOptions 구조) */ + @Column(name = "sync_options_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String syncOptionsJson; + + /** 백테스팅 완료 시 결과 팝업 자동 표시 여부 (기본 true) */ + @Column(name = "bt_auto_popup", nullable = false) + @Builder.Default + private Boolean btAutoPopup = true; + + /** 백테스팅 매수/매도 마커에 금액 표시 여부 (기본 true) */ + @Column(name = "bt_show_price", nullable = false) + @Builder.Default + private Boolean btShowPrice = true; + + /** 보조지표 우측 가격축 라벨·설명·금액 하이라이트 (기본 true) */ + @Column(name = "chart_series_price_labels", nullable = false) + @Builder.Default + private Boolean chartSeriesPriceLabels = true; + + /** 차트 하단 거래량 바 표시 (기본 true) */ + @Column(name = "chart_volume_visible", nullable = false) + @Builder.Default + private Boolean chartVolumeVisible = true; + + /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 JSON */ + @Column(name = "chart_legend_options_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String chartLegendOptionsJson; + + /** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */ + @Column(name = "trade_alert_popup", nullable = false) + @Builder.Default + private Boolean tradeAlertPopup = true; + + /** 매매 시그널 알림 사운드 재생 여부 */ + @Column(name = "trade_alert_sound_enabled", nullable = false) + @Builder.Default + private Boolean tradeAlertSoundEnabled = true; + + /** 매매 시그널 알림음 ID (bell, chime, silent 등) */ + @Column(name = "trade_alert_sound", nullable = false, length = 32) + @Builder.Default + private String tradeAlertSound = "bell"; + + /** 알림 팝업 위치: right | left | bottom */ + @Column(name = "trade_alert_popup_position", nullable = false, length = 10) + @Builder.Default + private String tradeAlertPopupPosition = "right"; + + /** 알림 팝업 배치: stack | grid | strip | single */ + @Column(name = "trade_alert_popup_layout", nullable = false, length = 10) + @Builder.Default + private String tradeAlertPopupLayout = "stack"; + + /** 그리드 배치 시 열 개수 (2~4) */ + @Column(name = "trade_alert_popup_grid_cols", nullable = false) + @Builder.Default + private Integer tradeAlertPopupGridCols = 2; + + /** 실시간 전략 체크 마스터 ON/OFF — ON 이면 DB 관심종목 전체가 체크 대상 */ + @Column(name = "live_strategy_check", nullable = false) + @Builder.Default + private Boolean liveStrategyCheck = false; + + /** 관심종목에 공통 적용할 전략 ID */ + @Column(name = "live_strategy_id") + private Long liveStrategyId; + + @Column(name = "live_execution_type", nullable = false, length = 30) + @Builder.Default + private String liveExecutionType = "CANDLE_CLOSE"; + + @Column(name = "live_position_mode", nullable = false, length = 20) + @Builder.Default + private String livePositionMode = "LONG_ONLY"; + + /** 모의투자 마스터 ON/OFF */ + @Column(name = "paper_trading_enabled", nullable = false) + @Builder.Default + private Boolean paperTradingEnabled = true; + + @Column(name = "paper_initial_capital", nullable = false, precision = 20, scale = 2) + @Builder.Default + private BigDecimal paperInitialCapital = BigDecimal.valueOf(10_000_000); + + @Column(name = "paper_fee_rate_pct", nullable = false, precision = 8, scale = 4) + @Builder.Default + private BigDecimal paperFeeRatePct = BigDecimal.valueOf(0.05); + + @Column(name = "paper_slippage_pct", nullable = false, precision = 8, scale = 4) + @Builder.Default + private BigDecimal paperSlippagePct = BigDecimal.ZERO; + + @Column(name = "paper_min_order_krw", nullable = false, precision = 20, scale = 2) + @Builder.Default + private BigDecimal paperMinOrderKrw = BigDecimal.valueOf(5000); + + /** 실시간 전략 시그널 시 자동 모의매매 */ + @Column(name = "paper_auto_trade_enabled", nullable = false) + @Builder.Default + private Boolean paperAutoTradeEnabled = false; + + @Column(name = "paper_auto_trade_budget_pct", nullable = false, precision = 8, scale = 4) + @Builder.Default + private BigDecimal paperAutoTradeBudgetPct = BigDecimal.valueOf(95); + + /** PAPER | LIVE | BOTH — 자동매매 실행 대상 */ + @Column(name = "trading_mode", nullable = false, length = 10) + @Builder.Default + private String tradingMode = "PAPER"; + + /** 실거래(Upbit API) 자동매매 ON */ + @Column(name = "live_auto_trade_enabled", nullable = false) + @Builder.Default + private Boolean liveAutoTradeEnabled = false; + + @Column(name = "upbit_access_key", length = 128) + private String upbitAccessKey; + + @Column(name = "upbit_secret_key", length = 256) + private String upbitSecretKey; + + /** BACKEND_STOMP | UPBIT_DIRECT */ + @Column(name = "chart_realtime_source", nullable = false, length = 20) + @Builder.Default + private String chartRealtimeSource = "BACKEND_STOMP"; + + @Column(name = "live_auto_trade_budget_pct", nullable = false, precision = 8, scale = 4) + @Builder.Default + private BigDecimal liveAutoTradeBudgetPct = BigDecimal.valueOf(95); + + @Column(name = "fcm_push_enabled", nullable = false) + @Builder.Default + private Boolean fcmPushEnabled = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java new file mode 100644 index 0000000..e9ffcbc --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java @@ -0,0 +1,48 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_backtest_result") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcBacktestResult { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "device_id", length = 100) private String deviceId; + @Column(name = "strategy_id") private Long strategyId; + @Column(name = "strategy_name", length = 200) private String strategyName; + @Column(name = "symbol", length = 50) private String symbol; + @Column(name = "timeframe", length = 10) private String timeframe; + @Column(name = "bar_count") private Integer barCount; + @Column(name = "from_time") private Long fromTime; + @Column(name = "to_time") private Long toTime; + + @Column(name = "settings_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) private String settingsJson; + + @Column(name = "signals_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) private String signalsJson; + + @Column(name = "analysis_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) private String analysisJson; + + @Column(name = "total_return", precision = 12, scale = 4) private BigDecimal totalReturn; + @Column(name = "win_rate", precision = 6, scale = 4) private BigDecimal winRate; + @Column(name = "total_trades") private Integer totalTrades; + @Column(name = "max_drawdown", precision = 12, scale = 4) private BigDecimal maxDrawdown; + @Column(name = "sharpe_ratio", precision = 10, scale = 4) private BigDecimal sharpeRatio; + @Column(name = "final_equity", precision = 20, scale = 2) private BigDecimal finalEquity; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java new file mode 100644 index 0000000..1865fcd --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java @@ -0,0 +1,133 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 백테스팅 설정 엔티티. + * Ta4j 의 비용 모델·손절/익절/트레일링 스탑·진입가격·재진입 규칙 등을 저장. + */ +@Entity +@Table(name = "gc_backtest_settings") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcBacktestSettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "device_id", length = 100) + private String deviceId; + + // ── 자본 설정 ────────────────────────────────────────────────────────────── + @Column(name = "initial_capital", nullable = false, precision = 20, scale = 2) + @Builder.Default + private BigDecimal initialCapital = new BigDecimal("10000000.00"); + + // ── 비용 모델 ────────────────────────────────────────────────────────────── + /** LINEAR | ZERO */ + @Column(name = "commission_type", nullable = false, length = 20) + @Builder.Default + private String commissionType = "LINEAR"; + + @Column(name = "commission_rate", nullable = false, precision = 8, scale = 5) + @Builder.Default + private BigDecimal commissionRate = new BigDecimal("0.00150"); + + @Column(name = "slippage_rate", nullable = false, precision = 8, scale = 5) + @Builder.Default + private BigDecimal slippageRate = new BigDecimal("0.00050"); + + // ── 진입/청산 가격 ───────────────────────────────────────────────────────── + /** CLOSE | NEXT_OPEN */ + @Column(name = "entry_price_type", nullable = false, length = 20) + @Builder.Default + private String entryPriceType = "CLOSE"; + + /** CLOSE | NEXT_OPEN */ + @Column(name = "exit_price_type", nullable = false, length = 20) + @Builder.Default + private String exitPriceType = "CLOSE"; + + // ── 포지션 방향 ──────────────────────────────────────────────────────────── + /** LONG | SHORT | BOTH */ + @Column(name = "position_direction", nullable = false, length = 10) + @Builder.Default + private String positionDirection = "LONG"; + + // ── 거래 규모 ────────────────────────────────────────────────────────────── + /** CAPITAL_PCT | FIXED_AMOUNT */ + @Column(name = "trade_size_type", nullable = false, length = 20) + @Builder.Default + private String tradeSizeType = "CAPITAL_PCT"; + + @Column(name = "trade_size_value", nullable = false, precision = 10, scale = 4) + @Builder.Default + private BigDecimal tradeSizeValue = new BigDecimal("100.0000"); + + // ── 손절 ─────────────────────────────────────────────────────────────────── + @Column(name = "stop_loss_enabled", nullable = false) + @Builder.Default + private Boolean stopLossEnabled = false; + + @Column(name = "stop_loss_pct", nullable = false, precision = 6, scale = 3) + @Builder.Default + private BigDecimal stopLossPct = new BigDecimal("2.000"); + + // ── 익절 ─────────────────────────────────────────────────────────────────── + @Column(name = "take_profit_enabled", nullable = false) + @Builder.Default + private Boolean takeProfitEnabled = false; + + @Column(name = "take_profit_pct", nullable = false, precision = 6, scale = 3) + @Builder.Default + private BigDecimal takeProfitPct = new BigDecimal("5.000"); + + // ── 트레일링 스탑 ────────────────────────────────────────────────────────── + @Column(name = "trailing_stop_enabled", nullable = false) + @Builder.Default + private Boolean trailingStopEnabled = false; + + @Column(name = "trailing_stop_pct", nullable = false, precision = 6, scale = 3) + @Builder.Default + private BigDecimal trailingStopPct = new BigDecimal("2.000"); + + // ── 재진입 제어 ──────────────────────────────────────────────────────────── + @Column(name = "reentry_wait_bars", nullable = false) + @Builder.Default + private Integer reentryWaitBars = 0; + + @Column(name = "max_open_trades", nullable = false) + @Builder.Default + private Integer maxOpenTrades = 1; + + // ── 포지션 종속성 모드 ───────────────────────────────────────────────────── + /** LONG_ONLY | SIGNAL_ONLY */ + @Column(name = "position_mode", nullable = false, length = 20) + @Builder.Default + private String positionMode = "LONG_ONLY"; + + // ── 분할 청산 ────────────────────────────────────────────────────────────── + @Column(name = "partial_exit_enabled", nullable = false) + @Builder.Default + private Boolean partialExitEnabled = false; + + @Column(name = "partial_exit_pct", nullable = false, precision = 6, scale = 3) + @Builder.Default + private BigDecimal partialExitPct = new BigDecimal("50.000"); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { createdAt = updatedAt = LocalDateTime.now(); } + + @PreUpdate + protected void onUpdate() { updatedAt = LocalDateTime.now(); } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcChartSlot.java b/backend/src/main/java/com/goldenchart/entity/GcChartSlot.java new file mode 100644 index 0000000..c56e28d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcChartSlot.java @@ -0,0 +1,121 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +/** + * 차트 슬롯 설정. + * 멀티차트 레이아웃 내 각 슬롯의 모든 설정값을 저장. + */ +@Entity +@Table(name = "gc_chart_slot", + uniqueConstraints = @UniqueConstraint(name = "uk_slot_workspace_index", + columnNames = {"workspace_id", "slot_index"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcChartSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workspace_id", nullable = false) + private GcChartWorkspace workspace; + + /** 슬롯 인덱스 (0-based) */ + @Column(name = "slot_index", nullable = false) + private Integer slotIndex; + + /** 종목 코드 (e.g. "KRW-BTC", "AAPL") */ + @Column(name = "symbol", length = 50) + private String symbol; + + /** 타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M) */ + @Column(name = "timeframe", length = 10) + private String timeframe; + + /** 차트 타입 (candlestick/bar/line/area/baseline) */ + @Column(name = "chart_type", length = 20) + @Builder.Default + private String chartType = "candlestick"; + + /** 테마 (dark/light/blue) */ + @Column(name = "theme", length = 20) + @Builder.Default + private String theme = "dark"; + + /** 차트 모드 (chart/trading) */ + @Column(name = "mode", length = 20) + @Builder.Default + private String mode = "chart"; + + /** 로그 스케일 사용 여부 */ + @Column(name = "log_scale") + @Builder.Default + private Boolean logScale = false; + + /** + * 지표 설정 JSON 배열. + * 형식: [{id, type, params, hidden, plotVisibility}] + * frontend IndicatorConfig[] 구조와 1:1 대응 + */ + @Column(name = "indicators_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String indicatorsJson; + + /** + * 드로잉 객체 JSON 배열. + * 형식: [{id, type, points, color, lineWidth, style, text, visible, fibtzSettings}] + * frontend Drawing[] 구조와 1:1 대응 + */ + @Column(name = "drawings_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String drawingsJson; + + /** 드로잉 잠금 여부 */ + @Column(name = "drawings_locked") + @Builder.Default + private Boolean drawingsLocked = false; + + /** 드로잉 표시 여부 */ + @Column(name = "drawings_visible") + @Builder.Default + private Boolean drawingsVisible = true; + + /** + * 보조지표 창(pane) 레이아웃 JSON. + * 형식: [{paneIndex, height}] + */ + @Column(name = "pane_layout_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String paneLayoutJson; + + /** + * 메인 차트 캔들 색상 설정 JSON. + * frontend MainChartStyle 구조 + */ + @Column(name = "main_chart_style_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String mainChartStyleJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcChartWorkspace.java b/backend/src/main/java/com/goldenchart/entity/GcChartWorkspace.java new file mode 100644 index 0000000..39c9381 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcChartWorkspace.java @@ -0,0 +1,64 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 사용자 차트 워크스페이스. + * 멀티차트 레이아웃 및 슬롯 집합을 관리한다. + */ +@Entity +@Table(name = "gc_chart_workspace") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcChartWorkspace { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 소유 사용자 ID (users.id 논리 참조) */ + @Column(name = "user_id") + private Long userId; + + /** 비회원 기기 식별자 */ + @Column(name = "device_id", length = 100) + private String deviceId; + + /** 현재 적용된 레이아웃 ID (frontend layoutTypes.ts 의 LayoutDef.id 와 일치: "1","2v","2h",...) */ + @Column(name = "layout_id", length = 20, nullable = false) + private String layoutId; + + /** 차트 슬롯 설정 목록 (1:N) */ + @OneToMany(mappedBy = "workspace", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("slotIndex ASC") + @Builder.Default + private List slots = new ArrayList<>(); + + /** 차트 간 동기화 옵션 JSON {"symbol":true,"timeframe":false,"crosshair":true} */ + @Column(name = "sync_options_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String syncOptionsJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcFcmToken.java b/backend/src/main/java/com/goldenchart/entity/GcFcmToken.java new file mode 100644 index 0000000..061adc6 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcFcmToken.java @@ -0,0 +1,53 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_fcm_token") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GcFcmToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", nullable = false, length = 64) + private String deviceId; + + @Column(nullable = false, unique = true, length = 512) + private String token; + + @Column(nullable = false) + @Builder.Default + private Boolean active = true; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcHoldings.java b/backend/src/main/java/com/goldenchart/entity/GcHoldings.java new file mode 100644 index 0000000..148e037 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcHoldings.java @@ -0,0 +1,66 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 보유종목. + */ +@Entity +@Table(name = "gc_holdings", + uniqueConstraints = @UniqueConstraint(name = "uk_holdings_user_symbol", + columnNames = {"user_id", "device_id", "symbol"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcHoldings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "symbol", length = 50, nullable = false) + private String symbol; + + @Column(name = "korean_name", length = 100) + private String koreanName; + + @Column(name = "english_name", length = 100) + private String englishName; + + /** 평균 매입가 */ + @Column(name = "avg_price", precision = 30, scale = 8) + private BigDecimal avgPrice; + + /** 보유 수량 */ + @Column(name = "quantity", precision = 30, scale = 8) + private BigDecimal quantity; + + @Column(name = "display_order") + @Builder.Default + private Integer displayOrder = 0; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcIndicatorSettings.java b/backend/src/main/java/com/goldenchart/entity/GcIndicatorSettings.java new file mode 100644 index 0000000..1898293 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcIndicatorSettings.java @@ -0,0 +1,85 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +/** + * 장치/사용자별 전역 지표 파라미터 설정. + * + * params_json 구조 예시: + * { + * "RSI": {"length": 9, "src": "close"}, + * "MACD": {"fastLength": 12, "slowLength": 26, "signalLength": 9, "src": "close"}, + * "BollingerBands": {"length": 20, "mult": 2.0, "src": "close"}, + * "Stochastic": {"kLength": 14, "smooth": 3, "dSmoothing": 3}, + * "IchimokuCloud": {"conversionPeriods": 9, "basePeriods": 26, + * "laggingSpan2Periods": 52, "displacement": 26}, + * ... + * } + * + * 프론트엔드 indicatorRegistry.ts 의 defaultParams 를 완전히 대체한다. + */ +@Entity +@Table(name = "gc_indicator_settings") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcIndicatorSettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 비회원 기기 식별자 (X-Device-Id 헤더) */ + @Column(name = "device_id", length = 100, unique = true) + private String deviceId; + + /** 회원 ID (향후 확장) */ + @Column(name = "user_id", unique = true) + private Long userId; + + /** + * 지표 타입 → 파라미터 맵의 JSON 직렬화. + * Map<String, Map<String, Object>> 형태로 역직렬화한다. + */ + @Column(name = "params_json", columnDefinition = "JSON", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + @Builder.Default + private String paramsJson = "{}"; + + /** + * 지표 타입 → 시각 설정(색상·선굵기·수평선) JSON 직렬화. + * Map<String, IndicatorVisual> 형태로 역직렬화한다. + *
+     * {
+     *   "RSI": {
+     *     "plots":  [{"id":"plot0","color":"#7E57C2","lineWidth":2,"type":"line"}],
+     *     "hlines": [{"price":70,"color":"#EF5350","visible":true}]
+     *   }
+     * }
+     * 
+ */ + @Column(name = "visual_config_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + @Builder.Default + private String visualConfigJson = "{}"; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java b/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java new file mode 100644 index 0000000..8928836 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcLiveStrategySettings.java @@ -0,0 +1,75 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 디바이스/유저 × 마켓별 실시간 전략 체크 설정. + * + *
    + *
  • isLiveCheck — 실시간 체크 ON/OFF
  • + *
  • executionType — CANDLE_CLOSE | REALTIME_TICK
  • + *
  • strategyId — 연결된 gc_strategy.id (null 이면 체크 비활성)
  • + *
+ */ +@Entity +@Table(name = "gc_live_strategy_settings") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcLiveStrategySettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "market", nullable = false, length = 30) + @Builder.Default + private String market = "KRW-BTC"; + + /** 연결된 전략 ID (null = 미연결) */ + @Column(name = "strategy_id") + private Long strategyId; + + @Column(name = "is_live_check", nullable = false) + @Builder.Default + private Boolean isLiveCheck = false; + + /** CANDLE_CLOSE | REALTIME_TICK */ + @Column(name = "execution_type", nullable = false, length = 30) + @Builder.Default + private String executionType = "CANDLE_CLOSE"; + + /** 전략 평가·데이터 수집 분봉 (1m, 3m, 5m, …) */ + @Column(name = "candle_type", nullable = false, length = 10) + @Builder.Default + private String candleType = "1m"; + + /** 매도 시그널 포지션 종속성 모드: LONG_ONLY | SIGNAL_ONLY */ + @Column(name = "position_mode", nullable = false, length = 20) + @Builder.Default + private String positionMode = "LONG_ONLY"; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcLiveTrade.java b/backend/src/main/java/com/goldenchart/entity/GcLiveTrade.java new file mode 100644 index 0000000..7443d5d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcLiveTrade.java @@ -0,0 +1,67 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_live_trade") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcLiveTrade { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "device_id", nullable = false, length = 100) + private String deviceId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "symbol", nullable = false, length = 30) + private String symbol; + + @Column(name = "side", nullable = false, length = 8) + private String side; + + @Column(name = "order_kind", nullable = false, length = 16) + @Builder.Default + private String orderKind = "market"; + + @Column(name = "source", nullable = false, length = 32) + @Builder.Default + private String source = "STRATEGY"; + + @Column(name = "strategy_id") + private Long strategyId; + + @Column(name = "upbit_order_uuid", length = 64) + private String upbitOrderUuid; + + @Column(name = "price", nullable = false, precision = 20, scale = 8) + private BigDecimal price; + + @Column(name = "quantity", nullable = false, precision = 24, scale = 12) + private BigDecimal quantity; + + @Column(name = "gross_amount", nullable = false, precision = 20, scale = 2) + private BigDecimal grossAmount; + + @Column(name = "fee_amount", precision = 20, scale = 2) + private BigDecimal feeAmount; + + @Column(name = "state", nullable = false, length = 24) + @Builder.Default + private String state = "done"; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java b/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java new file mode 100644 index 0000000..1abd688 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java @@ -0,0 +1,48 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_account") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperAccount { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", length = 100, nullable = false, unique = true) + private String deviceId; + + @Column(name = "cash_balance", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal cashBalance = BigDecimal.valueOf(10_000_000); + + @Column(name = "realized_pnl", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal realizedPnl = BigDecimal.ZERO; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperPosition.java b/backend/src/main/java/com/goldenchart/entity/GcPaperPosition.java new file mode 100644 index 0000000..6eccaf1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperPosition.java @@ -0,0 +1,53 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_position", + uniqueConstraints = @UniqueConstraint(name = "uk_paper_position_account_symbol", + columnNames = {"account_id", "symbol"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperPosition { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "symbol", length = 50, nullable = false) + private String symbol; + + @Column(name = "korean_name", length = 100) + private String koreanName; + + @Column(name = "quantity", precision = 30, scale = 12, nullable = false) + @Builder.Default + private BigDecimal quantity = BigDecimal.ZERO; + + @Column(name = "avg_price", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal avgPrice = BigDecimal.ZERO; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java b/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java new file mode 100644 index 0000000..666fb79 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java @@ -0,0 +1,64 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_trade") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperTrade { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "symbol", length = 50, nullable = false) + private String symbol; + + @Column(name = "side", length = 10, nullable = false) + private String side; + + @Column(name = "order_kind", length = 20, nullable = false) + @Builder.Default + private String orderKind = "limit"; + + @Column(name = "source", length = 20, nullable = false) + @Builder.Default + private String source = "MANUAL"; + + @Column(name = "strategy_id") + private Long strategyId; + + @Column(name = "price", precision = 20, scale = 2, nullable = false) + private BigDecimal price; + + @Column(name = "quantity", precision = 30, scale = 12, nullable = false) + private BigDecimal quantity; + + @Column(name = "gross_amount", precision = 20, scale = 2, nullable = false) + private BigDecimal grossAmount; + + @Column(name = "fee_amount", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal feeAmount = BigDecimal.ZERO; + + @Column(name = "net_amount", precision = 20, scale = 2, nullable = false) + private BigDecimal netAmount; + + @Column(name = "cash_after", precision = 20, scale = 2, nullable = false) + private BigDecimal cashAfter; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcRoleMenuPermission.java b/backend/src/main/java/com/goldenchart/entity/GcRoleMenuPermission.java new file mode 100644 index 0000000..97a18bd --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcRoleMenuPermission.java @@ -0,0 +1,24 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "gc_role_menu_permission", + uniqueConstraints = @UniqueConstraint(columnNames = {"role", "menu_id"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcRoleMenuPermission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 20) + private String role; + + @Column(name = "menu_id", nullable = false, length = 50) + private String menuId; + + @Column(nullable = false) + private Boolean allowed = true; +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcStrategy.java b/backend/src/main/java/com/goldenchart/entity/GcStrategy.java new file mode 100644 index 0000000..a672f22 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcStrategy.java @@ -0,0 +1,67 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +/** + * GoldenChart 투자전략 DSL 엔티티. + * frontend StrategyPage 에서 작성한 LogicNode 트리를 JSON 으로 저장. + */ +@Entity +@Table(name = "gc_strategy") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcStrategy { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 로그인 사용자 ID (nullable — 비회원은 device_id 사용) */ + @Column(name = "user_id") + private Long userId; + + /** 비회원 기기 식별자 */ + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + /** 매수 조건 DSL JSON — frontend LogicNode 구조 */ + @Column(name = "buy_condition_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String buyConditionJson; + + /** 매도 조건 DSL JSON — frontend LogicNode 구조 */ + @Column(name = "sell_condition_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String sellConditionJson; + + @Column(name = "enabled", nullable = false) + @Builder.Default + private Boolean enabled = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcTradeSignal.java b/backend/src/main/java/com/goldenchart/entity/GcTradeSignal.java new file mode 100644 index 0000000..4786fb4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcTradeSignal.java @@ -0,0 +1,59 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 실시간 전략 체크에서 발생한 매매 시그널 이력. + */ +@Entity +@Table(name = "gc_trade_signal") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcTradeSignal { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "market", nullable = false, length = 30) + private String market; + + @Column(name = "strategy_id") + private Long strategyId; + + @Column(name = "strategy_name", length = 200) + private String strategyName; + + /** BUY | SELL */ + @Column(name = "signal_type", nullable = false, length = 10) + private String signalType; + + @Column(name = "price", nullable = false) + private Double price; + + /** 캔들 시작 Unix 초 */ + @Column(name = "candle_time", nullable = false) + private Long candleTime; + + @Column(name = "candle_type", nullable = false, length = 10) + @Builder.Default + private String candleType = "1m"; + + /** CANDLE_CLOSE | REALTIME_TICK */ + @Column(name = "execution_type", nullable = false, length = 30) + private String executionType; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { createdAt = LocalDateTime.now(); } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcUser.java b/backend/src/main/java/com/goldenchart/entity/GcUser.java new file mode 100644 index 0000000..41b84c6 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcUser.java @@ -0,0 +1,50 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_user") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(name = "password_hash", nullable = false, length = 100) + private String passwordHash; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(nullable = false) + private Boolean enabled = true; + + /** ADMIN | USER | GUEST */ + @Column(nullable = false, length = 20) + private String role = "USER"; + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) createdAt = now; + updatedAt = now; + } + + @PreUpdate + void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcWatchlist.java b/backend/src/main/java/com/goldenchart/entity/GcWatchlist.java new file mode 100644 index 0000000..09a83ab --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcWatchlist.java @@ -0,0 +1,49 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 관심종목 (워치리스트). + * 기존 crypto_favorites 와 별도로 GoldenChart 전용 관리. + */ +@Entity +@Table(name = "gc_watchlist", + uniqueConstraints = @UniqueConstraint(name = "uk_watchlist_user_symbol", + columnNames = {"user_id", "device_id", "symbol"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcWatchlist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "device_id", length = 100) + private String deviceId; + + @Column(name = "symbol", length = 50, nullable = false) + private String symbol; + + @Column(name = "korean_name", length = 100) + private String koreanName; + + @Column(name = "english_name", length = 100) + private String englishName; + + @Column(name = "display_order") + @Builder.Default + private Integer displayOrder = 0; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java new file mode 100644 index 0000000..3a4c32b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcAppSettingsRepository.java @@ -0,0 +1,15 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcAppSettings; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface GcAppSettingsRepository extends JpaRepository { + + Optional findByDeviceId(String deviceId); + + Optional findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcBacktestResultRepository.java b/backend/src/main/java/com/goldenchart/repository/GcBacktestResultRepository.java new file mode 100644 index 0000000..8301dc2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcBacktestResultRepository.java @@ -0,0 +1,12 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcBacktestResult; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GcBacktestResultRepository extends JpaRepository { + List findByDeviceIdOrderByCreatedAtDesc(String deviceId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java new file mode 100644 index 0000000..0b116f1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java @@ -0,0 +1,10 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcBacktestSettings; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GcBacktestSettingsRepository extends JpaRepository { + Optional findFirstByDeviceIdOrderByUpdatedAtDesc(String deviceId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcChartWorkspaceRepository.java b/backend/src/main/java/com/goldenchart/repository/GcChartWorkspaceRepository.java new file mode 100644 index 0000000..00d68d3 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcChartWorkspaceRepository.java @@ -0,0 +1,20 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcChartWorkspace; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface GcChartWorkspaceRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + Optional findByDeviceId(String deviceId); + + @Query("SELECT w FROM GcChartWorkspace w LEFT JOIN FETCH w.slots WHERE w.userId = :userId") + Optional findWithSlotsByUserId(Long userId); + + @Query("SELECT w FROM GcChartWorkspace w LEFT JOIN FETCH w.slots WHERE w.deviceId = :deviceId") + Optional findWithSlotsByDeviceId(String deviceId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcFcmTokenRepository.java b/backend/src/main/java/com/goldenchart/repository/GcFcmTokenRepository.java new file mode 100644 index 0000000..2f85ee4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcFcmTokenRepository.java @@ -0,0 +1,18 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcFcmToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GcFcmTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + List findByActiveTrue(); + + List findByDeviceIdAndActiveTrue(String deviceId); + + void deleteByDeviceId(String deviceId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcHoldingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcHoldingsRepository.java new file mode 100644 index 0000000..fd77b5e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcHoldingsRepository.java @@ -0,0 +1,13 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcHoldings; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcHoldingsRepository extends JpaRepository { + List findByUserIdOrderByDisplayOrderAsc(Long userId); + List findByDeviceIdOrderByDisplayOrderAsc(String deviceId); + void deleteByUserIdAndSymbol(Long userId, String symbol); + void deleteByDeviceIdAndSymbol(String deviceId, String symbol); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcIndicatorSettingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcIndicatorSettingsRepository.java new file mode 100644 index 0000000..e0b7681 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcIndicatorSettingsRepository.java @@ -0,0 +1,15 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcIndicatorSettings; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface GcIndicatorSettingsRepository extends JpaRepository { + + Optional findByDeviceId(String deviceId); + + Optional findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcLiveStrategySettingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcLiveStrategySettingsRepository.java new file mode 100644 index 0000000..e0fd2cd --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcLiveStrategySettingsRepository.java @@ -0,0 +1,30 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcLiveStrategySettings; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface GcLiveStrategySettingsRepository + extends JpaRepository { + + Optional findByUserIdAndMarket(Long userId, String market); + + Optional findByDeviceIdAndMarket(String deviceId, String market); + + /** isLiveCheck = true 인 모든 설정 (스케줄러 순회용) */ + List findAllByIsLiveCheckTrue(); + + /** 디바이스별 실시간 체크 ON 설정 */ + List findByDeviceIdAndIsLiveCheckTrue(String deviceId); + + /** 유저별 실시간 체크 ON 설정 */ + List findByUserIdAndIsLiveCheckTrue(Long userId); + + /** 특정 마켓의 활성 설정 전체 (여러 디바이스가 동일 마켓 구독 시) */ + @Query("SELECT s FROM GcLiveStrategySettings s WHERE s.market = :market AND s.isLiveCheck = true") + List findActiveByMarket(@Param("market") String market); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcLiveTradeRepository.java b/backend/src/main/java/com/goldenchart/repository/GcLiveTradeRepository.java new file mode 100644 index 0000000..031f1a9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcLiveTradeRepository.java @@ -0,0 +1,14 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcLiveTrade; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GcLiveTradeRepository extends JpaRepository { + List findTop100ByDeviceIdOrderByCreatedAtDesc(String deviceId); + Optional findTopByDeviceIdAndSymbolAndSideOrderByCreatedAtDesc( + String deviceId, String symbol, String side); + List findBySymbolAndSideOrderByCreatedAtDesc(String symbol, String side); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperAccountRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperAccountRepository.java new file mode 100644 index 0000000..8e5bec9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperAccountRepository.java @@ -0,0 +1,11 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperAccount; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GcPaperAccountRepository extends JpaRepository { + Optional findByDeviceId(String deviceId); + Optional findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperPositionRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperPositionRepository.java new file mode 100644 index 0000000..9f32916 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperPositionRepository.java @@ -0,0 +1,14 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperPosition; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GcPaperPositionRepository extends JpaRepository { + List findByAccountIdOrderBySymbolAsc(Long accountId); + Optional findByAccountIdAndSymbol(Long accountId, String symbol); + /** 실시간 손절/익절 — 종목별 보유 포지션 전체 */ + List findBySymbolAndQuantityGreaterThan(String symbol, java.math.BigDecimal minQty); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java new file mode 100644 index 0000000..ebef318 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java @@ -0,0 +1,10 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperTrade; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcPaperTradeRepository extends JpaRepository { + List findTop100ByAccountIdOrderByCreatedAtDesc(Long accountId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcRoleMenuPermissionRepository.java b/backend/src/main/java/com/goldenchart/repository/GcRoleMenuPermissionRepository.java new file mode 100644 index 0000000..2cedfe1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcRoleMenuPermissionRepository.java @@ -0,0 +1,11 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcRoleMenuPermission; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcRoleMenuPermissionRepository extends JpaRepository { + List findByRole(String role); + void deleteByRole(String role); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcStrategyRepository.java b/backend/src/main/java/com/goldenchart/repository/GcStrategyRepository.java new file mode 100644 index 0000000..c20b369 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcStrategyRepository.java @@ -0,0 +1,15 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcStrategy; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcStrategyRepository extends JpaRepository { + + /** 사용자 ID 기준 전략 목록 조회 */ + List findByUserIdOrderByUpdatedAtDesc(Long userId); + + /** 기기 ID 기준 전략 목록 조회 (비회원) */ + List findByDeviceIdOrderByUpdatedAtDesc(String deviceId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java new file mode 100644 index 0000000..0b291ac --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java @@ -0,0 +1,13 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcTradeSignal; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcTradeSignalRepository extends JpaRepository { + + List findByDeviceIdOrderByCreatedAtDesc(String deviceId); + List findByUserIdOrderByCreatedAtDesc(Long userId); + List findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcUserRepository.java b/backend/src/main/java/com/goldenchart/repository/GcUserRepository.java new file mode 100644 index 0000000..aea716b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcUserRepository.java @@ -0,0 +1,10 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcUser; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GcUserRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcWatchlistRepository.java b/backend/src/main/java/com/goldenchart/repository/GcWatchlistRepository.java new file mode 100644 index 0000000..60ababb --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcWatchlistRepository.java @@ -0,0 +1,13 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcWatchlist; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcWatchlistRepository extends JpaRepository { + List findByUserIdOrderByDisplayOrderAsc(Long userId); + List findByDeviceIdOrderByDisplayOrderAsc(String deviceId); + void deleteByUserIdAndSymbol(Long userId, String symbol); + void deleteByDeviceIdAndSymbol(String deviceId, String symbol); +} diff --git a/backend/src/main/java/com/goldenchart/security/SecretCryptoService.java b/backend/src/main/java/com/goldenchart/security/SecretCryptoService.java new file mode 100644 index 0000000..da142b9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/security/SecretCryptoService.java @@ -0,0 +1,112 @@ +package com.goldenchart.security; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * 업비트 API 키 등 민감 문자열 DB 저장용 AES-256-GCM 암호화. + * 평문은 API 응답·로그에 노출하지 않는다. + */ +@Service +@Slf4j +public class SecretCryptoService { + + private static final String PREFIX = "ENC:v1:"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + + @Value("${goldenchart.secrets.encryption-key:}") + private String encryptionKeyConfig; + + private SecretKey secretKey; + private final SecureRandom secureRandom = new SecureRandom(); + + @PostConstruct + void init() { + String raw = encryptionKeyConfig != null ? encryptionKeyConfig.trim() : ""; + if (raw.isEmpty()) { + raw = "goldenchart-dev-change-in-production"; + log.warn("[SecretCrypto] GC_SECRETS_ENCRYPTION_KEY 미설정 — 개발용 기본 키 사용 (운영 환경에서는 반드시 설정)"); + } + try { + byte[] keyBytes = MessageDigest.getInstance("SHA-256") + .digest(raw.getBytes(StandardCharsets.UTF_8)); + this.secretKey = new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + throw new IllegalStateException("암호화 키 초기화 실패", e); + } + } + + public boolean isEncrypted(String value) { + return value != null && value.startsWith(PREFIX); + } + + /** DB 저장용 암호화 (이미 암호문이면 그대로 반환) */ + public String encrypt(String plaintext) { + if (plaintext == null || plaintext.isBlank()) { + return plaintext; + } + if (isEncrypted(plaintext)) { + return plaintext; + } + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buf = ByteBuffer.allocate(iv.length + encrypted.length); + buf.put(iv); + buf.put(encrypted); + return PREFIX + Base64.getEncoder().encodeToString(buf.array()); + } catch (Exception e) { + throw new IllegalStateException("민감 정보 암호화 실패", e); + } + } + + /** DB에서 읽은 값 복호화 (레거시 평문은 그대로 반환) */ + public String decrypt(String stored) { + if (stored == null || stored.isBlank()) { + return stored; + } + if (!isEncrypted(stored)) { + return stored; + } + try { + String payload = stored.substring(PREFIX.length()); + byte[] decoded = Base64.getDecoder().decode(payload); + ByteBuffer buf = ByteBuffer.wrap(decoded); + byte[] iv = new byte[GCM_IV_LENGTH]; + buf.get(iv); + byte[] ciphertext = new byte[buf.remaining()]; + buf.get(ciphertext); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] plain = cipher.doFinal(ciphertext); + return new String(plain, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("[SecretCrypto] 복호화 실패 — 키 손상 또는 encryption-key 불일치 가능"); + throw new IllegalStateException("민감 정보 복호화 실패", e); + } + } + + /** API 응답용 — 마지막 4자만 노출 */ + public static String maskForDisplay(String plain) { + if (plain == null || plain.length() < 4) { + return null; + } + return "····" + plain.substring(plain.length() - 4); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/AdminService.java b/backend/src/main/java/com/goldenchart/service/AdminService.java new file mode 100644 index 0000000..2701239 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/AdminService.java @@ -0,0 +1,126 @@ +package com.goldenchart.service; + +import com.goldenchart.auth.UserRole; +import com.goldenchart.dto.*; +import com.goldenchart.entity.GcUser; +import com.goldenchart.repository.GcUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final GcUserRepository userRepo; + private final PasswordEncoder passwordEncoder; + private final RolePermissionService rolePermissionService; + + @Transactional(readOnly = true) + public void verifyAdminPassword(Long userId, String password) { + rolePermissionService.requireAdmin(userId); + if (password == null || password.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호를 입력하세요."); + } + GcUser user = userRepo.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "사용자를 찾을 수 없습니다.")); + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "관리자 비밀번호가 올바르지 않습니다."); + } + } + + @Transactional(readOnly = true) + public List listUsers() { + return userRepo.findAll().stream().map(this::toDto).toList(); + } + + @Transactional + public UserDto createUser(CreateUserRequest req) { + if (req.getUsername() == null || req.getUsername().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "아이디를 입력하세요."); + } + if (req.getPassword() == null || req.getPassword().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호를 입력하세요."); + } + String username = req.getUsername().trim(); + if (userRepo.findByUsername(username).isPresent()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다."); + } + String role = UserRole.fromString(req.getRole() != null ? req.getRole() : UserRole.USER.name()).name(); + GcUser user = GcUser.builder() + .username(username) + .passwordHash(passwordEncoder.encode(req.getPassword())) + .displayName(req.getDisplayName() != null ? req.getDisplayName().trim() : username) + .role(role) + .enabled(req.getEnabled() == null || req.getEnabled()) + .build(); + return toDto(userRepo.save(user)); + } + + @Transactional + public UserDto updateUser(Long id, UpdateUserRequest req, Long actorId) { + GcUser user = userRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + if (req.getDisplayName() != null) { + user.setDisplayName(req.getDisplayName().trim()); + } + if (req.getRole() != null) { + user.setRole(UserRole.fromString(req.getRole()).name()); + } + if (req.getEnabled() != null) { + user.setEnabled(req.getEnabled()); + } + if (req.getPassword() != null && !req.getPassword().isBlank()) { + user.setPasswordHash(passwordEncoder.encode(req.getPassword())); + } + // 마지막 ADMIN 비활성화/강등 방지 + if (UserRole.ADMIN.name().equalsIgnoreCase(user.getRole()) + && (!Boolean.TRUE.equals(user.getEnabled()) + || (req.getRole() != null && !UserRole.ADMIN.name().equalsIgnoreCase(req.getRole())))) { + long adminCount = userRepo.findAll().stream() + .filter(u -> UserRole.ADMIN.name().equalsIgnoreCase(u.getRole()) + && Boolean.TRUE.equals(u.getEnabled()) + && !u.getId().equals(id)) + .count(); + if (adminCount == 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "최소 한 명의 활성 관리자가 필요합니다."); + } + } + return toDto(userRepo.save(user)); + } + + @Transactional + public void deleteUser(Long id, Long actorId) { + if (id.equals(actorId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "본인 계정은 삭제할 수 없습니다."); + } + GcUser user = userRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + if (UserRole.ADMIN.name().equalsIgnoreCase(user.getRole())) { + long adminCount = userRepo.findAll().stream() + .filter(u -> UserRole.ADMIN.name().equalsIgnoreCase(u.getRole()) + && Boolean.TRUE.equals(u.getEnabled())) + .count(); + if (adminCount <= 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "최소 한 명의 관리자가 필요합니다."); + } + } + userRepo.delete(user); + } + + private UserDto toDto(GcUser u) { + return UserDto.builder() + .id(u.getId()) + .username(u.getUsername()) + .displayName(u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()) + .role(u.getRole() != null ? u.getRole() : UserRole.USER.name()) + .enabled(u.getEnabled()) + .createdAt(u.getCreatedAt() != null ? u.getCreatedAt().toString() : null) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java new file mode 100644 index 0000000..6f7096e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -0,0 +1,263 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.UpbitApiCredentials; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.repository.GcAppSettingsRepository; +import com.goldenchart.security.SecretCryptoService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 앱 전역 차트 기본 설정 서비스. + * + *

프론트엔드에서 하드코딩된 기본값들(DEFAULT_STATE, DEFAULT_MAIN_CHART_STYLE, + * DEFAULT_SYNC 등)을 DB 로 대체하여 사용자별 맞춤 기본값을 관리한다.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class AppSettingsService { + + private final GcAppSettingsRepository repo; + private final ObjectMapper mapper; + private final SecretCryptoService secretCrypto; + + // ── 공개 API ───────────────────────────────────────────────────────────── + + /** + * 현재 설정값을 Map 으로 반환. + * DB 에 없으면 엔티티 기본값이 반영된 빈 엔티티를 기반으로 Map 생성. + */ + @Transactional(readOnly = true) + public Map get(Long userId, String deviceId) { + GcAppSettings s = findEntity(userId, deviceId) + .orElse(new GcAppSettings()); + return toMap(s); + } + + /** + * 설정값을 Map 으로 받아 저장. + * DB 에 레코드가 없으면 신규 생성. + */ + public Map save(Long userId, String deviceId, + Map data) { + GcAppSettings s = findOrCreate(userId, deviceId); + apply(s, data); + repo.save(s); + log.debug("[AppSettings] saved for device={}", deviceId); + return toMap(s); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private Optional findEntity(Long userId, String deviceId) { + if (userId != null) return repo.findByUserId(userId); + if (deviceId != null && !deviceId.isBlank()) return repo.findByDeviceId(deviceId); + return Optional.empty(); + } + + private GcAppSettings findOrCreate(Long userId, String deviceId) { + return findEntity(userId, deviceId) + .orElseGet(() -> GcAppSettings.builder() + .userId(userId) + .deviceId(deviceId) + .build()); + } + + private void apply(GcAppSettings s, Map d) { + if (d.containsKey("defaultSymbol")) s.setDefaultSymbol((String) d.get("defaultSymbol")); + if (d.containsKey("defaultTimeframe")) s.setDefaultTimeframe((String) d.get("defaultTimeframe")); + if (d.containsKey("defaultChartType")) s.setDefaultChartType((String) d.get("defaultChartType")); + if (d.containsKey("defaultTheme")) s.setDefaultTheme((String) d.get("defaultTheme")); + if (d.containsKey("defaultLogScale")) s.setDefaultLogScale( + Boolean.parseBoolean(d.get("defaultLogScale").toString())); + if (d.containsKey("defaultLayoutId")) s.setDefaultLayoutId((String) d.get("defaultLayoutId")); + if (d.containsKey("displayTimezone")) s.setDisplayTimezone((String) d.get("displayTimezone")); + if (d.containsKey("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle"))); + if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions"))); + if (d.containsKey("btAutoPopup")) s.setBtAutoPopup( + Boolean.parseBoolean(d.get("btAutoPopup").toString())); + if (d.containsKey("btShowPrice")) s.setBtShowPrice( + Boolean.parseBoolean(d.get("btShowPrice").toString())); + if (d.containsKey("chartSeriesPriceLabels")) s.setChartSeriesPriceLabels( + Boolean.parseBoolean(d.get("chartSeriesPriceLabels").toString())); + if (d.containsKey("chartVolumeVisible")) s.setChartVolumeVisible( + Boolean.parseBoolean(d.get("chartVolumeVisible").toString())); + if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions"))); + if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup( + Boolean.parseBoolean(d.get("tradeAlertPopup").toString())); + if (d.containsKey("tradeAlertSoundEnabled")) s.setTradeAlertSoundEnabled( + Boolean.parseBoolean(d.get("tradeAlertSoundEnabled").toString())); + if (d.containsKey("tradeAlertSound")) s.setTradeAlertSound( + d.get("tradeAlertSound").toString()); + if (d.containsKey("tradeAlertPopupPosition")) { + String pos = d.get("tradeAlertPopupPosition").toString().toLowerCase(); + s.setTradeAlertPopupPosition("left".equals(pos) || "bottom".equals(pos) ? pos : "right"); + } + if (d.containsKey("tradeAlertPopupLayout")) { + String lay = d.get("tradeAlertPopupLayout").toString().toLowerCase(); + s.setTradeAlertPopupLayout( + "grid".equals(lay) || "strip".equals(lay) || "single".equals(lay) ? lay : "stack"); + } + if (d.containsKey("tradeAlertPopupGridCols")) { + int cols = Integer.parseInt(d.get("tradeAlertPopupGridCols").toString()); + s.setTradeAlertPopupGridCols(Math.min(4, Math.max(2, cols))); + } + if (d.containsKey("liveStrategyCheck")) s.setLiveStrategyCheck( + Boolean.parseBoolean(d.get("liveStrategyCheck").toString())); + if (d.containsKey("liveStrategyId")) { + Object v = d.get("liveStrategyId"); + s.setLiveStrategyId(v == null || "".equals(v.toString()) ? null : Long.parseLong(v.toString())); + } + if (d.containsKey("liveExecutionType")) s.setLiveExecutionType( + "REALTIME_TICK".equals(d.get("liveExecutionType")) ? "REALTIME_TICK" : "CANDLE_CLOSE"); + if (d.containsKey("livePositionMode")) s.setLivePositionMode( + "SIGNAL_ONLY".equals(d.get("livePositionMode")) ? "SIGNAL_ONLY" : "LONG_ONLY"); + if (d.containsKey("paperTradingEnabled")) s.setPaperTradingEnabled( + Boolean.parseBoolean(d.get("paperTradingEnabled").toString())); + if (d.containsKey("paperInitialCapital")) s.setPaperInitialCapital( + new java.math.BigDecimal(d.get("paperInitialCapital").toString())); + if (d.containsKey("paperFeeRatePct")) s.setPaperFeeRatePct( + new java.math.BigDecimal(d.get("paperFeeRatePct").toString())); + if (d.containsKey("paperSlippagePct")) s.setPaperSlippagePct( + new java.math.BigDecimal(d.get("paperSlippagePct").toString())); + if (d.containsKey("paperMinOrderKrw")) s.setPaperMinOrderKrw( + new java.math.BigDecimal(d.get("paperMinOrderKrw").toString())); + if (d.containsKey("paperAutoTradeEnabled")) s.setPaperAutoTradeEnabled( + Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString())); + if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct( + new java.math.BigDecimal(d.get("paperAutoTradeBudgetPct").toString())); + if (d.containsKey("tradingMode")) { + String mode = d.get("tradingMode").toString().toUpperCase(); + s.setTradingMode("LIVE".equals(mode) || "BOTH".equals(mode) ? mode : "PAPER"); + } + if (d.containsKey("liveAutoTradeEnabled")) s.setLiveAutoTradeEnabled( + Boolean.parseBoolean(d.get("liveAutoTradeEnabled").toString())); + if (d.containsKey("upbitAccessKey")) { + String v = d.get("upbitAccessKey").toString().trim(); + if (!v.isEmpty() && !v.startsWith("····") && !v.startsWith("****")) { + s.setUpbitAccessKey(secretCrypto.encrypt(v)); + } + } + if (d.containsKey("upbitSecretKey")) { + String v = d.get("upbitSecretKey").toString().trim(); + if (!v.isEmpty() && !"__UNCHANGED__".equals(v)) { + s.setUpbitSecretKey(secretCrypto.encrypt(v)); + } + } + if (d.containsKey("chartRealtimeSource")) { + String src = d.get("chartRealtimeSource").toString().toUpperCase(); + s.setChartRealtimeSource("UPBIT_DIRECT".equals(src) ? "UPBIT_DIRECT" : "BACKEND_STOMP"); + } + if (d.containsKey("liveAutoTradeBudgetPct")) s.setLiveAutoTradeBudgetPct( + new java.math.BigDecimal(d.get("liveAutoTradeBudgetPct").toString())); + if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled( + Boolean.parseBoolean(d.get("fcmPushEnabled").toString())); + } + + private Map toMap(GcAppSettings s) { + Map m = new HashMap<>(); + m.put("defaultSymbol", s.getDefaultSymbol() != null ? s.getDefaultSymbol() : "KRW-BTC"); + m.put("defaultTimeframe", s.getDefaultTimeframe() != null ? s.getDefaultTimeframe() : "1D"); + m.put("defaultChartType", s.getDefaultChartType() != null ? s.getDefaultChartType() : "candlestick"); + m.put("defaultTheme", s.getDefaultTheme() != null ? s.getDefaultTheme() : "dark"); + m.put("defaultLogScale", s.getDefaultLogScale() != null ? s.getDefaultLogScale() : false); + m.put("defaultLayoutId", s.getDefaultLayoutId() != null ? s.getDefaultLayoutId() : "1"); + m.put("displayTimezone", s.getDisplayTimezone() != null ? s.getDisplayTimezone() : "Asia/Seoul"); + m.put("mainChartStyle", parseJson(s.getMainChartStyleJson())); + m.put("syncOptions", parseJson(s.getSyncOptionsJson())); + m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true); + m.put("btShowPrice", s.getBtShowPrice() != null ? s.getBtShowPrice() : true); + m.put("chartSeriesPriceLabels", s.getChartSeriesPriceLabels() != null ? s.getChartSeriesPriceLabels() : true); + m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true); + m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson())); + m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true); + m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true); + m.put("tradeAlertSound", s.getTradeAlertSound() != null ? s.getTradeAlertSound() : "bell"); + m.put("tradeAlertPopupPosition", s.getTradeAlertPopupPosition() != null ? s.getTradeAlertPopupPosition() : "right"); + m.put("tradeAlertPopupLayout", s.getTradeAlertPopupLayout() != null ? s.getTradeAlertPopupLayout() : "stack"); + m.put("tradeAlertPopupGridCols", s.getTradeAlertPopupGridCols() != null ? s.getTradeAlertPopupGridCols() : 2); + m.put("liveStrategyCheck", s.getLiveStrategyCheck() != null ? s.getLiveStrategyCheck() : false); + m.put("liveStrategyId", s.getLiveStrategyId()); + m.put("liveExecutionType", s.getLiveExecutionType() != null ? s.getLiveExecutionType() : "CANDLE_CLOSE"); + m.put("livePositionMode", s.getLivePositionMode() != null ? s.getLivePositionMode() : "LONG_ONLY"); + m.put("paperTradingEnabled", s.getPaperTradingEnabled() != null ? s.getPaperTradingEnabled() : true); + m.put("paperInitialCapital", s.getPaperInitialCapital() != null + ? s.getPaperInitialCapital().doubleValue() : 10_000_000); + m.put("paperFeeRatePct", s.getPaperFeeRatePct() != null ? s.getPaperFeeRatePct().doubleValue() : 0.05); + m.put("paperSlippagePct", s.getPaperSlippagePct() != null ? s.getPaperSlippagePct().doubleValue() : 0); + m.put("paperMinOrderKrw", s.getPaperMinOrderKrw() != null ? s.getPaperMinOrderKrw().doubleValue() : 5000); + m.put("paperAutoTradeEnabled", s.getPaperAutoTradeEnabled() != null ? s.getPaperAutoTradeEnabled() : false); + m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null + ? s.getPaperAutoTradeBudgetPct().doubleValue() : 95); + m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER"); + m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false); + UpbitApiCredentials creds = resolveUpbitCredentials(s); + m.put("upbitAccessKeyMasked", SecretCryptoService.maskForDisplay(creds.accessKey())); + m.put("hasUpbitKeys", creds.isComplete()); + m.put("chartRealtimeSource", s.getChartRealtimeSource() != null ? s.getChartRealtimeSource() : "BACKEND_STOMP"); + m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null + ? s.getLiveAutoTradeBudgetPct().doubleValue() : 95); + m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false); + return m; + } + + /** 업비트 API 키 복호화 (실거래 API 호출 전용, 외부 노출 금지) */ + @Transactional(readOnly = true) + public UpbitApiCredentials resolveUpbitCredentials(GcAppSettings s) { + if (s == null) { + return UpbitApiCredentials.builder().accessKey("").secretKey("").build(); + } + String access = safeDecrypt(s.getUpbitAccessKey()); + String secret = safeDecrypt(s.getUpbitSecretKey()); + return UpbitApiCredentials.builder() + .accessKey(access != null ? access : "") + .secretKey(secret != null ? secret : "") + .build(); + } + + /** encryption-key 불일치 시 API 500 대신 키 미등록으로 처리 */ + private String safeDecrypt(String stored) { + try { + return secretCrypto.decrypt(stored); + } catch (Exception e) { + log.warn("[AppSettings] 업비트 키 복호화 실패 — hasUpbitKeys=false 로 처리: {}", e.getMessage()); + return ""; + } + } + + @Transactional(readOnly = true) + public boolean hasUpbitApiKeys(GcAppSettings s) { + return resolveUpbitCredentials(s).isComplete(); + } + + /** 실시간 전략 전역 템플릿 (관심종목 자동 연동용) */ + @Transactional(readOnly = true) + public GcAppSettings getEntity(Long userId, String deviceId) { + return findEntity(userId, deviceId).orElse(new GcAppSettings()); + } + + private Object parseJson(String json) { + if (json == null || json.isBlank()) return null; + try { return mapper.readValue(json, Object.class); } + catch (Exception e) { return null; } + } + + private String toJson(Object obj) { + if (obj == null) return null; + try { return mapper.writeValueAsString(obj); } + catch (JsonProcessingException e) { + log.warn("[AppSettings] JSON 직렬화 실패", e); + return null; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/AuthService.java b/backend/src/main/java/com/goldenchart/service/AuthService.java new file mode 100644 index 0000000..3405658 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/AuthService.java @@ -0,0 +1,68 @@ +package com.goldenchart.service; + +import com.goldenchart.auth.UserRole; +import com.goldenchart.dto.LoginResponse; +import com.goldenchart.entity.GcUser; +import com.goldenchart.repository.GcUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final GcUserRepository userRepo; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public LoginResponse login(String username, String password) { + if (username == null || username.isBlank() || password == null || password.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "아이디와 비밀번호를 입력하세요."); + } + GcUser user = userRepo.findByUsername(username.trim()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다.")); + if (!Boolean.TRUE.equals(user.getEnabled())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비활성화된 계정입니다."); + } + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다."); + } + return toLoginResponse(user); + } + + @Transactional(readOnly = true) + public Optional me(Long userId) { + if (userId == null) return Optional.empty(); + return userRepo.findById(userId) + .filter(u -> Boolean.TRUE.equals(u.getEnabled())) + .map(this::toLoginResponse); + } + + private LoginResponse toLoginResponse(GcUser user) { + return LoginResponse.builder() + .userId(user.getId()) + .username(user.getUsername()) + .displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()) + .role(user.getRole() != null ? user.getRole() : UserRole.USER.name()) + .build(); + } + + /** 기본 관리자 계정 (admin / admin) — 없으면 생성 */ + @Transactional + public void ensureDefaultAdmin() { + if (userRepo.findByUsername("admin").isPresent()) return; + userRepo.save(GcUser.builder() + .username("admin") + .passwordHash(passwordEncoder.encode("admin")) + .displayName("관리자") + .enabled(true) + .role(UserRole.ADMIN.name()) + .build()); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java new file mode 100644 index 0000000..8a6b9f5 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java @@ -0,0 +1,84 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.BacktestSettingsDto; +import com.goldenchart.entity.GcBacktestSettings; +import com.goldenchart.repository.GcBacktestSettingsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BacktestSettingsService { + + private final GcBacktestSettingsRepository repo; + + /** device_id 기준 설정 조회 — 없으면 기본값 반환 */ + @Transactional(readOnly = true) + public BacktestSettingsDto get(String deviceId) { + return repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId) + .map(this::toDto) + .orElseGet(BacktestSettingsDto::new); + } + + /** upsert — 기존 설정이 있으면 덮어쓰기, 없으면 새 행 삽입 */ + @Transactional + public BacktestSettingsDto save(String deviceId, BacktestSettingsDto dto) { + GcBacktestSettings entity = repo + .findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId) + .orElseGet(GcBacktestSettings::new); + + entity.setDeviceId(deviceId); + entity.setInitialCapital(dto.getInitialCapital()); + entity.setCommissionType(dto.getCommissionType()); + entity.setCommissionRate(dto.getCommissionRate()); + entity.setSlippageRate(dto.getSlippageRate()); + entity.setEntryPriceType(dto.getEntryPriceType()); + entity.setExitPriceType(dto.getExitPriceType()); + entity.setPositionDirection(dto.getPositionDirection()); + entity.setTradeSizeType(dto.getTradeSizeType()); + entity.setTradeSizeValue(dto.getTradeSizeValue()); + entity.setStopLossEnabled(dto.getStopLossEnabled()); + entity.setStopLossPct(dto.getStopLossPct()); + entity.setTakeProfitEnabled(dto.getTakeProfitEnabled()); + entity.setTakeProfitPct(dto.getTakeProfitPct()); + entity.setTrailingStopEnabled(dto.getTrailingStopEnabled()); + entity.setTrailingStopPct(dto.getTrailingStopPct()); + entity.setReentryWaitBars(dto.getReentryWaitBars()); + entity.setMaxOpenTrades(dto.getMaxOpenTrades()); + entity.setPartialExitEnabled(dto.getPartialExitEnabled()); + entity.setPartialExitPct(dto.getPartialExitPct()); + entity.setPositionMode( + "SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"); + + return toDto(repo.save(entity)); + } + + // ── 변환 헬퍼 ───────────────────────────────────────────────────────────── + + private BacktestSettingsDto toDto(GcBacktestSettings e) { + return BacktestSettingsDto.builder() + .id(e.getId()) + .initialCapital(e.getInitialCapital()) + .commissionType(e.getCommissionType()) + .commissionRate(e.getCommissionRate()) + .slippageRate(e.getSlippageRate()) + .entryPriceType(e.getEntryPriceType()) + .exitPriceType(e.getExitPriceType()) + .positionDirection(e.getPositionDirection()) + .tradeSizeType(e.getTradeSizeType()) + .tradeSizeValue(e.getTradeSizeValue()) + .stopLossEnabled(e.getStopLossEnabled()) + .stopLossPct(e.getStopLossPct()) + .takeProfitEnabled(e.getTakeProfitEnabled()) + .takeProfitPct(e.getTakeProfitPct()) + .trailingStopEnabled(e.getTrailingStopEnabled()) + .trailingStopPct(e.getTrailingStopPct()) + .reentryWaitBars(e.getReentryWaitBars()) + .maxOpenTrades(e.getMaxOpenTrades()) + .partialExitEnabled(e.getPartialExitEnabled()) + .partialExitPct(e.getPartialExitPct()) + .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java new file mode 100644 index 0000000..6bca788 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -0,0 +1,558 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.*; +import com.goldenchart.dto.BacktestResponse.Signal; +import com.goldenchart.dto.BacktestResponse.Stats; +import com.goldenchart.entity.GcBacktestResult; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcBacktestResultRepository; +import com.goldenchart.repository.GcStrategyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.*; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.indicators.helpers.ClosePriceIndicator; +import org.ta4j.core.num.Num; +import org.ta4j.core.rules.BooleanRule; +import org.ta4j.core.rules.OrRule; +import org.ta4j.core.rules.StopGainRule; +import org.ta4j.core.rules.StopLossRule; +import org.ta4j.core.rules.TrailingStopLossRule; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +/** + * Ta4j 기반 백테스팅 실행 서비스. + * + *
    + *
  • TradingRecord 를 정상 populate → AnalysisCriterion 전체 활용
  • + *
  • StopLoss / StopGain / TrailingStop / Commission / Slippage 반영
  • + *
  • 실행 결과를 gc_backtest_result 테이블에 저장
  • + *
+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BacktestingService { + + private final StrategyDslToTa4jAdapter adapter; + private final GcStrategyRepository strategyRepository; + private final GcBacktestResultRepository resultRepository; + private final ObjectMapper objectMapper; + + private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto(); + + // ── Public API ──────────────────────────────────────────────────────────── + + public BacktestResponse run(BacktestRequest req) { + if (req.getBars() == null || req.getBars().isEmpty()) { + return emptyResponse("캔들 데이터가 없습니다."); + } + + JsonNode buyDsl = req.getBuyCondition(); + JsonNode sellDsl = req.getSellCondition(); + + String strategyName = req.getStrategyName() != null ? req.getStrategyName() : "전략"; + + if (req.getStrategyId() != null) { + Optional opt = strategyRepository.findById(req.getStrategyId()); + if (opt.isEmpty()) return emptyResponse("전략을 찾을 수 없습니다: id=" + req.getStrategyId()); + GcStrategy strat = opt.get(); + strategyName = strat.getName() != null ? strat.getName() : strategyName; + try { + if (strat.getBuyConditionJson() != null) + buyDsl = objectMapper.readTree(strat.getBuyConditionJson()); + if (strat.getSellConditionJson() != null) + sellDsl = objectMapper.readTree(strat.getSellConditionJson()); + } catch (Exception e) { + log.warn("전략 DSL JSON 파싱 실패: {}", e.getMessage()); + return emptyResponse("전략 DSL 파싱 오류"); + } + } + + Map> params = req.getIndicatorParams() != null + ? req.getIndicatorParams() : Map.of(); + + BacktestSettingsDto cfg = req.getSettings() != null ? req.getSettings() : DEFAULT_SETTINGS; + + BarSeries series = buildSeries(req.getBars(), req.getTimeframe()); + int n = series.getBarCount(); + if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다."); + + Rule entryRule = adapter.toRule(buyDsl, series, params); + Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) + ? adapter.toRule(sellDsl, series, params) + : new BooleanRule(false); + Rule exitRule = buildExitRule(baseExitRule, series, cfg); + + return runBacktest(series, entryRule, exitRule, req, cfg, strategyName); + } + + // ── 청산 규칙 합성 ──────────────────────────────────────────────────────── + + private Rule buildExitRule(Rule baseExit, BarSeries series, BacktestSettingsDto cfg) { + Rule result = baseExit; + ClosePriceIndicator close = new ClosePriceIndicator(series); + + if (Boolean.TRUE.equals(cfg.getStopLossEnabled())) { + double pct = cfg.getStopLossPct() != null ? cfg.getStopLossPct().doubleValue() : 2.0; + result = new OrRule(result, new StopLossRule(close, series.numFactory().numOf(pct))); + } + if (Boolean.TRUE.equals(cfg.getTakeProfitEnabled())) { + double pct = cfg.getTakeProfitPct() != null ? cfg.getTakeProfitPct().doubleValue() : 5.0; + result = new OrRule(result, new StopGainRule(close, series.numFactory().numOf(pct))); + } + if (Boolean.TRUE.equals(cfg.getTrailingStopEnabled())) { + double pct = cfg.getTrailingStopPct() != null ? cfg.getTrailingStopPct().doubleValue() : 2.0; + result = new OrRule(result, new TrailingStopLossRule(close, series.numFactory().numOf(pct))); + } + return result; + } + + // ── 백테스팅 루프 ───────────────────────────────────────────────────────── + + private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule, + BacktestRequest req, BacktestSettingsDto cfg, + String strategyName) { + + BaseTradingRecord record = new BaseTradingRecord(); + + List signals = new ArrayList<>(); + boolean inPosition = false; + double entryPrice = 0; + int entryBarIdx = -1; + int lastExitBar = -1; + int reentryWait = cfg.getReentryWaitBars() != null ? cfg.getReentryWaitBars() : 0; + String direction = cfg.getPositionDirection() != null ? cfg.getPositionDirection() : "LONG"; + // positionMode: LONG_ONLY(기본) | SIGNAL_ONLY(포지션 락 우회) + String posMode = cfg.getPositionMode() != null ? cfg.getPositionMode() : "LONG_ONLY"; + boolean signalOnly = "SIGNAL_ONLY".equals(posMode); + + double initCap = cfg.getInitialCapital() != null ? cfg.getInitialCapital().doubleValue() : 10_000_000.0; + double tradeSizePct = cfg.getTradeSizeValue() != null ? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0; + boolean partialExit = Boolean.TRUE.equals(cfg.getPartialExitEnabled()); + double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5; + boolean partialDone = false; + + double equity = initCap; + + int barCount = series.getBarCount(); + + for (int i = 0; i < barCount; i++) { + double closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType()); + double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); + long time = series.getBar(i).getEndTime().getEpochSecond(); + + // ── SIGNAL_ONLY 모드: 포지션 상태와 무관하게 순수 지표 규칙 충족 여부만 판정 ── + if (signalOnly) { + boolean enterOk = entryRule.isSatisfied(i); + boolean exitOk = exitRule.isSatisfied(i); + + if (enterOk) { + double effEntry = applySlippage(closePrice, cfg, true); + String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; + signals.add(Signal.builder() + .time(time).type(sigType).price(effEntry).barIndex(i).build()); + // 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용) + if (!inPosition) { + double shares = (equity * tradeSizePct) / effEntry; + record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares)); + entryPrice = effEntry; + entryBarIdx = i; + inPosition = true; + partialDone = false; + } + } else if (exitOk) { + double effExit = applySlippage(exitPrice, cfg, false); + String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; + signals.add(Signal.builder() + .time(time).type(sigType).price(effExit).barIndex(i).build()); + // 수익 계산: 실제 포지션이 있을 때만 + if (inPosition) { + double commission = calcCommissionRate(cfg) * 2; + double rawReturn = "SHORT".equals(direction) + ? (entryPrice - effExit) / entryPrice + : (effExit - entryPrice) / entryPrice; + double size = partialDone ? (1.0 - partialPct) : 1.0; + equity += equity * tradeSizePct * size * (rawReturn - commission); + Num numExitPrice = series.numFactory().numOf(effExit); + Num numShares = record.getCurrentPosition().getEntry().getAmount(); + record.exit(i, numExitPrice, numShares); + inPosition = false; + lastExitBar = i; + partialDone = false; + } + } + continue; // SIGNAL_ONLY 처리 완료, 다음 봉으로 + } + + // ── LONG_ONLY 모드: 표준 포지션 제약 로직 ──────────────────────────── + if (!inPosition) { + if (i - lastExitBar <= reentryWait && lastExitBar >= 0) continue; + + boolean doEnter = entryRule.isSatisfied(i, record); + if (!doEnter && "SHORT".equals(direction)) + doEnter = exitRule.isSatisfied(i, record); + + if (doEnter) { + double effEntry = applySlippage(closePrice, cfg, true); + double shares = (equity * tradeSizePct) / effEntry; + + Num numPrice = series.numFactory().numOf(effEntry); + Num numShares = series.numFactory().numOf(shares); + record.enter(i, numPrice, numShares); + + String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; + signals.add(Signal.builder() + .time(time).type(sigType).price(effEntry).barIndex(i).build()); + + entryPrice = effEntry; + entryBarIdx = i; + inPosition = true; + partialDone = false; + } + } else { + // 분할 청산: exit 조건 처음 충족 시 일부만 청산 + if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) { + double effExit = applySlippage(exitPrice, cfg, false); + double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct; + double partReturn = "SHORT".equals(direction) + ? (entryPrice - effExit) / entryPrice + : (effExit - entryPrice) / entryPrice; + double commission = calcCommissionRate(cfg) * 2; + equity += equity * tradeSizePct * partialPct * (partReturn - commission); + + signals.add(Signal.builder() + .time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build()); + partialDone = true; + continue; + } + + if (exitRule.isSatisfied(i, record)) { + double effExit = applySlippage(exitPrice, cfg, false); + double commission = calcCommissionRate(cfg) * 2; + double rawReturn = "SHORT".equals(direction) + ? (entryPrice - effExit) / entryPrice + : (effExit - entryPrice) / entryPrice; + double netReturn = rawReturn - commission; + + double size = partialDone ? (1.0 - partialPct) : 1.0; + equity += equity * tradeSizePct * size * netReturn; + + Num numExitPrice = series.numFactory().numOf(effExit); + Num numShares = record.getCurrentPosition().getEntry().getAmount(); + record.exit(i, numExitPrice, numShares); + + String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; + signals.add(Signal.builder() + .time(time).type(sigType).price(effExit).barIndex(i).build()); + + inPosition = false; + lastExitBar = i; + partialDone = false; + } + } + } + + // ── AnalysisCriterion 전체 계산 ─────────────────────────────────────── + BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity); + + // ── Stats (하위 호환) ───────────────────────────────────────────────── + Stats stats = toStats(analysis, signals); + + // ── DB 저장 ─────────────────────────────────────────────────────────── + Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName); + + return BacktestResponse.builder() + .signals(signals) + .stats(stats) + .analysis(analysis) + .resultId(resultId) + .build(); + } + + // ── Ta4j AnalysisCriterion 전체 계산 ───────────────────────────────────── + + private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record, + BacktestSettingsDto cfg, double initCap, double finalEquity) { + BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder() + .initialCapital(initCap) + .finalEquity(finalEquity); + + double totalReturnPct = safeCalc(() -> calcTotalReturnPct(series, record)); + double grossProfit = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossProfitCriterion", series, record)); + double grossLoss = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossLossCriterion", series, record)); + double avgReturnPct = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record)); + double profitLossRatio = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.ProfitLossRatioCriterion", series, record)); + + int positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record)); + int winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record)); + int losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record)); + int breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record)); + double winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record)); + + double maxDrawdown = safeCalc(() -> calcMaxDrawdown(series, record)); + double maxRunup = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.MaximumRunupCriterion", series, record)); + double sharpe = safeCalc(() -> calcSharpeRatio(series, record)); + double sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); + double calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; + double var95 = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ValueAtRiskCriterion", series, record)); + double es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record)); + + double buyHoldPct = safeCalc(() -> calcBuyAndHold(series, record)); + double vsBuyHold = (buyHoldPct != 0) ? (1 + totalReturnPct) / (1 + buyHoldPct) : 0.0; + + // 금액 기준 총 손익 재계산 + double totalPnl = finalEquity - initCap; + + // positions = 0 이면 record에서 직접 추출 시도 + if (positions == 0) positions = record.getPositionCount(); + if (winning == 0 && positions > 0) { + winning = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isPositive()).count(); + losing = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isNegative()).count(); + breakEven = positions - winning - losing; + } + if (winRate == 0 && positions > 0) winRate = (double) winning / positions; + + return b + .totalReturnPct(totalReturnPct) + .totalProfitLoss(totalPnl) + .grossProfit(grossProfit) + .grossLoss(grossLoss) + .avgReturnPct(avgReturnPct) + .profitLossRatio(profitLossRatio) + .numberOfPositions(positions) + .numberOfWinning(winning) + .numberOfLosing(losing) + .numberOfBreakEven(breakEven) + .winRate(winRate) + .maxDrawdownPct(maxDrawdown) + .maxRunupPct(maxRunup) + .sharpeRatio(sharpe) + .sortinoRatio(sortino) + .calmarRatio(calmar) + .valueAtRisk95(var95) + .expectedShortfall(es) + .buyAndHoldReturnPct(buyHoldPct) + .vsBuyAndHold(vsBuyHold) + .build(); + } + + // ── 개별 Criterion 계산 헬퍼 ───────────────────────────────────────────── + + private double calcTotalReturnPct(BarSeries series, TradingRecord record) throws Exception { + // 총 수익률 = (finalEquity - initialCapital) / initialCapital + // TotalProfitLossPercentageCriterion 또는 ReturnCriterion 시도 + try { + Class cls = Class.forName("org.ta4j.core.criteria.pnl.TotalProfitLossPercentageCriterion"); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + return criterion.calculate(series, record).doubleValue(); + } catch (Exception ignored) {} + try { + Class cls = Class.forName("org.ta4j.core.criteria.ReturnCriterion"); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + return criterion.calculate(series, record).doubleValue() - 1.0; + } catch (Exception ignored) {} + // fallback: 거래 수익 직접 계산 + return record.getPositions().stream() + .filter(Position::isClosed) + .mapToDouble(p -> p.getProfit().doubleValue() / p.getEntry().getNetPrice().doubleValue()) + .sum(); + } + + private double calcMaxDrawdown(BarSeries series, TradingRecord record) throws Exception { + try { + Class cls = Class.forName("org.ta4j.core.criteria.MaximumDrawdownCriterion"); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + return criterion.calculate(series, record).doubleValue(); + } catch (Exception ignored) {} + return 0.0; + } + + private double calcSharpeRatio(BarSeries series, TradingRecord record) throws Exception { + try { + Class cls = Class.forName("org.ta4j.core.criteria.SharpeRatioCriterion"); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + return criterion.calculate(series, record).doubleValue(); + } catch (Exception ignored) {} + return 0.0; + } + + private double calcBuyAndHold(BarSeries series, TradingRecord record) throws Exception { + try { + Class cls = Class.forName("org.ta4j.core.criteria.EnterAndHoldReturnCriterion"); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + double v = criterion.calculate(series, record).doubleValue(); + // 일부 버전은 비율 반환, 일부는 배수 반환 + return v > 10 ? v / 100.0 : v; + } catch (Exception ignored) {} + // fallback: 첫봉~마지막봉 종가 변화율 + if (series.getBarCount() >= 2) { + double first = series.getBar(0).getClosePrice().doubleValue(); + double last = series.getBar(series.getBarCount() - 1).getClosePrice().doubleValue(); + return (last - first) / first; + } + return 0.0; + } + + private double calcCriterion(String className, BarSeries series, TradingRecord record) throws Exception { + Class cls = Class.forName(className); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + return criterion.calculate(series, record).doubleValue(); + } + + private int calcCriterionInt(String className, BarSeries series, TradingRecord record) throws Exception { + Class cls = Class.forName(className); + AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); + return (int) Math.round(criterion.calculate(series, record).doubleValue()); + } + + private double safeCalc(CriterionSupplier supplier) { + try { return supplier.get(); } + catch (Exception e) { + log.debug("Criterion 계산 실패 (무시): {}", e.getMessage()); + return 0.0; + } + } + + private int safeCalcInt(CriterionSupplier supplier) { + try { return (int) Math.round(supplier.get()); } + catch (Exception e) { return 0; } + } + + @FunctionalInterface + interface CriterionSupplier { double get() throws Exception; } + + // ── DB 저장 ─────────────────────────────────────────────────────────────── + + private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg, + List signals, BacktestAnalysisDto analysis, + BarSeries series, String strategyName) { + try { + List bars = req.getBars(); + long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime(); + long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime(); + + GcBacktestResult entity = GcBacktestResult.builder() + .deviceId(req.getDeviceId()) + .strategyId(req.getStrategyId()) + .strategyName(strategyName) + .symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN") + .timeframe(req.getTimeframe()) + .barCount(bars.size()) + .fromTime(fromTime) + .toTime(toTime) + .settingsJson(objectMapper.writeValueAsString(cfg)) + .signalsJson(objectMapper.writeValueAsString(signals)) + .analysisJson(objectMapper.writeValueAsString(analysis)) + .totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct())) + .winRate(BigDecimal.valueOf(analysis.getWinRate())) + .totalTrades(analysis.getNumberOfPositions()) + .maxDrawdown(BigDecimal.valueOf(analysis.getMaxDrawdownPct())) + .sharpeRatio(BigDecimal.valueOf(analysis.getSharpeRatio())) + .finalEquity(BigDecimal.valueOf(analysis.getFinalEquity())) + .build(); + + return resultRepository.save(entity).getId(); + } catch (Exception e) { + log.warn("백테스팅 결과 DB 저장 실패: {}", e.getMessage()); + return null; + } + } + + // ── Stats 변환 (하위 호환) ──────────────────────────────────────────────── + + private Stats toStats(BacktestAnalysisDto a, List signals) { + int buySignals = (int) signals.stream().filter(s -> "BUY".equals(s.getType()) || "SHORT_ENTRY".equals(s.getType())).count(); + int sellSignals = (int) signals.stream().filter(s -> "SELL".equals(s.getType()) || "SHORT_EXIT".equals(s.getType())).count(); + return Stats.builder() + .totalSignals(signals.size()) + .buySignals(buySignals) + .sellSignals(sellSignals) + .totalTrades(a.getNumberOfPositions()) + .winTrades(a.getNumberOfWinning()) + .winRate(a.getWinRate()) + .totalReturn(a.getTotalReturnPct()) + .maxDrawdown(a.getMaxDrawdownPct()) + .avgReturn(a.getAvgReturnPct()) + .finalEquity(a.getFinalEquity()) + .build(); + } + + // ── 가격 결정 ───────────────────────────────────────────────────────────── + + private double getPrice(BarSeries series, List bars, int i, String priceType) { + if ("NEXT_OPEN".equals(priceType) && i + 1 < bars.size()) + return bars.get(i + 1).getOpen(); + if ("OPEN".equals(priceType)) + return series.getBar(i).getOpenPrice().doubleValue(); + if ("HIGH".equals(priceType)) + return series.getBar(i).getHighPrice().doubleValue(); + if ("LOW".equals(priceType)) + return series.getBar(i).getLowPrice().doubleValue(); + return series.getBar(i).getClosePrice().doubleValue(); + } + + private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) { + double slip = cfg.getSlippageRate() != null ? cfg.getSlippageRate().doubleValue() : 0.0005; + return isBuy ? price * (1 + slip) : price * (1 - slip); + } + + private double calcCommissionRate(BacktestSettingsDto cfg) { + if ("ZERO".equals(cfg.getCommissionType())) return 0.0; + return cfg.getCommissionRate() != null ? cfg.getCommissionRate().doubleValue() : 0.0015; + } + + // ── BarSeries 빌드 ──────────────────────────────────────────────────────── + + private BarSeries buildSeries(List bars, String timeframe) { + BarSeries series = new BaseBarSeriesBuilder() + .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Duration period = timeframeToDuration(timeframe); + for (OhlcvBar b : bars) { + // b.getTime() = 봉 시작 시각. ta4j Bar.endTime = 봉 종료 시각이므로 duration 가산. + Instant endInst = Instant.ofEpochSecond(b.getTime()).plus(period); + factory.createBarBuilder(series) + .timePeriod(period).endTime(endInst) + .openPrice(b.getOpen()).highPrice(b.getHigh()) + .lowPrice(b.getLow()).closePrice(b.getClose()) + .volume(b.getVolume()).add(); + } + return series; + } + + private static Duration timeframeToDuration(String tf) { + if (tf == null) return Duration.ofMinutes(1); + return switch (tf) { + case "1m" -> Duration.ofMinutes(1); + case "3m" -> Duration.ofMinutes(3); + case "5m" -> Duration.ofMinutes(5); + case "15m" -> Duration.ofMinutes(15); + case "30m" -> Duration.ofMinutes(30); + case "1h" -> Duration.ofHours(1); + case "4h" -> Duration.ofHours(4); + case "1D" -> Duration.ofDays(1); + case "1W" -> Duration.ofDays(7); + case "1M" -> Duration.ofDays(30); + default -> Duration.ofMinutes(1); + }; + } + + private BacktestResponse emptyResponse(String reason) { + log.warn("[BacktestingService] 빈 결과: {}", reason); + BacktestAnalysisDto emptyAnalysis = BacktestAnalysisDto.builder().build(); + return BacktestResponse.builder() + .signals(List.of()) + .stats(Stats.builder().build()) + .analysis(emptyAnalysis) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/ChartSettingsService.java b/backend/src/main/java/com/goldenchart/service/ChartSettingsService.java new file mode 100644 index 0000000..a627f32 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/ChartSettingsService.java @@ -0,0 +1,145 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcChartSlot; +import com.goldenchart.entity.GcChartWorkspace; +import com.goldenchart.repository.GcChartWorkspaceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 차트 워크스페이스·슬롯 설정 저장/조회 서비스. + * userId 또는 deviceId 중 하나를 식별자로 사용한다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ChartSettingsService { + + private final GcChartWorkspaceRepository workspaceRepo; + private final ObjectMapper objectMapper; + + // ── 워크스페이스 조회 ────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public GcChartWorkspace getWorkspace(Long userId, String deviceId) { + return findWorkspace(userId, deviceId).orElse(null); + } + + @Transactional(readOnly = true) + public GcChartWorkspace getWorkspaceWithSlots(Long userId, String deviceId) { + if (userId != null) { + return workspaceRepo.findWithSlotsByUserId(userId).orElse(null); + } + return workspaceRepo.findWithSlotsByDeviceId(deviceId).orElse(null); + } + + // ── 워크스페이스 저장 (생성 또는 전체 업데이트) ──────────────────────────── + + public GcChartWorkspace saveWorkspace(Long userId, String deviceId, + String layoutId, String syncOptionsJson) { + GcChartWorkspace ws = findWorkspace(userId, deviceId) + .orElseGet(() -> GcChartWorkspace.builder() + .userId(userId) + .deviceId(deviceId) + .layoutId(layoutId) + .slots(new ArrayList<>()) + .build()); + ws.setLayoutId(layoutId); + ws.setSyncOptionsJson(syncOptionsJson); + return workspaceRepo.save(ws); + } + + // ── 슬롯 설정 저장 ───────────────────────────────────────────────────────── + + public GcChartSlot saveSlot(Long userId, String deviceId, int slotIndex, + Map slotData) { + GcChartWorkspace ws = findOrCreateWorkspace(userId, deviceId); + + GcChartSlot slot = ws.getSlots().stream() + .filter(s -> s.getSlotIndex() == slotIndex) + .findFirst() + .orElseGet(() -> { + GcChartSlot newSlot = GcChartSlot.builder() + .workspace(ws) + .slotIndex(slotIndex) + .build(); + ws.getSlots().add(newSlot); + return newSlot; + }); + + applySlotData(slot, slotData); + workspaceRepo.save(ws); + return slot; + } + + // ── 전체 워크스페이스 + 슬롯 일괄 저장 (프론트 최초 로드 또는 전체 저장) ── + + public GcChartWorkspace saveAll(Long userId, String deviceId, + String layoutId, String syncOptionsJson, + List> slotsData) { + GcChartWorkspace ws = findOrCreateWorkspace(userId, deviceId); + ws.setLayoutId(layoutId); + ws.setSyncOptionsJson(syncOptionsJson); + + // 슬롯 동기화 + List existing = ws.getSlots(); + for (int i = 0; i < slotsData.size(); i++) { + final int idx = i; + GcChartSlot slot = existing.stream() + .filter(s -> s.getSlotIndex() == idx) + .findFirst() + .orElseGet(() -> { + GcChartSlot ns = GcChartSlot.builder() + .workspace(ws).slotIndex(idx).build(); + existing.add(ns); + return ns; + }); + applySlotData(slot, slotsData.get(i)); + } + // 초과 슬롯 제거 + existing.removeIf(s -> s.getSlotIndex() >= slotsData.size()); + + return workspaceRepo.save(ws); + } + + // ── private helpers ──────────────────────────────────────────────────────── + + private java.util.Optional findWorkspace(Long userId, String deviceId) { + if (userId != null) return workspaceRepo.findByUserId(userId); + return workspaceRepo.findByDeviceId(deviceId); + } + + private GcChartWorkspace findOrCreateWorkspace(Long userId, String deviceId) { + return findWorkspace(userId, deviceId) + .orElseGet(() -> workspaceRepo.save(GcChartWorkspace.builder() + .userId(userId).deviceId(deviceId).layoutId("1").build())); + } + + private void applySlotData(GcChartSlot slot, Map d) { + if (d.containsKey("symbol")) slot.setSymbol((String) d.get("symbol")); + if (d.containsKey("timeframe")) slot.setTimeframe((String) d.get("timeframe")); + if (d.containsKey("chartType")) slot.setChartType((String) d.get("chartType")); + if (d.containsKey("theme")) slot.setTheme((String) d.get("theme")); + if (d.containsKey("mode")) slot.setMode((String) d.get("mode")); + if (d.containsKey("logScale")) slot.setLogScale(Boolean.parseBoolean(d.get("logScale").toString())); + if (d.containsKey("drawingsLocked")) slot.setDrawingsLocked(Boolean.parseBoolean(d.get("drawingsLocked").toString())); + if (d.containsKey("drawingsVisible"))slot.setDrawingsVisible(Boolean.parseBoolean(d.get("drawingsVisible").toString())); + try { + if (d.containsKey("indicators")) slot.setIndicatorsJson(objectMapper.writeValueAsString(d.get("indicators"))); + if (d.containsKey("drawings")) slot.setDrawingsJson(objectMapper.writeValueAsString(d.get("drawings"))); + if (d.containsKey("paneLayout")) slot.setPaneLayoutJson(objectMapper.writeValueAsString(d.get("paneLayout"))); + if (d.containsKey("mainChartStyle")) slot.setMainChartStyleJson(objectMapper.writeValueAsString(d.get("mainChartStyle"))); + } catch (JsonProcessingException e) { + log.error("슬롯 JSON 직렬화 실패", e); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/DashboardService.java b/backend/src/main/java/com/goldenchart/service/DashboardService.java new file mode 100644 index 0000000..6ad3d20 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/DashboardService.java @@ -0,0 +1,91 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.LiveStrategySettingsDto; +import com.goldenchart.dto.LiveSummaryDto; +import com.goldenchart.dto.PaperSummaryDto; +import com.goldenchart.dto.TradeSignalDto; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.repository.GcTradeSignalRepository; +import com.goldenchart.repository.GcWatchlistRepository; +import com.goldenchart.trading.pipeline.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class DashboardService { + + private final AppSettingsService appSettingsService; + private final PaperTradingService paperTradingService; + private final LiveTradingService liveTradingService; + private final LiveStrategySettingsService liveStrategySettingsService; + private final GcWatchlistRepository watchlistRepo; + private final GcTradeSignalRepository signalRepo; + private final GcLiveStrategySettingsRepository liveSettingsRepo; + private final FcmPushService fcmPushService; + private final PipelineMonitorService pipelineMonitorService; + + @Transactional(readOnly = true) + public Map buildSummary(Long userId, String deviceId) { + Map out = new HashMap<>(); + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + + out.put("app", Map.of( + "tradingMode", app.getTradingMode() != null ? app.getTradingMode() : "PAPER", + "liveAutoTradeEnabled", Boolean.TRUE.equals(app.getLiveAutoTradeEnabled()), + "paperAutoTradeEnabled", Boolean.TRUE.equals(app.getPaperAutoTradeEnabled()), + "liveStrategyCheck", Boolean.TRUE.equals(app.getLiveStrategyCheck()), + "chartRealtimeSource", app.getChartRealtimeSource() != null ? app.getChartRealtimeSource() : "BACKEND_STOMP", + "fcmPushEnabled", Boolean.TRUE.equals(app.getFcmPushEnabled()), + "hasUpbitKeys", liveTradingService.hasApiKeys(app) + )); + + int watchlistCount = userId != null + ? watchlistRepo.findByUserIdOrderByDisplayOrderAsc(userId).size() + : watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId).size(); + out.put("watchlistCount", watchlistCount); + + int liveCheckMarkets = (int) liveSettingsRepo.findAllByIsLiveCheckTrue().stream() + .filter(s -> deviceId.equals(s.getDeviceId()) || (userId != null && userId.equals(s.getUserId()))) + .count(); + out.put("liveCheckMarkets", liveCheckMarkets); + + List active = liveStrategySettingsService.listActive(userId, deviceId); + out.put("monitoredMarkets", active.size()); + + PaperSummaryDto paper = paperTradingService.getSummary(userId, deviceId, null); + out.put("paper", paper); + + LiveSummaryDto live = liveTradingService.getSummary(userId, deviceId); + out.put("live", live); + + List recentSignals = signalRepo + .findByDeviceIdOrderByCreatedAtDesc(deviceId) + .stream().limit(8) + .map(s -> TradeSignalDto.builder() + .id(s.getId()) + .market(s.getMarket()) + .signalType(s.getSignalType()) + .price(s.getPrice()) + .candleType(s.getCandleType()) + .createdAt(s.getCreatedAt() != null ? s.getCreatedAt().toString() : null) + .build()) + .toList(); + out.put("recentSignals", recentSignals); + + out.put("systemMonitor", pipelineMonitorService.buildMonitorSnapshot()); + + out.put("fcm", Map.of( + "available", fcmPushService.isAvailable(), + "pushEnabled", Boolean.TRUE.equals(app.getFcmPushEnabled()) + )); + + return out; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/FcmPushService.java b/backend/src/main/java/com/goldenchart/service/FcmPushService.java new file mode 100644 index 0000000..3b867dc --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/FcmPushService.java @@ -0,0 +1,133 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcFcmToken; +import com.goldenchart.repository.GcFcmTokenRepository; +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FcmPushService { + + private final GcFcmTokenRepository tokenRepo; + private final AppSettingsService appSettingsService; + + @Value("${firebase.enabled:false}") + private boolean firebaseEnabled; + + @Transactional + public void registerToken(Long userId, String deviceId, String token) { + if (token == null || token.isBlank()) return; + String dev = deviceId != null ? deviceId : "anonymous"; + Optional existing = tokenRepo.findByToken(token); + if (existing.isPresent()) { + GcFcmToken t = existing.get(); + t.setUserId(userId); + t.setDeviceId(dev); + t.setActive(true); + t.setLastUsedAt(LocalDateTime.now()); + tokenRepo.save(t); + } else { + tokenRepo.save(GcFcmToken.builder() + .token(token) + .userId(userId) + .deviceId(dev) + .active(true) + .lastUsedAt(LocalDateTime.now()) + .build()); + } + log.info("[FCM] token registered device={}", dev); + } + + @Transactional + public void deleteByDevice(String deviceId) { + if (deviceId != null) tokenRepo.deleteByDeviceId(deviceId); + } + + public boolean isAvailable() { + return firebaseEnabled && !FirebaseApp.getApps().isEmpty(); + } + + /** + * 매매 시그널 FCM — deviceId 일치 토큰 + fcm_push_enabled 설정 시. + */ + public void sendTradeSignalIfEnabled(String deviceId, Long userId, String market, + String signalType, double price, + String strategyName, Long signalId) { + if (!isAvailable() || deviceId == null) return; + var app = appSettingsService.getEntity(userId, deviceId); + if (!Boolean.TRUE.equals(app.getFcmPushEnabled())) return; + + List tokens = tokenRepo.findByDeviceIdAndActiveTrue(deviceId); + if (tokens.isEmpty()) { + tokens = tokenRepo.findByActiveTrue(); + } + if (tokens.isEmpty()) return; + + String title = "BUY".equals(signalType) ? "🟢 매수 시그널" : "🔴 매도 시그널"; + String body = String.format("%s · ₩%,.0f%s", + market, price, + strategyName != null ? " · " + strategyName : ""); + + for (GcFcmToken t : tokens) { + try { + Message msg = Message.builder() + .setToken(t.getToken()) + .setNotification(Notification.builder().setTitle(title).setBody(body).build()) + .putData("type", "trade_signal") + .putData("signalId", signalId != null ? String.valueOf(signalId) : "") + .putData("market", market) + .putData("signalType", signalType) + .putData("price", String.valueOf(price)) + .build(); + FirebaseMessaging.getInstance().send(msg); + t.setLastUsedAt(LocalDateTime.now()); + tokenRepo.save(t); + } catch (FirebaseMessagingException e) { + log.warn("[FCM] send failed: {}", e.getMessagingErrorCode()); + if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED + || e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) { + t.setActive(false); + tokenRepo.save(t); + } + } catch (Exception e) { + log.warn("[FCM] send error: {}", e.getMessage()); + } + } + } + + public void sendTest(Long userId, String deviceId) { + if (!isAvailable()) { + log.warn("[FCM] test skipped — Firebase not initialized"); + return; + } + List tokens = deviceId != null + ? tokenRepo.findByDeviceIdAndActiveTrue(deviceId) + : tokenRepo.findByActiveTrue(); + for (GcFcmToken t : tokens) { + try { + Message msg = Message.builder() + .setToken(t.getToken()) + .setNotification(Notification.builder() + .setTitle("GoldenChart 테스트") + .setBody("FCM 푸시가 정상 동작합니다.") + .build()) + .putData("type", "test") + .build(); + FirebaseMessaging.getInstance().send(msg); + } catch (Exception e) { + log.warn("[FCM] test send failed: {}", e.getMessage()); + } + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java b/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java new file mode 100644 index 0000000..c9b5c97 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/HistoricalDataService.java @@ -0,0 +1,314 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.ta4j.core.*; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.indicators.RSIIndicator; +import org.ta4j.core.indicators.averages.EMAIndicator; +import org.ta4j.core.indicators.helpers.ClosePriceIndicator; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 과거 캔들 데이터 페이징 중계기 (명세서 3.4). + * + * 동작 메커니즘: + * 1. 요청한 기준 시간(to) 이 Ta4jStorage 인메모리(최근 300개) 범위 내 → 메모리 슬라이싱 반환 + * 2. 인메모리 범위 초과(먼 과거) → 업비트 REST API 포워딩 + * 3. 응답 데이터 + 워밍업 캔들을 임시 BarSeries 에 로드 → Ta4j RSI 계산 후 첨부 반환 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class HistoricalDataService { + + private final Ta4jStorage ta4jStorage; + private final WebClient.Builder webClientBuilder; + private final ObjectMapper objectMapper; + + @Value("${upbit.api.base-url:https://api.upbit.com}") + private String upbitBaseUrl; + + @Value("${upbit.api.candle-limit:200}") + private int candleLimit; + + @Value("${upbit.api.request-delay-ms:110}") + private long requestDelayMs; + + /** RSI 계산 기본 기간 */ + private static final int RSI_PERIOD = 14; + + /** RSI 수렴에 필요한 워밍업 캔들 수 (실제 반환하지 않음) */ + private static final int RSI_WARMUP = 50; + + // ── 공개 API ────────────────────────────────────────────────────────────── + + /** + * 과거 캔들 조회. + * + * @param market 업비트 마켓 코드 (e.g. "KRW-BTC") + * @param candleType 캔들 타입 (e.g. "1m", "1d") + * @param to 기준 시간 이전(exclusive) ISO-8601 문자열 (e.g. "2026-05-20T10:00:00") + * @param count 요청 캔들 수 (최대 200) + * @return CandleBarDto 리스트 (시간 오름차순) + */ + public List getHistory(String market, String candleType, String to, int count) { + int safeCount = Math.min(count, candleLimit); + + // ── 1. to 시간 파싱 ─────────────────────────────────────────────────── + ZonedDateTime toTime = parseToTime(to); + + // ── 2. 인메모리 → 부족 시 업비트 REST 폴백 ───────────────────────────── + List rawBars = Collections.emptyList(); + Optional oldestMemory = ta4jStorage.getOldestTime(market, candleType); + boolean inMemory = oldestMemory.isPresent() && !toTime.isBefore(oldestMemory.get()); + + if (inMemory) { + log.debug("[HistoricalDataService] 인메모리에서 조회: {} / {} / to={}", market, candleType, to); + rawBars = sliceFromMemory(market, candleType, toTime, safeCount); + } + + if (rawBars.isEmpty()) { + log.debug("[HistoricalDataService] 업비트 REST 프록시: {} / {} / to={}", market, candleType, to); + rawBars = fetchFromUpbit(market, candleType, to, safeCount); + } + + if (rawBars.isEmpty()) return Collections.emptyList(); + + // ── 3. RSI 계산 (실패 시 OHLCV만 반환) ───────────────────────────────── + return attachRsi(rawBars, market, candleType); + } + + // ── 인메모리 슬라이싱 ───────────────────────────────────────────────────── + + private List sliceFromMemory(String market, String candleType, + ZonedDateTime toTime, int count) { + List bars = ta4jStorage.getBars(market, candleType); + Instant toInstant = toTime.toInstant(); + // toTime 이전 Bar 만 필터링, 최신부터 count 개 역순 선택 후 다시 시간순 정렬 + List result = bars.stream() + .filter(b -> !b.getEndTime().isAfter(toInstant)) + .sorted(Comparator.comparing(Bar::getEndTime).reversed()) + .limit(count) + .map(UpbitCandleRaw::of) + .collect(Collectors.toList()); + Collections.reverse(result); + return result; + } + + // ── 업비트 REST 프록시 ──────────────────────────────────────────────────── + + private List fetchFromUpbit(String market, String candleType, + String to, int count) { + String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1"); + String url = upbitBaseUrl + "/v1/candles/" + path + + "?market=" + market + + "&count=" + count + + (to != null && !to.isBlank() ? "&to=" + to + "Z" : ""); + + try { + Thread.sleep(requestDelayMs); // 업비트 요청 속도 제한 준수 + String json = webClientBuilder.build() + .get() + .uri(url) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (json == null || json.isBlank()) return Collections.emptyList(); + + List> raw = + objectMapper.readValue(json, new TypeReference<>() {}); + + // 업비트 응답은 최신→과거 순 → 시간 오름차순으로 뒤집기 + List list = raw.stream() + .map(this::parseUpbitCandle) + .sorted(Comparator.comparingLong(c -> c.time)) + .collect(Collectors.toList()); + return list; + + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.error("[HistoricalDataService] 업비트 요청 인터럽트", ie); + } catch (Exception e) { + log.error("[HistoricalDataService] 업비트 REST 호출 실패: {}", e.getMessage()); + } + return Collections.emptyList(); + } + + private UpbitCandleRaw parseUpbitCandle(Map m) { + // candle_date_time_utc: "2026-05-20T10:00:00" + String dtStr = (String) m.get("candle_date_time_utc"); + long epochSec = ZonedDateTime.parse(dtStr + "Z", + java.time.format.DateTimeFormatter.ISO_DATE_TIME).toEpochSecond(); + return new UpbitCandleRaw( + epochSec, + toDouble(m.get("opening_price")), + toDouble(m.get("high_price")), + toDouble(m.get("low_price")), + toDouble(m.get("trade_price")), + toDouble(m.get("candle_acc_trade_volume")) + ); + } + + // ── RSI 계산 (Ta4j 임시 BarSeries) ──────────────────────────────────────── + + /** + * rawBars 앞에 워밍업용 캔들을 추가(메모리에서 보충)하여 임시 BarSeries 구성 후 + * RSI 를 계산. 워밍업 구간은 결과에서 제외하고 원래 rawBars 구간만 반환. + */ + private List attachRsi(List rawBars, + String market, String candleType) { + // 워밍업 캔들: 인메모리에서 rawBars 의 가장 오래된 시간 이전 데이터를 추가 + List warmup = getWarmupBars(market, candleType, + Ta4jStorage.epochToZdt(rawBars.get(0).time), RSI_WARMUP); + + // 임시 BarSeries 구성 (워밍업 + 대상 bars) + BarSeries tmpSeries = buildTempSeries(warmup, rawBars, candleType); + + if (tmpSeries.isEmpty()) { + return rawBars.stream() + .map(HistoricalDataService::toDtoWithoutRsi) + .collect(Collectors.toList()); + } + + ClosePriceIndicator close = new ClosePriceIndicator(tmpSeries); + RSIIndicator rsi = new RSIIndicator(close, RSI_PERIOD); + + int warmupSize = warmup.size(); + int endIdx = tmpSeries.getEndIndex(); + List result = new ArrayList<>(); + for (int i = 0; i < rawBars.size(); i++) { + int seriesIdx = warmupSize + i; + UpbitCandleRaw raw = rawBars.get(i); + Double rsiVal = null; + if (seriesIdx >= RSI_PERIOD && seriesIdx <= endIdx) { + try { + double v = rsi.getValue(seriesIdx).doubleValue(); + if (!Double.isNaN(v) && !Double.isInfinite(v)) rsiVal = v; + } catch (Exception e) { + log.trace("[HistoricalDataService] RSI 스킵 idx={}: {}", seriesIdx, e.getMessage()); + } + } + result.add(CandleBarDto.builder() + .time(raw.time) + .open(raw.open) + .high(raw.high) + .low(raw.low) + .close(raw.close) + .volume(raw.volume) + .rsi(rsiVal) + .build()); + } + return result; + } + + private static CandleBarDto toDtoWithoutRsi(UpbitCandleRaw raw) { + return CandleBarDto.builder() + .time(raw.time) + .open(raw.open) + .high(raw.high) + .low(raw.low) + .close(raw.close) + .volume(raw.volume) + .build(); + } + + /** 인메모리에서 워밍업 캔들 추출 */ + private List getWarmupBars(String market, String candleType, + ZonedDateTime before, int maxCount) { + if (!ta4jStorage.exists(market, candleType)) return Collections.emptyList(); + List bars = ta4jStorage.getBars(market, candleType); + Instant beforeInstant = before.toInstant(); + return bars.stream() + .filter(b -> b.getEndTime().isBefore(beforeInstant)) + .sorted(Comparator.comparing(Bar::getEndTime).reversed()) + .limit(maxCount) + .map(UpbitCandleRaw::of) + .sorted(Comparator.comparingLong(c -> c.time)) + .collect(Collectors.toList()); + } + + /** 워밍업 + 대상 캔들 배열로 임시 BarSeries 생성 */ + private BarSeries buildTempSeries(List warmup, + List target, + String candleType) { + BarSeries series = new BaseBarSeriesBuilder() + .withName("tmp") + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22 + .build(); + Duration duration = Ta4jStorage.parseDuration(candleType); + + for (UpbitCandleRaw r : warmup) addRawToSeries(series, r, duration); + for (UpbitCandleRaw r : target) addRawToSeries(series, r, duration); + return series; + } + + private void addRawToSeries(BarSeries series, UpbitCandleRaw r, Duration duration) { + try { + // Ta4j 0.22: barBuilder().endTime() 은 Instant 파라미터 + Instant endInstant = Ta4jStorage.epochToInstant(r.time).plus(duration); + Bar bar = series.barBuilder() + .timePeriod(duration) + .endTime(endInstant) + .openPrice(r.open) + .highPrice(r.high) + .lowPrice(r.low) + .closePrice(r.close) + .volume(r.volume) + .build(); + series.addBar(bar, true); + } catch (Exception e) { + log.trace("[HistoricalDataService] Bar 추가 스킵: {}", e.getMessage()); + } + } + + // ── 유틸 ────────────────────────────────────────────────────────────────── + + private ZonedDateTime parseToTime(String to) { + if (to == null || to.isBlank()) return ZonedDateTime.now(java.time.ZoneOffset.UTC); + try { + // ISO-8601 without Z 또는 with Z 모두 처리 + String s = to.endsWith("Z") ? to : to + "Z"; + return ZonedDateTime.parse(s, java.time.format.DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception e) { + return ZonedDateTime.now(java.time.ZoneOffset.UTC); + } + } + + private static double toDouble(Object v) { + if (v == null) return 0.0; + return ((Number) v).doubleValue(); + } + + // ── 내부 레코드 ─────────────────────────────────────────────────────────── + + /** 업비트 캔들 / 인메모리 Bar 공통 내부 표현 */ + record UpbitCandleRaw(long time, double open, double high, double low, + double close, double volume) { + static UpbitCandleRaw of(Bar b) { + return new UpbitCandleRaw( + b.getEndTime().getEpochSecond() - b.getTimePeriod().getSeconds(), + b.getOpenPrice().doubleValue(), + b.getHighPrice().doubleValue(), + b.getLowPrice().doubleValue(), + b.getClosePrice().doubleValue(), + b.getVolume().doubleValue() + ); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorService.java b/backend/src/main/java/com/goldenchart/service/IndicatorService.java new file mode 100644 index 0000000..4be9902 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/IndicatorService.java @@ -0,0 +1,818 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.IndicatorResponse; +import com.goldenchart.dto.IndicatorResponse.PlotPoint; +import com.goldenchart.dto.OhlcvBar; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.*; +import org.ta4j.core.indicators.*; +import org.ta4j.core.indicators.adx.*; +import org.ta4j.core.indicators.aroon.*; +import org.ta4j.core.indicators.averages.*; +import org.ta4j.core.indicators.bollinger.*; +import org.ta4j.core.indicators.donchian.*; +import org.ta4j.core.indicators.helpers.*; +import org.ta4j.core.indicators.ichimoku.*; +import org.ta4j.core.indicators.keltner.*; +import org.ta4j.core.indicators.numeric.NumericIndicator; +import org.ta4j.core.indicators.numeric.UnaryOperationIndicator; +import org.ta4j.core.indicators.statistics.*; +import org.ta4j.core.indicators.volume.*; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; +import org.ta4j.core.num.Num; + +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.IntStream; + +/** + * Ta4j 기술 지표 계산 서비스. + * frontend indicatorRegistry.ts 에 등록된 지표를 동일 로직으로 구현. + */ +@Service +@Slf4j +public class IndicatorService { + + // ── Public API ──────────────────────────────────────────────────────────── + + public IndicatorResponse calculate(List bars, String type, Map params, String timeframe) { + BarSeries series = buildSeries(bars, timeframe); + int n = series.getBarCount(); + Map p = params != null ? params : Map.of(); + + Map> result = switch (type) { + // Moving Averages + case "SMA" -> calcSMA(series, p); + case "EMA" -> single("plot0", series, new EMAIndicator(src(series, p), intP(p, "length", 21))); + case "WMA" -> single("plot0", series, new WMAIndicator(src(series, p), intP(p, "length", 20))); + case "HMA" -> single("plot0", series, new HMAIndicator(src(series, p), intP(p, "length", 16))); + case "VWMA" -> single("plot0", series, new VWMAIndicator(new ClosePriceIndicator(series), intP(p, "length", 20))); + case "DEMA" -> single("plot0", series, new DoubleEMAIndicator(src(series, p), intP(p, "length", 21))); + case "TEMA" -> single("plot0", series, new TripleEMAIndicator(src(series, p), intP(p, "length", 21))); + case "RMA", "SMMA" -> single("plot0", series, new WildersMAIndicator(src(series, p), intP(p, "length", 14))); + case "LSMA" -> single("plot0", series, new LSMAIndicator(src(series, p), intP(p, "length", 25))); + case "MACross" -> calcMACross(series, p); + + // Bands + case "BollingerBands" -> calcBB(series, p); + case "KeltnerChannels" -> calcKC(series, p); + case "DonchianChannels" -> calcDC(series, p); + case "BBPercentB" -> calcBBPercentB(series, p); + case "BBBandWidth" -> calcBBBandWidth(series, p); + + // Oscillators + case "RSI" -> calcRSI(series, p); + case "Stochastic" -> calcStochastic(series, p); + case "StochRSI" -> calcStochRSI(series, p); + case "CCI" -> calcCCI(series, p); + case "WilliamsPercentRange" -> calcWilliamsR(series, p); + case "AwesomeOscillator" -> calcAO(series); + case "DPO" -> single("plot0", series, new DPOIndicator(src(series, p), intP(p, "length", 21))); + + // Momentum + case "MACD" -> calcMACD(series, p); + case "Momentum" -> calcMomentumInd(series, p); + case "ROC" -> calcROC(series, p); + case "TSI" -> calcTSI(series, p); + case "TRIX" -> calcTRIX(series, p); + + // Trend + case "ADX" -> calcADX(series, p); + case "DMI" -> calcDMI(series, p); + case "Aroon" -> calcAroon(series, p); + case "IchimokuCloud"-> calcIchimoku(series, p); + case "ParabolicSAR" -> calcParabolicSAR(series, p); + case "Choppiness" -> calcChoppiness(series, p); + case "MassIndex" -> calcMassIndex(series, p); + + // Volatility + case "ATR" -> single("plot0", series, new ATRIndicator(series, intP(p, "length", 14))); + case "StandardDeviation"-> single("plot0", series, new StandardDeviationIndicator(src(series, p), intP(p, "length", 20))); + case "HistoricalVolatility" -> calcHV(series, p); + + // Volume + case "OBV" -> calcOBV(series, p); + case "MFI" -> single("plot0", series, new MoneyFlowIndexIndicator(series, intP(p, "length", 14))); + case "ChaikinMF"-> single("plot0", series, new ChaikinMoneyFlowIndicator(series, intP(p, "length", 20))); + case "VolumeOscillator" -> calcVolumeOscillator(series, p); + case "VR" -> calcVR(series, p); + case "Disparity" -> calcDisparity(series, p); + case "Psychological", "NewPsychological" -> calcPsychological(series, p); + case "InvestPsychological"-> calcInvestPsychological(series, p); + + default -> throw new IllegalArgumentException("지원하지 않는 지표: " + type); + }; + + List times = IntStream.range(0, n) + .mapToObj(i -> { + Bar bar = series.getBar(i); + return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + }) + .toList(); + + return IndicatorResponse.builder() + .indicatorType(type) + .times(times) + .values(result) + .build(); + } + + // ── Series Builder ──────────────────────────────────────────────────────── + + /** + * OhlcvBar 목록으로 Ta4j BarSeries 를 생성한다. + * + * @param bars OHLCV 바 데이터 + * @param timeframe 타임프레임 문자열 (1m/5m/15m/30m/1h/4h/1D/1W/1M). + * null 또는 알 수 없는 값이면 1분으로 fallback. + */ + private BarSeries buildSeries(List bars, String timeframe) { + BarSeries series = new BaseBarSeriesBuilder() + .withNumFactory(DoubleNumFactory.getInstance()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Duration period = timeframeToDuration(timeframe); + for (OhlcvBar b : bars) { + // b.getTime() 은 봉 시작 시각(Unix 초). + // ta4j Bar 의 endTime 은 봉 종료 시각이므로 duration 을 더해야 한다. + java.time.Instant endInst = java.time.Instant.ofEpochSecond(b.getTime()).plus(period); + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(endInst) + .openPrice(b.getOpen()) + .highPrice(b.getHigh()) + .lowPrice(b.getLow()) + .closePrice(b.getClose()) + .volume(b.getVolume()) + .add(); + } + return series; + } + + /** + * 타임프레임 문자열을 Duration 으로 변환. + * frontend Timeframe 타입 (1m/5m/15m/30m/1h/4h/1D/1W/1M)과 1:1 대응. + */ + private static Duration timeframeToDuration(String tf) { + if (tf == null) return Duration.ofMinutes(1); + return switch (tf) { + case "1m" -> Duration.ofMinutes(1); + case "3m" -> Duration.ofMinutes(3); + case "5m" -> Duration.ofMinutes(5); + case "15m" -> Duration.ofMinutes(15); + case "30m" -> Duration.ofMinutes(30); + case "1h" -> Duration.ofHours(1); + case "4h" -> Duration.ofHours(4); + case "1D" -> Duration.ofDays(1); + case "1W" -> Duration.ofDays(7); + case "1M" -> Duration.ofDays(30); + default -> Duration.ofMinutes(1); + }; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private int intP(Map p, String k, int def) { + Object v = p.get(k); return v == null ? def : ((Number) v).intValue(); + } + + private double dblP(Map p, String k, double def) { + Object v = p.get(k); return v == null ? def : ((Number) v).doubleValue(); + } + + private Indicator src(BarSeries s, Map p) { + return switch (p.getOrDefault("src", "close").toString()) { + case "open" -> new OpenPriceIndicator(s); + case "high" -> new HighPriceIndicator(s); + case "low" -> new LowPriceIndicator(s); + case "hl2" -> new MedianPriceIndicator(s); + case "hlc3" -> new TypicalPriceIndicator(s); + default -> new ClosePriceIndicator(s); + }; + } + + private Double safe(Num n) { + if (n == null) return null; + double v = n.doubleValue(); + return Double.isNaN(v) || Double.isInfinite(v) ? null : v; + } + + private List toPlot(BarSeries s, Indicator ind) { + int n = s.getBarCount(); + List pts = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + // 프론트 차트의 time 축은 봉 시작 시각 기준. + // endTime - timePeriod = 봉 시작 시각 + long startEpoch = bar.getEndTime().getEpochSecond() + - bar.getTimePeriod().getSeconds(); + pts.add(PlotPoint.builder() + .time(startEpoch) + .value(safe(ind.getValue(i))) + .build()); + } + return pts; + } + + private Map> single(String id, BarSeries s, Indicator ind) { + return Map.of(id, toPlot(s, ind)); + } + + // ── Moving Averages ─────────────────────────────────────────────────────── + + /** MA1~MA11 기본 기간: 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 */ + private static final int[] SMA_DEFAULT_PERIODS = { 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 }; + + private Map> calcSMA(BarSeries s, Map p) { + Indicator source = src(s, p); + Map> result = new LinkedHashMap<>(); + for (int i = 0; i < SMA_DEFAULT_PERIODS.length; i++) { + String periodKey = "period" + (i + 1); + int period = intP(p, periodKey, SMA_DEFAULT_PERIODS[i]); + // 레거시 단일 len → MA1 + if (i == 0 && p.containsKey("len")) { + period = intP(p, "len", SMA_DEFAULT_PERIODS[0]); + } + period = Math.max(1, period); + result.put("plot" + i, toPlot(s, new SMAIndicator(source, period))); + } + return result; + } + + private Map> calcMACross(BarSeries s, Map p) { + Indicator src = src(s, p); + return Map.of( + "plot0", toPlot(s, new EMAIndicator(src, intP(p, "fastLength", 9))), + "plot1", toPlot(s, new EMAIndicator(src, intP(p, "slowLength", 21))) + ); + } + + // ── Bands ───────────────────────────────────────────────────────────────── + + private Map> calcBB(BarSeries s, Map p) { + int len = intP(p, "length", 20); + double mult = dblP(p, "mult", 2.0); + int offset = intP(p, "offset", 0); + // 업비트 BB: 종가 SMA + 모집단 표준편차 × 곱 + ClosePriceIndicator close = new ClosePriceIndicator(s); + SMAIndicator basis = new SMAIndicator(close, len); + StandardDeviationIndicator std = StandardDeviationIndicator.ofPopulation(close, len); + BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis); + Num k = s.numFactory().numOf(mult); + BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); + BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); + return Map.of( + "plot0", shiftPlotOffset(toPlot(s, mid), offset), + "plot1", shiftPlotOffset(toPlot(s, upper), offset), + "plot2", shiftPlotOffset(toPlot(s, lower), offset) + ); + } + + /** TradingView/업비트 오프셋: 양수 = 플롯을 시간축 오른쪽으로 이동 */ + private List shiftPlotOffset(List pts, int offset) { + if (offset == 0) return pts; + int n = pts.size(); + List out = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + int src = i - offset; + Double val = (src >= 0 && src < n) ? pts.get(src).getValue() : null; + out.add(PlotPoint.builder().time(pts.get(i).getTime()).value(val).build()); + } + return out; + } + + private Indicator maOf(Indicator source, String maType, int len, BarSeries s) { + return switch (maType) { + case "EMA" -> new EMAIndicator(source, len); + case "WMA" -> new WMAIndicator(source, len); + case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len); + case "VWMA" -> new VWMAIndicator(source, len); + default -> new SMAIndicator(source, len); + }; + } + + private Map> calcKC(BarSeries s, Map p) { + int len = intP(p, "length", 20); + double mult = dblP(p, "multiplier", 2.0); + EMAIndicator ema = new EMAIndicator(new ClosePriceIndicator(s), len); + KeltnerChannelMiddleIndicator mid = new KeltnerChannelMiddleIndicator(s, len); + ATRIndicator atr = new ATRIndicator(s, len); + KeltnerChannelUpperIndicator upper = new KeltnerChannelUpperIndicator(mid, atr, mult); + KeltnerChannelLowerIndicator lower = new KeltnerChannelLowerIndicator(mid, atr, (double) mult); + return Map.of( + "plot0", toPlot(s, upper), + "plot1", toPlot(s, ema), + "plot2", toPlot(s, lower) + ); + } + + private Map> calcDC(BarSeries s, Map p) { + int len = intP(p, "length", 20); + DonchianChannelUpperIndicator upper = new DonchianChannelUpperIndicator(s, len); + DonchianChannelLowerIndicator lower = new DonchianChannelLowerIndicator(s, len); + // basis = (upper + lower) / 2 + NumericIndicator basis = NumericIndicator.of(upper).plus(lower).dividedBy(2); + return Map.of( + "plot0", toPlot(s, upper), + "plot1", toPlot(s, basis), + "plot2", toPlot(s, lower) + ); + } + + private Map> calcBBPercentB(BarSeries s, Map p) { + int len = intP(p, "length", 20); + double mult = dblP(p, "mult", 2.0); + Indicator src = src(s, p); + SMAIndicator sma = new SMAIndicator(src, len); + StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len); + BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma); + Num k = s.numFactory().numOf(mult); + BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); + BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); + // %B = (price - lower) / (upper - lower) + NumericIndicator pctB = NumericIndicator.of(src) + .minus(lower) + .dividedBy(NumericIndicator.of(upper).minus(lower)); + return single("plot0", s, pctB); + } + + private Map> calcBBBandWidth(BarSeries s, Map p) { + int len = intP(p, "length", 20); + double mult = dblP(p, "mult", 2.0); + Indicator src = src(s, p); + SMAIndicator sma = new SMAIndicator(src, len); + StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len); + BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma); + Num k = s.numFactory().numOf(mult); + BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); + BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); + BollingerBandWidthIndicator bw = new BollingerBandWidthIndicator(upper, mid, lower); + return single("plot0", s, bw); + } + + // ── Oscillators ─────────────────────────────────────────────────────────── + + /** + * RSI + 선택적 신호선 (SMA / EMA / WMA). + * maType == "None" 이면 plot1 을 null 로 채워 비활성 처리. + */ + private Map> calcRSI(BarSeries s, Map p) { + int len = intP(p, "length", 14); + int maLen = intP(p, "maLength", 14); + String maType = p.getOrDefault("maType", "SMA").toString(); + + RSIIndicator rsi = new RSIIndicator(src(s, p), len); + + Indicator maInd = switch (maType) { + case "EMA" -> new EMAIndicator(rsi, maLen); + case "WMA" -> new WMAIndicator(rsi, maLen); + case "None" -> null; + default -> new SMAIndicator(rsi, maLen); + }; + + if (maInd == null) { + int n = s.getBarCount(); + List empty = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + empty.add(PlotPoint.builder().time(t).value(null).build()); + } + return Map.of("plot0", toPlot(s, rsi), "plot1", empty); + } + + return Map.of("plot0", toPlot(s, rsi), "plot1", toPlot(s, maInd)); + } + + /** + * CCI + 선택적 MA 오버레이 (업비트 동일: SMA / EMA / WMA). + * maType == "None" 이면 MA 라인(plot1) 값을 null 로 채워 빈 시리즈로 반환. + */ + private Map> calcCCI(BarSeries s, Map p) { + int len = intP(p, "length", 13); + int maLen = intP(p, "maLength", 20); + String maType = p.getOrDefault("maType", "SMA").toString(); + + CCIIndicator cci = new CCIIndicator(s, len); + + Indicator maInd = switch (maType) { + case "EMA" -> new EMAIndicator(cci, maLen); + case "WMA" -> new WMAIndicator(cci, maLen); + case "None" -> null; + default -> new SMAIndicator(cci, maLen); // SMA + }; + + if (maInd == null) { + // MA 비활성 — plot1 을 null 값으로 채워 반환 + int n = s.getBarCount(); + List empty = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + empty.add(PlotPoint.builder().time(t).value(null).build()); + } + return Map.of("plot0", toPlot(s, cci), "plot1", empty); + } + + return Map.of("plot0", toPlot(s, cci), "plot1", toPlot(s, maInd)); + } + + private Map> calcStochastic(BarSeries s, Map p) { + int kLen = intP(p, "kLength", 14); + int smooth = intP(p, "smooth", 3); + int dSmooth = intP(p, "dSmoothing", 3); + StochasticOscillatorKIndicator k = new StochasticOscillatorKIndicator(s, kLen); + SMAIndicator kSmooth = new SMAIndicator(k, smooth); + SMAIndicator d = new SMAIndicator(kSmooth, dSmooth); + return Map.of("plot0", toPlot(s, kSmooth), "plot1", toPlot(s, d)); + } + + private Map> calcStochRSI(BarSeries s, Map p) { + int rsiLen = intP(p, "lengthRSI", 14); + int stochLen = intP(p, "lengthStoch", 14); + int smoothK = intP(p, "smoothK", 3); + int smoothD = intP(p, "smoothD", 3); + RSIIndicator rsi = new RSIIndicator(src(s, p), rsiLen); + StochasticRSIIndicator stochRsi = new StochasticRSIIndicator(rsi, stochLen); + SMAIndicator k = new SMAIndicator(stochRsi, smoothK); + SMAIndicator d = new SMAIndicator(k, smoothD); + return Map.of("plot0", toPlot(s, k), "plot1", toPlot(s, d)); + } + + private Map> calcAO(BarSeries s) { + // AO = SMA(median,5) - SMA(median,34) + MedianPriceIndicator median = new MedianPriceIndicator(s); + NumericIndicator ao = NumericIndicator.of(new SMAIndicator(median, 5)) + .minus(new SMAIndicator(median, 34)); + return single("plot0", s, ao); + } + + // ── Momentum ────────────────────────────────────────────────────────────── + + private Map> calcMACD(BarSeries s, Map p) { + int fast = intP(p, "fastLength", 12); + int slow = intP(p, "slowLength", 26); + int signal = intP(p, "signalLength", 9); + Indicator src = src(s, p); + MACDIndicator macd = new MACDIndicator(src, fast, slow); + EMAIndicator sigLine = new EMAIndicator(macd, signal); + NumericIndicator histogram = NumericIndicator.of(macd).minus(sigLine); + return Map.of( + "plot0", toPlot(s, histogram), + "plot1", toPlot(s, macd), + "plot2", toPlot(s, sigLine) + ); + } + + private Map> calcMomentumInd(BarSeries s, Map p) { + int len = intP(p, "length", 10); + return single("plot0", s, new ROCIndicator(src(s, p), len)); + } + + private Map> calcROC(BarSeries s, Map p) { + int len = intP(p, "length", 9); + NumericIndicator rocPct = NumericIndicator.of(new ROCIndicator(src(s, p), len)) + .multipliedBy(100); + return single("plot0", s, rocPct); + } + + /** + * TSI (True Strength Index) — 수동 구현 (Ta4j에 없음). + * TSI = 100 * EMA(EMA(diff, longLen), shortLen) / EMA(EMA(absDiff, longLen), shortLen) + */ + private Map> calcTSI(BarSeries s, Map p) { + int longLen = intP(p, "longLength", 25); + int shortLen = intP(p, "shortLength", 13); + int sigLen = intP(p, "signalLength", 13); + int n = s.getBarCount(); + + ClosePriceIndicator close = new ClosePriceIndicator(s); + + // momentum = close - close[1] + List tsiPts = new ArrayList<>(n); + List sigPts = new ArrayList<>(n); + + double[] diff = new double[n]; + double[] absDiff = new double[n]; + for (int i = 0; i < n; i++) { + if (i == 0) { diff[i] = 0; absDiff[i] = 0; } + else { + Double c1 = safe(close.getValue(i)); + Double c0 = safe(close.getValue(i - 1)); + diff[i] = (c1 != null && c0 != null) ? c1 - c0 : 0; + absDiff[i] = Math.abs(diff[i]); + } + } + + double[] ema1d = ema(diff, longLen, n); + double[] ema2d = ema(ema1d, shortLen, n); + double[] ema1a = ema(absDiff, longLen, n); + double[] ema2a = ema(ema1a, shortLen, n); + double[] tsi = new double[n]; + for (int i = 0; i < n; i++) { + tsi[i] = ema2a[i] == 0 ? 0 : 100.0 * ema2d[i] / ema2a[i]; + } + double[] sig = ema(tsi, sigLen, n); + + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + tsiPts.add(new PlotPoint(t, tsi[i], null)); + sigPts.add(new PlotPoint(t, sig[i], null)); + } + return Map.of("plot0", tsiPts, "plot1", sigPts); + } + + // ── Trend ───────────────────────────────────────────────────────────────── + + private Map> calcADX(BarSeries s, Map p) { + int adxSmoothing = intP(p, "adxSmoothing", 14); + int diLen = intP(p, "diLength", 14); + return Map.of( + "plot0", toPlot(s, new ADXIndicator(s, diLen, adxSmoothing)), + "plot1", toPlot(s, new PlusDIIndicator(s, diLen)), + "plot2", toPlot(s, new MinusDIIndicator(s, diLen)) + ); + } + + private Map> calcDMI(BarSeries s, Map p) { + int diLen = intP(p, "diLength", intP(p, "length", 14)); + int adxSmooth = intP(p, "adxSmoothing", 14); + PlusDIIndicator plusDI = new PlusDIIndicator(s, diLen); + MinusDIIndicator minusDI = new MinusDIIndicator(s, diLen); + ADXIndicator adx = new ADXIndicator(s, diLen, adxSmooth); + NumericIndicator dx = NumericIndicator.of(plusDI).minus(minusDI).abs() + .dividedBy(NumericIndicator.of(plusDI).plus(minusDI)) + .multipliedBy(100); + List adxPlot = toPlot(s, adx); + return Map.of( + "plot0", toPlot(s, plusDI), + "plot1", toPlot(s, minusDI), + "plot2", toPlot(s, dx), + "plot3", adxPlot, + "plot4", calcAdxrFromAdx(adxPlot, adxSmooth) + ); + } + + /** ADXR = (ADX + ADX[n]) / 2 — Wilder·업비트 */ + private List calcAdxrFromAdx(List adxPlot, int lag) { + List out = new ArrayList<>(adxPlot.size()); + for (int i = 0; i < adxPlot.size(); i++) { + PlotPoint cur = adxPlot.get(i); + if (i < lag) { + out.add(PlotPoint.builder().time(cur.getTime()).value(Double.NaN).build()); + continue; + } + Double a = cur.getValue(); + Double b = adxPlot.get(i - lag).getValue(); + Double v = (a != null && b != null && !a.isNaN() && !b.isNaN()) ? (a + b) / 2.0 : Double.NaN; + out.add(PlotPoint.builder().time(cur.getTime()).value(v).build()); + } + return out; + } + + private Map> calcAroon(BarSeries s, Map p) { + int len = intP(p, "length", 14); + return Map.of( + "plot0", toPlot(s, new AroonUpIndicator(s, len)), + "plot1", toPlot(s, new AroonDownIndicator(s, len)) + ); + } + + private Map> calcIchimoku(BarSeries s, Map p) { + int conv = intP(p, "conversionPeriods", 9); + int base = intP(p, "basePeriods", 26); + int span2 = intP(p, "laggingSpan2Periods", 52); + int disp = intP(p, "displacement", 26); + IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(s, conv); + IchimokuKijunSenIndicator kijun = new IchimokuKijunSenIndicator(s, base); + IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, disp); + // 선행스팬B displacement 도 spanA·chikou 와 동일한 disp 를 적용해야 한다 + IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, disp); + IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, disp); + return Map.of( + "plot0", toPlot(s, tenkan), + "plot1", toPlot(s, kijun), + "plot2", toPlot(s, spanA), + "plot3", toPlot(s, spanB), + "plot4", toPlot(s, chikou) + ); + } + + private Map> calcParabolicSAR(BarSeries s, Map p) { + double start = dblP(p, "start", 0.02); + double inc = dblP(p, "increment", 0.02); + double max = dblP(p, "maximum", 0.2); + return single("plot0", s, new ParabolicSarIndicator(s, + s.numFactory().numOf(start), s.numFactory().numOf(max), s.numFactory().numOf(inc))); + } + + private Map> calcChoppiness(BarSeries s, Map p) { + int len = intP(p, "length", 14); + // Ta4j ChopIndicator(BarSeries, ciTimeFrame, scaleTo=100) + ChopIndicator chop = new ChopIndicator(s, len, 100); + return single("plot0", s, chop); + } + + private Map> calcMassIndex(BarSeries s, Map p) { + int emaLen = intP(p, "length2", 9); // inner EMA + int total = intP(p, "length", 25); // total period + return single("plot0", s, new MassIndexIndicator(s, emaLen, total)); + } + + // ── Volatility ──────────────────────────────────────────────────────────── + + private Map> calcHV(BarSeries s, Map p) { + int len = intP(p, "length", 20); + int annualBars = intP(p, "annualBars", 252); + int n = s.getBarCount(); + ClosePriceIndicator close = new ClosePriceIndicator(s); + List pts = new ArrayList<>(n); + double[] logRet = new double[n]; + for (int i = 0; i < n; i++) { + if (i == 0) { logRet[i] = 0; continue; } + Double c0 = safe(close.getValue(i - 1)); + Double c1 = safe(close.getValue(i)); + logRet[i] = (c0 != null && c0 > 0 && c1 != null) ? Math.log(c1 / c0) : 0; + } + double factor = Math.sqrt(annualBars) * 100; + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + if (i < len) { pts.add(new PlotPoint(t, null, null)); continue; } + double mean = 0; + for (int j = i - len + 1; j <= i; j++) mean += logRet[j]; + mean /= len; + double var = 0; + for (int j = i - len + 1; j <= i; j++) var += Math.pow(logRet[j] - mean, 2); + double hv = Math.sqrt(var / (len - 1)) * factor; + pts.add(new PlotPoint(t, hv, null)); + } + return Map.of("plot0", pts); + } + + // ── Williams %R / OBV / TRIX / Volume Osc / VR / 이격도 / 심리도 ───────── + + private Map> calcWilliamsR(BarSeries s, Map p) { + int len = intP(p, "length", 14); + // ta4j WilliamsR는 시고저 종가 기준 — 업비트와 동일 + return single("plot0", s, new WilliamsRIndicator(s, len)); + } + + /** 업비트 OBV: OBV + 신호선(SMA/EMA/WMA, 기본 SMA 9) */ + private Map> calcOBV(BarSeries s, Map p) { + OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s); + String maType = p.getOrDefault("maType", "SMA").toString(); + int maLen = intP(p, "maLength", 9); + Indicator maInd = switch (maType) { + case "EMA" -> new EMAIndicator(obv, maLen); + case "WMA" -> new WMAIndicator(obv, maLen); + default -> new SMAIndicator(obv, maLen); + }; + return Map.of("plot0", toPlot(s, obv), "plot1", toPlot(s, maInd)); + } + + /** 업비트 TRIX: ln(종가) → 3×EMA → 1봉 차분 × 10000, 신호선 = TRIX EMA */ + private Map> calcTRIX(BarSeries s, Map p) { + int len = intP(p, "length", 12); + int sigLen = intP(p, "signalLength", 9); + Indicator logClose = UnaryOperationIndicator.log(new ClosePriceIndicator(s)); + EMAIndicator e3 = new EMAIndicator(new EMAIndicator(new EMAIndicator(logClose, len), len), len); + PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); + NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000); + EMAIndicator signal = new EMAIndicator(trix, sigLen); + return Map.of("plot0", toPlot(s, trix), "plot1", toPlot(s, signal)); + } + + private Map> calcVolumeOscillator(BarSeries s, Map p) { + int shortLen = intP(p, "shortLength", 5); + int longLen = intP(p, "longLength", 10); + VolumeIndicator vol = new VolumeIndicator(s, 1); + EMAIndicator shortEma = new EMAIndicator(vol, shortLen); + EMAIndicator longEma = new EMAIndicator(vol, longLen); + NumericIndicator osc = NumericIndicator.of(shortEma).minus(longEma) + .dividedBy(longEma).multipliedBy(100); + return single("plot0", s, osc); + } + + private Map> calcVR(BarSeries s, Map p) { + int period = intP(p, "length", 10); + int n = s.getBarCount(); + List pts = new ArrayList<>(n); + ClosePriceIndicator close = new ClosePriceIndicator(s); + VolumeIndicator vol = new VolumeIndicator(s, 1); + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + if (i < period) { + pts.add(PlotPoint.builder().time(t).value(null).build()); + continue; + } + double upVol = 0, downVol = 0; + for (int j = i - period + 1; j <= i; j++) { + Double c = safe(close.getValue(j)); + Double cPrev = safe(close.getValue(j - 1)); + Double v = safe(vol.getValue(j)); + if (c == null || cPrev == null || v == null) continue; + if (c > cPrev) upVol += v; + else if (c < cPrev) downVol += v; + } + Double val = downVol == 0 ? null : (upVol / downVol) * 100.0; + pts.add(PlotPoint.builder().time(t).value(val).build()); + } + return Map.of("plot0", pts); + } + + /** 업비트 이격도: 종가 ÷ SMA × 100 */ + private Map> calcDisparity(BarSeries s, Map p) { + Indicator source = src(s, p); + Map> result = new LinkedHashMap<>(); + result.put("plot0", disparityLine(s, source, intP(p, "ultraLength", 5))); + result.put("plot1", disparityLine(s, source, intP(p, "shortLength", 10))); + result.put("plot2", disparityLine(s, source, intP(p, "midLength", 20))); + result.put("plot3", disparityLine(s, source, intP(p, "longLength", 60))); + return result; + } + + private List disparityLine(BarSeries s, Indicator source, int period) { + SMAIndicator sma = new SMAIndicator(source, period); + int n = s.getBarCount(); + List pts = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + Double c = safe(source.getValue(i)); + Double m = safe(sma.getValue(i)); + if (c == null || m == null || m == 0) { + pts.add(PlotPoint.builder().time(t).value(null).build()); + } else { + pts.add(PlotPoint.builder().time(t).value(c / m * 100.0).build()); + } + } + return pts; + } + + private Map> calcPsychological(BarSeries s, Map p) { + int period = intP(p, "length", 12); + return Map.of("plot0", psychologicalPoints(s, period, false)); + } + + private Map> calcInvestPsychological(BarSeries s, Map p) { + int period = intP(p, "length", 10); + return Map.of("plot0", psychologicalPoints(s, period, true)); + } + + private List psychologicalPoints(BarSeries s, int period, boolean volumeWeighted) { + int n = s.getBarCount(); + ClosePriceIndicator close = new ClosePriceIndicator(s); + VolumeIndicator vol = new VolumeIndicator(s, 1); + List pts = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Bar bar = s.getBar(i); + long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + if (i < period) { + pts.add(PlotPoint.builder().time(t).value(null).build()); + continue; + } + double val; + if (!volumeWeighted) { + int up = 0; + for (int j = i - period + 1; j <= i; j++) { + Double c = safe(close.getValue(j)); + Double cPrev = safe(close.getValue(j - 1)); + if (c != null && cPrev != null && c > cPrev) up++; + } + val = (double) up / period * 100.0; + } else { + double upVol = 0, total = 0; + for (int j = i - period + 1; j <= i; j++) { + Double c = safe(close.getValue(j)); + Double cPrev = safe(close.getValue(j - 1)); + Double v = safe(vol.getValue(j)); + if (v == null) continue; + total += v; + if (c != null && cPrev != null && c > cPrev) upVol += v; + } + val = total == 0 ? Double.NaN : upVol / total * 100.0; + } + pts.add(PlotPoint.builder().time(t).value(Double.isNaN(val) ? null : val).build()); + } + return pts; + } + + // ── EMA helper (double[], for TSI manual calc) ──────────────────────────── + + private double[] ema(double[] src, int len, int n) { + double[] out = new double[n]; + double k = 2.0 / (len + 1); + out[0] = src[0]; + for (int i = 1; i < n; i++) { + out[i] = src[i] * k + out[i - 1] * (1 - k); + } + return out; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/IndicatorSettingsService.java b/backend/src/main/java/com/goldenchart/service/IndicatorSettingsService.java new file mode 100644 index 0000000..21f8dff --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/IndicatorSettingsService.java @@ -0,0 +1,197 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcIndicatorSettings; +import com.goldenchart.repository.GcIndicatorSettingsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 전역 지표 파라미터 설정 서비스. + * + *

프론트엔드 indicatorRegistry.ts 의 defaultParams 를 완전히 대체하며, + * 사용자가 변경한 파라미터를 DB 에 저장하고 백엔드 지표 계산 시 우선 적용한다.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class IndicatorSettingsService { + + private final GcIndicatorSettingsRepository repo; + private final ObjectMapper mapper; + + // ── 공개 API ───────────────────────────────────────────────────────────── + + /** + * 장치/사용자 식별자로 전체 지표 파라미터 맵을 조회한다. + * 없으면 빈 맵 반환. + * + * @return Map<indicatorType, Map<paramKey, paramValue>> + */ + @Transactional(readOnly = true) + public Map> getAll(Long userId, String deviceId) { + return findEntity(userId, deviceId) + .map(s -> parseParamsJson(s.getParamsJson())) + .orElse(Collections.emptyMap()); + } + + /** + * 특정 지표의 파라미터를 조회한다. + * 없으면 빈 맵 반환. + */ + @Transactional(readOnly = true) + public Map getForType(Long userId, String deviceId, String indicatorType) { + Map> all = getAll(userId, deviceId); + return all.getOrDefault(indicatorType, Collections.emptyMap()); + } + + /** + * 전체 지표 파라미터를 저장(덮어쓰기)한다. + * 프론트엔드에서 전체 설정을 한 번에 저장할 때 사용. + * + * @param allParams Map<indicatorType, Map<paramKey, paramValue>> + */ + public void saveAll(Long userId, String deviceId, + Map> allParams) { + GcIndicatorSettings entity = findOrCreate(userId, deviceId); + entity.setParamsJson(toJson(allParams)); + repo.save(entity); + log.debug("[IndicatorSettings] saved {} indicator types for device={}", allParams.size(), deviceId); + } + + /** + * 특정 지표의 파라미터만 병합(upsert)한다. + * 프론트엔드에서 단일 지표 파라미터를 변경할 때 사용. + * + * @param indicatorType 지표 타입 (e.g. "RSI", "MACD") + * @param params 파라미터 맵 (e.g. {"length": 9, "src": "close"}) + */ + public void saveForType(Long userId, String deviceId, + String indicatorType, Map params) { + GcIndicatorSettings entity = findOrCreate(userId, deviceId); + Map> all = new HashMap<>(parseParamsJson(entity.getParamsJson())); + all.put(indicatorType, params); + entity.setParamsJson(toJson(all)); + repo.save(entity); + log.debug("[IndicatorSettings] saved params for {}:{} device={}", indicatorType, params, deviceId); + } + + // ── 시각 설정 (색상·선굵기·수평선) ────────────────────────────────────── + + /** + * 전체 지표의 시각 설정을 조회한다. + * + * @return Map<indicatorType, Map{plots, hlines}> + */ + @Transactional(readOnly = true) + public Map> getAllVisual(Long userId, String deviceId) { + return findEntity(userId, deviceId) + .map(s -> parseVisualJson(s.getVisualConfigJson())) + .orElse(Collections.emptyMap()); + } + + /** + * 특정 지표의 시각 설정(색상·선굵기·수평선)을 저장(병합)한다. + * + * @param indicatorType 지표 타입 (e.g. "RSI") + * @param visual {@code {plots:[...], hlines:[...]}} 구조의 맵 + */ + public void saveVisualForType(Long userId, String deviceId, + String indicatorType, Map visual) { + GcIndicatorSettings entity = findOrCreate(userId, deviceId); + Map> all = new HashMap<>(parseVisualJson(entity.getVisualConfigJson())); + all.put(indicatorType, visual); + entity.setVisualConfigJson(toJson(all)); + repo.save(entity); + log.debug("[IndicatorSettings] saved visual for {}:{} device={}", indicatorType, visual, deviceId); + } + + /** + * 요청 params 에 없는 키를 DB 설정값으로 채운다. + * IndicatorController 에서 호출 — 하드코딩 기본값 제거. + * + * @param requestParams 프론트엔드가 보낸 파라미터 (null 허용) + * @param indicatorType 지표 타입 + * @return 병합된 파라미터 맵 (DB 값 우선, 없으면 요청값) + */ + @Transactional(readOnly = true) + public Map mergeWithDb(Long userId, String deviceId, + Map requestParams, + String indicatorType) { + Map dbParams = getForType(userId, deviceId, indicatorType); + if (dbParams.isEmpty() && (requestParams == null || requestParams.isEmpty())) { + return Map.of(); // 둘 다 없으면 빈 맵 반환 (IndicatorService 내부 기본값 사용) + } + + // DB 값 기반 + 요청 파라미터로 덮어쓰기 (요청이 명시적으로 보낸 값 우선) + Map merged = new HashMap<>(dbParams); + if (requestParams != null) { + merged.putAll(requestParams); + } + return merged; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private Optional findEntity(Long userId, String deviceId) { + if (userId != null) return repo.findByUserId(userId); + if (deviceId != null) return repo.findByDeviceId(deviceId); + return Optional.empty(); + } + + private GcIndicatorSettings findOrCreate(Long userId, String deviceId) { + return findEntity(userId, deviceId).orElseGet(() -> + repo.save(GcIndicatorSettings.builder() + .userId(userId) + .deviceId(deviceId) + .paramsJson("{}") + .build())); + } + + @SuppressWarnings("unchecked") + private Map> parseParamsJson(String json) { + if (json == null || json.isBlank() || "{}".equals(json.trim())) { + return new HashMap<>(); + } + try { + return mapper.readValue(json, + new TypeReference>>() {}); + } catch (JsonProcessingException e) { + log.warn("[IndicatorSettings] JSON 파싱 실패: {}", e.getMessage()); + return new HashMap<>(); + } + } + + @SuppressWarnings("unchecked") + private Map> parseVisualJson(String json) { + if (json == null || json.isBlank() || "{}".equals(json.trim())) { + return new HashMap<>(); + } + try { + return mapper.readValue(json, + new TypeReference>>() {}); + } catch (JsonProcessingException e) { + log.warn("[IndicatorSettings] visual JSON 파싱 실패: {}", e.getMessage()); + return new HashMap<>(); + } + } + + private String toJson(Object obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("[IndicatorSettings] JSON 직렬화 실패", e); + return "{}"; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java b/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java new file mode 100644 index 0000000..f05a479 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java @@ -0,0 +1,118 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.dto.BacktestSettingsDto; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.repository.GcAppSettingsRepository; +import com.goldenchart.trading.TradingExecutionService; +import com.goldenchart.trading.TradingMode; +import com.goldenchart.trading.pipeline.OrderRequest; +import com.goldenchart.upbit.UpbitOrderApiClient; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 실거래 포지션 틱 단위 손절/익절 — 계좌 스냅샷(30초) + 틱 비교. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class LiveRiskMonitorService { + + private final GcAppSettingsRepository appSettingsRepo; + private final AppSettingsService appSettingsService; + private final BacktestSettingsService backtestSettingsService; + private final UpbitOrderApiClient upbitApi; + private final LiveTradingService liveTradingService; + private final TradingExecutionService tradingExecutionService; + + private final Map exitCooldown = new ConcurrentHashMap<>(); + /** deviceId → currency → {balance, avgPrice} */ + private final Map> accountCache = new ConcurrentHashMap<>(); + + private static final long COOLDOWN_MS = 5_000; + + @PostConstruct + void init() { + refreshAccounts(); + } + + @Scheduled(fixedDelay = 30_000) + public void refreshAccounts() { + for (GcAppSettings app : appSettingsRepo.findAll()) { + if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) continue; + if (!liveTradingService.hasApiKeys(app)) continue; + if (!TradingMode.fromString(app.getTradingMode()).useLive()) continue; + String deviceId = app.getDeviceId(); + if (deviceId == null || deviceId.isBlank()) continue; + try { + var creds = appSettingsService.resolveUpbitCredentials(app); + if (!creds.isComplete()) continue; + JsonNode accounts = upbitApi.getAccounts( + creds.accessKey(), creds.secretKey()); + Map coins = new ConcurrentHashMap<>(); + if (accounts != null && accounts.isArray()) { + for (JsonNode a : accounts) { + String cur = a.path("currency").asText(); + double bal = a.path("balance").asDouble(0); + if (bal <= 0 || "KRW".equals(cur)) continue; + double avg = a.path("avg_buy_price").asDouble(0); + coins.put(cur, new double[]{bal, avg}); + } + } + accountCache.put(deviceId, coins); + } catch (Exception e) { + log.debug("[LiveRisk] account sync {}: {}", deviceId, e.getMessage()); + } + } + } + + public void onTick(String market, double tradePrice) { + if (tradePrice <= 0) return; + String currency = market.startsWith("KRW-") ? market.substring(4) : market; + + for (var entry : accountCache.entrySet()) { + String deviceId = entry.getKey(); + double[] pos = entry.getValue().get(currency); + if (pos == null || pos[0] <= 0 || pos[1] <= 0) continue; + + String key = deviceId + ":" + market; + long now = System.currentTimeMillis(); + Long last = exitCooldown.get(key); + if (last != null && now - last < COOLDOWN_MS) continue; + + GcAppSettings app = appSettingsRepo.findByDeviceId(deviceId).orElse(null); + if (app == null) continue; + + BacktestSettingsDto risk = backtestSettingsService.get(deviceId); + double avg = pos[1]; + double pnlPct = (tradePrice - avg) / avg * 100.0; + + if (Boolean.TRUE.equals(risk.getStopLossEnabled()) + && risk.getStopLossPct() != null + && pnlPct <= -risk.getStopLossPct().doubleValue()) { + exitCooldown.put(key, now); + tradingExecutionService.executeRiskExit( + OrderRequest.stopLoss(deviceId, app.getUserId(), market, tradePrice, + risk.getStopLossPct().doubleValue())); + return; + } + if (Boolean.TRUE.equals(risk.getTakeProfitEnabled()) + && risk.getTakeProfitPct() != null + && pnlPct >= risk.getTakeProfitPct().doubleValue()) { + exitCooldown.put(key, now); + tradingExecutionService.executeRiskExit( + OrderRequest.takeProfit(deviceId, app.getUserId(), market, tradePrice, + risk.getTakeProfitPct().doubleValue())); + return; + } + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java new file mode 100644 index 0000000..cdadd4a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -0,0 +1,325 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.repository.GcStrategyRepository; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ta4j.core.*; +import org.ta4j.core.rules.BooleanRule; + +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 실시간 전략 체크 평가기. + * + *

두 가지 방식으로 전략 만족 여부를 판정한다: + *

    + *
  • 방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거
  • + *
  • 방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정
  • + *
+ * + *

포지션 종속성 모드: + *

    + *
  • LONG_ONLY: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생. + * 시장 진입/청산을 {@code strategy.shouldEnter/shouldExit} 로 판정.
  • + *
  • SIGNAL_ONLY: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정. + * {@code rule.isSatisfied(index)} 를 직접 호출.
  • + *
+ * + *

동시성 안전: + *

    + *
  • ConcurrentHashMap 으로 market×candleType 키에 대한 Strategy 캐시 관리
  • + *
  • LONG_ONLY 모드용 TradingRecord 캐시도 ConcurrentHashMap 으로 관리
  • + *
  • Ta4jStorage.getOrCreate/updateLastBar 는 내부 synchronized 처리
  • + *
+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class LiveStrategyEvaluator { + + private final GcLiveStrategySettingsRepository settingsRepo; + private final GcStrategyRepository strategyRepo; + private final Ta4jStorage ta4jStorage; + private final StrategyDslToTa4jAdapter adapter; + private final IndicatorSettingsService indicatorSettingsService; + private final ObjectMapper objectMapper; + private final StrategySignalDeterminer determiner; + + /** + * Strategy 캐시: "market:candleType:strategyId" → Strategy + * (entryRule / exitRule 포함) + */ + private final ConcurrentHashMap strategyCache = new ConcurrentHashMap<>(); + + /** + * LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord + * 시그널이 발생할 때마다 Record 를 갱신하여 포지션 상태를 유지한다. + */ + private final ConcurrentHashMap tradingRecordCache = new ConcurrentHashMap<>(); + + /** + * LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응. + */ + private final ConcurrentHashMap positionOpenCache = new ConcurrentHashMap<>(); + + /** + * REALTIME_TICK 중복 시그널 방지: "market:candleType" → 마지막으로 시그널을 낸 seriesIndex. + * 동일 provisional 봉에서 3초 스케줄러가 반복 실행되더라도 이미 시그널을 낸 인덱스면 무시. + * 새 provisional 봉(다음 분)이 시작되면 자동으로 다른 index 이므로 갱신된다. + */ + private final ConcurrentHashMap realtimeSignaledIdx = new ConcurrentHashMap<>(); + + // ── 공개 API ───────────────────────────────────────────────────────────── + + /** + * 방식 A — 봉 마감 직후 호출. + * + * @param market 마켓 코드 + * @param candleType 캔들 타입 + * @param maturedIndex 확정된 봉의 BarSeries 인덱스 + * @return "BUY" | "SELL" | "NONE" + */ + public String evaluateCandleClose(String market, String candleType, int maturedIndex) { + List settings = settingsRepo.findActiveByMarket(market); + if (settings.isEmpty()) return "NONE"; + + if (!ta4jStorage.exists(market, candleType)) return "NONE"; + log.debug("[Evaluator] CANDLE_CLOSE 평가: {} {} idx={} barCount={}", + market, candleType, maturedIndex, + ta4jStorage.getOrCreate(market, candleType).getBarCount()); + + for (GcLiveStrategySettings s : settings) { + if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; + if (!"CANDLE_CLOSE".equals(s.getExecutionType())) continue; + if (s.getStrategyId() == null) continue; + + String result = evaluate(market, candleType, s.getStrategyId(), + s.getPositionMode(), maturedIndex, + s.getDeviceId(), s.getUserId()); + if (!"NONE".equals(result)) { + log.info("[Evaluator] CANDLE_CLOSE {} {} idx={} mode={} → {}", + market, candleType, maturedIndex, s.getPositionMode(), result); + return result; + } + } + return "NONE"; + } + + /** + * 방식 B — 3초 스케줄러에서 호출. + * + *

동일 provisional 봉(endIndex)에서 CROSS 시그널이 중복 발화되지 않도록 + * 마지막 시그널 인덱스를 추적한다. 새 분이 시작되면 endIndex가 증가하므로 + * 자동으로 다음 교차 이벤트를 감지할 수 있다. + * + *

캐시 무효화 정책 (Ta4j CachedIndicator memoization 문제 대응)
+ * Ta4j 의 {@code CachedIndicator} 구현체(CCIIndicator, RSIIndicator 등)는 + * {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다. + * {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도 + * 이미 캐시된 값은 변하지 않으므로, CrossedDown/Up Rule 이 구 가격으로 판정한다. + * 이를 방지하기 위해 REALTIME_TICK 평가마다 {@code strategyCache} 를 지워 + * 인디케이터 인스턴스를 강제 재생성한다. + * + * @param market 마켓 코드 + * @param candleType 캔들 타입 + * @return "BUY" | "SELL" | "NONE" + */ + public String evaluateRealtimeTick(String market, String candleType) { + List settings = settingsRepo.findActiveByMarket(market); + if (settings.isEmpty()) return "NONE"; + + if (!ta4jStorage.exists(market, candleType)) return "NONE"; + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + if (series.isEmpty()) return "NONE"; + int currentIndex = series.getEndIndex(); + + // 동일 provisional 봉에서 이미 시그널을 냈으면 중복 발화 방지 + String dedupeKey = market + ":" + candleType; + Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey); + if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE"; + + for (GcLiveStrategySettings s : settings) { + if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; + if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; + if (s.getStrategyId() == null) continue; + + // ★ Ta4j CachedIndicator 캐시 무효화: + // updateLastBar 로 provisional 봉 가격이 바뀌어도 CCI 등 CachedIndicator 는 + // 이미 캐시된 구 값을 반환한다. 전략을 재빌드하면 새 인디케이터 인스턴스를 + // 생성(빈 캐시)하므로 getValue(currentIndex) 가 현재 가격으로 재계산된다. + String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); + strategyCache.remove(cacheKey); + + String result = evaluate(market, candleType, s.getStrategyId(), + s.getPositionMode(), currentIndex, + s.getDeviceId(), s.getUserId()); + if (!"NONE".equals(result)) { + log.info("[Evaluator] REALTIME_TICK {} {} idx={} mode={} → {}", + market, candleType, currentIndex, s.getPositionMode(), result); + realtimeSignaledIdx.put(dedupeKey, currentIndex); + return result; + } + } + return "NONE"; + } + + /** + * 봉 마감 직후 REALTIME_TICK 설정에 대해 확정봉 인덱스로 전략을 평가한다. + * + *

배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는 + * 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다. + * (CrossedDown(N+1) → CCI(N) < threshold, CCI(N+1) < threshold → 교차 없음) + * + *

이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한 + * 타이밍에 REALTIME_TICK 전략도 확정봉 인덱스(N)로 평가하여 누락을 방지한다. + * + * @param market 마켓 코드 + * @param candleType 캔들 타입 + * @param maturedIndex 확정된 봉의 BarSeries 인덱스 + * @return "BUY" | "SELL" | "NONE" + */ + public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) { + List settings = settingsRepo.findActiveByMarket(market); + if (settings.isEmpty()) return "NONE"; + if (!ta4jStorage.exists(market, candleType)) return "NONE"; + + for (GcLiveStrategySettings s : settings) { + if (!Boolean.TRUE.equals(s.getIsLiveCheck())) continue; + if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; + if (s.getStrategyId() == null) continue; + + // CachedIndicator 캐시 무효화 — 확정봉의 최종 close 로 재계산 + String cacheKey = market + ":" + candleType + ":" + s.getStrategyId(); + strategyCache.remove(cacheKey); + + String result = evaluate(market, candleType, s.getStrategyId(), + s.getPositionMode(), maturedIndex, + s.getDeviceId(), s.getUserId()); + if (!"NONE".equals(result)) { + log.info("[Evaluator] REALTIME_TICK @candle_close {} {} idx={} mode={} → {}", + market, candleType, maturedIndex, s.getPositionMode(), result); + realtimeSignaledIdx.put(market + ":" + candleType, maturedIndex); + return result; + } + } + return "NONE"; + } + + /** 설정 변경 시 해당 마켓 캐시 무효화 */ + public void invalidateCache(String market) { + strategyCache.keySet().removeIf(k -> k.startsWith(market + ":")); + tradingRecordCache.keySet().removeIf(k -> k.startsWith(market + ":")); + positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":")); + realtimeSignaledIdx.keySet().removeIf(k -> k.startsWith(market + ":")); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private String evaluate(String market, String candleType, long strategyId, + String positionMode, int index, + String deviceId, Long userId) { + String cacheKey = market + ":" + candleType + ":" + strategyId; + + // computeIfAbsent 는 device context 전달이 불가하므 수동 분기 + Strategy strategy = strategyCache.get(cacheKey); + if (strategy == null) { + strategy = buildStrategy(market, candleType, strategyId, deviceId, userId); + if (strategy != null) strategyCache.put(cacheKey, strategy); + } + + if (strategy == null) return "NONE"; + + try { + String mode = positionMode != null ? positionMode : "LONG_ONLY"; + + if ("SIGNAL_ONLY".equals(mode)) { + // 포지션 락 우회 — 순수 Rule 충족 여부만 판단 + return determiner.determineSignal(strategy, null, index, "SIGNAL_ONLY"); + } + + // LONG_ONLY — TradingRecord 상태를 유지하여 포지션 검증 + BaseTradingRecord record = tradingRecordCache.computeIfAbsent( + cacheKey, k -> new BaseTradingRecord()); + boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey)); + String signal = determiner.determineSignal(strategy, record, index, "LONG_ONLY"); + + // 시그널에 따라 TradingRecord 갱신 및 포지션 상태 추적 + if ("BUY".equals(signal) && !isOpen) { + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + org.ta4j.core.num.Num price = series.getBar(index).getClosePrice(); + record.enter(index, price, series.numFactory().numOf(1)); + positionOpenCache.put(cacheKey, true); + } else if ("SELL".equals(signal) && isOpen) { + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + org.ta4j.core.num.Num price = series.getBar(index).getClosePrice(); + record.exit(index, price, series.numFactory().numOf(1)); + positionOpenCache.put(cacheKey, false); + } else if ("BUY".equals(signal) || "SELL".equals(signal)) { + // 포지션 상태와 불일치 (이미 매수 중인데 또 BUY 등) → 신호 무시 + signal = "NONE"; + } + return signal; + + } catch (Exception e) { + log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage()); + strategyCache.remove(cacheKey); + tradingRecordCache.remove(cacheKey); + } + return "NONE"; + } + + private Strategy buildStrategy(String market, String candleType, long strategyId, + String deviceId, Long userId) { + Optional strategyOpt = strategyRepo.findById(strategyId); + if (strategyOpt.isEmpty()) return null; + + GcStrategy strategy = strategyOpt.get(); + if (!ta4jStorage.exists(market, candleType)) return null; + + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + // 사용자·장치별 지표 파라미터 우선 로드 (없으면 빈 맵 → adapter 기본값 사용) + Map> indicatorParams = + indicatorSettingsService.getAll(userId, deviceId); + + int barCount = series.getBarCount(); + if (barCount < 2) { + log.warn("[Evaluator] 바 수 부족 strategyId={} market={} barCount={} (최소 2 이상 필요)", + strategyId, market, barCount); + return null; + } + log.debug("[Evaluator] Strategy 빌드: strategyId={} market={} barCount={}", strategyId, market, barCount); + + try { + Rule entryRule = buildRule(strategy.getBuyConditionJson(), series, indicatorParams); + Rule exitRule = buildRule(strategy.getSellConditionJson(), series, indicatorParams); + BaseStrategy builtStrategy = new BaseStrategy(entryRule, exitRule); + return builtStrategy; + } catch (Exception e) { + log.error("[Evaluator] Strategy 빌드 실패 strategyId={}: {}", strategyId, e.getMessage()); + return null; + } + } + + private Rule buildRule(String conditionJson, BarSeries series, + Map> params) { + if (conditionJson == null || conditionJson.isBlank()) return new BooleanRule(false); + try { + JsonNode node = objectMapper.readTree(conditionJson); + return adapter.toRule(node, series, params); + } catch (Exception e) { + log.warn("[Evaluator] 조건 JSON 파싱 실패: {}", e.getMessage()); + return new BooleanRule(false); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java new file mode 100644 index 0000000..266aa5e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -0,0 +1,285 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.LiveStrategyBulkRequest; +import com.goldenchart.dto.LiveStrategySettingsDto; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.entity.GcWatchlist; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.repository.GcWatchlistRepository; +import com.goldenchart.websocket.DynamicSubscriptionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class LiveStrategySettingsService { + + private final GcLiveStrategySettingsRepository repo; + private final GcWatchlistRepository watchlistRepo; + private final AppSettingsService appSettingsService; + private final DynamicSubscriptionManager subscriptionManager; + private final LiveStrategyEvaluator liveStrategyEvaluator; + private final LiveStrategyTimeframeService timeframeService; + + // ── 조회 ────────────────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public LiveStrategySettingsDto get(Long userId, String deviceId, String market) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + Optional entity = findEntity(userId, deviceId, market); + LiveStrategySettingsDto dto = entity.isPresent() + ? toDto(entity.get()) + : defaultDtoFromApp(market, app); + // 실시간 체크 마스터 스위치·전략 ID는 앱 전역 설정(gc_app_settings) 기준 + return mergeGlobalTemplate(dto, app, market); + } + + @Transactional(readOnly = true) + public List listActive(Long userId, String deviceId) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { + return List.of(); + } + List watchlist = listWatchlistSymbols(userId, deviceId); + return watchlist.stream() + .map(m -> { + Optional row = findEntity(userId, deviceId, m); + String ct = row.map(GcLiveStrategySettings::getCandleType) + .map(LiveStrategyTimeframeService::normalize) + .orElseGet(() -> timeframeService.resolveCandleType(m, userId, deviceId)); + return LiveStrategySettingsDto.builder() + .market(m) + .strategyId(app.getLiveStrategyId()) + .liveCheck(true) + .executionType(app.getLiveExecutionType()) + .positionMode(app.getLivePositionMode()) + .candleType(ct) + .build(); + }) + .toList(); + } + + // ── 관심종목 연동 (WatchlistService 에서 호출) ───────────────────────────── + + /** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */ + public void enableForWatchlistMarket(Long userId, String deviceId, String market) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + LiveStrategySettingsDto template = defaultDtoFromApp(market, app); + template.setLiveCheck(true); + upsertEntity(userId, deviceId, template); + pinIfReady(market, template); + log.debug("[LiveStrategy] watchlist add → enabled {}", market); + } + + /** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */ + public void disableForWatchlistMarket(Long userId, String deviceId, String market) { + findEntity(userId, deviceId, market).ifPresent(entity -> { + entity.setIsLiveCheck(false); + repo.save(entity); + liveStrategyEvaluator.invalidateCache(market); + }); + log.debug("[LiveStrategy] watchlist remove → disabled {}", market); + } + + // ── 일괄 저장 (레거시 API) ───────────────────────────────────────────────── + + public List saveBulk(Long userId, String deviceId, + LiveStrategyBulkRequest req) { + if (req.getMarkets() == null || req.getMarkets().isEmpty()) { + return List.of(); + } + LiveStrategySettingsDto template = req.getTemplate() != null + ? req.getTemplate() + : defaultDtoFromApp("KRW-BTC", appSettingsService.getEntity(userId, deviceId)); + persistGlobalTemplate(userId, deviceId, template); + List results = new ArrayList<>(); + for (String market : req.getMarkets()) { + if (market == null || market.isBlank()) continue; + LiveStrategySettingsDto one = LiveStrategySettingsDto.builder() + .market(market) + .strategyId(template.getStrategyId()) + .liveCheck(true) + .executionType(template.getExecutionType()) + .positionMode(template.getPositionMode()) + .candleType(template.getCandleType() != null ? template.getCandleType() : "1m") + .build(); + results.add(upsertEntity(userId, deviceId, one)); + } + log.info("[LiveStrategySettings] bulk save: {} markets", results.size()); + return results; + } + + // ── 저장/갱신 — 전역 템플릿 + 관심종목 전체 동기화 ───────────────────────── + + public LiveStrategySettingsDto save(Long userId, String deviceId, + LiveStrategySettingsDto dto) { + persistGlobalTemplate(userId, deviceId, dto); + syncAllWatchlistMarkets(userId, deviceId); + String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; + log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market); + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + LiveStrategySettingsDto out = mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market); + if (dto.getExecutionType() != null) { + out.setExecutionType(dto.getExecutionType()); + } + if (dto.getPositionMode() != null) { + out.setPositionMode(dto.getPositionMode()); + } + if (dto.getCandleType() != null) { + out.setCandleType(LiveStrategyTimeframeService.normalize(dto.getCandleType())); + } + return out; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private void persistGlobalTemplate(Long userId, String deviceId, LiveStrategySettingsDto dto) { + Map patch = new HashMap<>(); + patch.put("liveStrategyCheck", dto.isLiveCheck()); + patch.put("liveStrategyId", dto.getStrategyId()); + patch.put("liveExecutionType", dto.getExecutionType()); + patch.put("livePositionMode", dto.getPositionMode()); + appSettingsService.save(userId, deviceId, patch); + } + + private void syncAllWatchlistMarkets(Long userId, String deviceId) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck()) + && app.getLiveStrategyId() != null; + for (String symbol : listWatchlistSymbols(userId, deviceId)) { + LiveStrategySettingsDto row = defaultDtoFromApp(symbol, app); + row.setLiveCheck(active); + LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row); + if (active) { + pinIfReady(symbol, saved); + } + } + } + + private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId, + LiveStrategySettingsDto dto) { + String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; + GcLiveStrategySettings entity = findEntity(userId, deviceId, market) + .orElseGet(() -> GcLiveStrategySettings.builder() + .userId(userId) + .deviceId(userId == null ? deviceId : null) + .market(market) + .build()); + + applyOwnerKeys(entity, userId, deviceId); + + entity.setStrategyId(dto.getStrategyId()); + entity.setIsLiveCheck(dto.isLiveCheck()); + entity.setExecutionType( + "REALTIME_TICK".equals(dto.getExecutionType()) ? "REALTIME_TICK" : "CANDLE_CLOSE"); + entity.setPositionMode( + "SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"); + entity.setCandleType(LiveStrategyTimeframeService.normalize( + dto.getCandleType() != null ? dto.getCandleType() : "1m")); + + repo.save(entity); + liveStrategyEvaluator.invalidateCache(market); + return toDto(entity); + } + + private void pinIfReady(String market, LiveStrategySettingsDto dto) { + if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) { + String ct = LiveStrategyTimeframeService.normalize( + dto.getCandleType() != null ? dto.getCandleType() : "1m"); + subscriptionManager.ensureMarketPinned(market, ct); + } + } + + private List listWatchlistSymbols(Long userId, String deviceId) { + List list = userId != null + ? watchlistRepo.findByUserIdOrderByDisplayOrderAsc(userId) + : watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc( + deviceId != null ? deviceId : "anonymous"); + return list.stream().map(GcWatchlist::getSymbol).toList(); + } + + /** + * user_id·device_id 어느 쪽으로 저장됐든 동일 마켓 행을 찾는다. + * (게스트 → 로그인 전환 시 device_id 행만 있어도 upsert 가 INSERT 로 중복 나지 않게) + */ + private Optional findEntity(Long userId, String deviceId, + String market) { + if (userId != null) { + Optional byUser = repo.findByUserIdAndMarket(userId, market); + if (byUser.isPresent()) return byUser; + } + if (deviceId != null && !deviceId.isBlank()) { + return repo.findByDeviceIdAndMarket(deviceId, market); + } + return Optional.empty(); + } + + /** 회원: user_id 전용 행 / 비회원: device_id 전용 행 (UK 충돌 방지) */ + private void applyOwnerKeys(GcLiveStrategySettings entity, Long userId, String deviceId) { + if (userId != null) { + entity.setUserId(userId); + entity.setDeviceId(null); + } else { + entity.setUserId(null); + entity.setDeviceId(deviceId != null && !deviceId.isBlank() ? deviceId : null); + } + } + + private LiveStrategySettingsDto toDto(GcLiveStrategySettings e) { + return LiveStrategySettingsDto.builder() + .market(e.getMarket()) + .strategyId(e.getStrategyId()) + .liveCheck(Boolean.TRUE.equals(e.getIsLiveCheck())) + .executionType(e.getExecutionType()) + .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") + .candleType(e.getCandleType() != null ? e.getCandleType() : "1m") + .build(); + } + + /** 앱 전역 실시간 체크 ON/OFF·전략 ID를 DTO에 반영 (종목별 행과 분리) */ + private LiveStrategySettingsDto mergeGlobalTemplate( + LiveStrategySettingsDto dto, GcAppSettings app, String market) { + dto.setMarket(market); + dto.setLiveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())); + dto.setStrategyId(app.getLiveStrategyId()); + if (app.getLiveExecutionType() != null) { + dto.setExecutionType(app.getLiveExecutionType()); + } + if (app.getLivePositionMode() != null) { + dto.setPositionMode(app.getLivePositionMode()); + } + return dto; + } + + private LiveStrategySettingsDto defaultDtoFromApp(String market, GcAppSettings app) { + return LiveStrategySettingsDto.builder() + .market(market) + .strategyId(app.getLiveStrategyId()) + .liveCheck(Boolean.TRUE.equals(app.getLiveStrategyCheck())) + .executionType(app.getLiveExecutionType() != null ? app.getLiveExecutionType() : "CANDLE_CLOSE") + .positionMode(app.getLivePositionMode() != null ? app.getLivePositionMode() : "LONG_ONLY") + .candleType(LiveStrategyTimeframeService.normalize( + app.getDefaultTimeframe() != null + ? mapAppTf(app.getDefaultTimeframe()) : "1m")) + .build(); + } + + private static String mapAppTf(String tf) { + return switch (tf) { + case "1m", "3m", "5m", "15m", "30m", "1h", "4h" -> tf; + case "1D" -> "1d"; + default -> "1m"; + }; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java new file mode 100644 index 0000000..9a59877 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java @@ -0,0 +1,79 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; + +/** + * 종목별 전략 평가 분봉 — DB gc_live_strategy_settings.candle_type. + */ +@Service +@RequiredArgsConstructor +public class LiveStrategyTimeframeService { + + private static final Set ALLOWED = Ta4jStorage.CANDLE_TYPE_MAP.keySet(); + + private final GcLiveStrategySettingsRepository repo; + private final AppSettingsService appSettingsService; + + @Transactional(readOnly = true) + public String resolveCandleType(String market, Long userId, String deviceId) { + Optional row = find(userId, deviceId, market); + if (row.isPresent() && row.get().getCandleType() != null) { + return normalize(row.get().getCandleType()); + } + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + String tf = app.getDefaultTimeframe(); + if (tf != null) { + String mapped = mapChartTfToCandleType(tf); + if (mapped != null) return mapped; + } + return "1m"; + } + + /** + * 워커 스레드 등 user/device 컨텍스트 없을 때 — 해당 마켓 활성 설정의 candle_type. + */ + @Transactional(readOnly = true) + public String resolveCandleTypeForMarket(String market) { + return repo.findActiveByMarket(market).stream() + .map(GcLiveStrategySettings::getCandleType) + .map(LiveStrategyTimeframeService::normalize) + .findFirst() + .orElse("1m"); + } + + public static String normalize(String candleType) { + if (candleType == null || candleType.isBlank()) return "1m"; + String c = candleType.trim().toLowerCase(); + if ("1d".equals(c)) return "1d"; + if ("1h".equals(c)) return "1h"; + if ("4h".equals(c)) return "4h"; + if (ALLOWED.contains(c)) return c; + return "1m"; + } + + private static String mapChartTfToCandleType(String chartTf) { + return switch (chartTf) { + case "1m", "3m", "5m", "15m", "30m" -> chartTf; + case "1h", "4h" -> chartTf; + case "1D" -> "1d"; + case "1W", "1M" -> "1d"; + default -> null; + }; + } + + private Optional find(Long userId, String deviceId, String market) { + if (userId != null) return repo.findByUserIdAndMarket(userId, market); + if (deviceId != null && !deviceId.isBlank()) + return repo.findByDeviceIdAndMarket(deviceId, market); + return Optional.empty(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveTradingService.java b/backend/src/main/java/com/goldenchart/service/LiveTradingService.java new file mode 100644 index 0000000..b8b43c2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LiveTradingService.java @@ -0,0 +1,245 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.dto.LiveOrderRequest; +import com.goldenchart.dto.LiveSummaryDto; +import com.goldenchart.dto.LiveTradeDto; +import com.goldenchart.dto.UpbitApiCredentials; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcLiveTrade; +import com.goldenchart.repository.GcLiveTradeRepository; +import com.goldenchart.trading.TradingMode; +import com.goldenchart.upbit.UpbitOrderApiClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** + * 업비트 실거래 — 시그널·손절/익절 시 주문 실행. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class LiveTradingService { + + private static final RoundingMode RM = RoundingMode.HALF_UP; + private static final double MIN_ORDER_KRW = 5000; + + private final AppSettingsService appSettingsService; + private final UpbitOrderApiClient upbitApi; + private final GcLiveTradeRepository tradeRepo; + + @Transactional(readOnly = true) + public LiveSummaryDto getSummary(Long userId, String deviceId) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); + boolean configured = creds.isComplete(); + double krw = 0; + List positions = new ArrayList<>(); + + if (configured) { + try { + krw = upbitApi.getKrwBalance(creds.accessKey(), creds.secretKey()); + var accounts = upbitApi.getAccounts(creds.accessKey(), creds.secretKey()); + if (accounts != null && accounts.isArray()) { + for (JsonNode a : accounts) { + String cur = a.path("currency").asText(); + if ("KRW".equals(cur)) continue; + double bal = a.path("balance").asDouble(0); + if (bal <= 0) continue; + double avg = a.path("avg_buy_price").asDouble(0); + positions.add(LiveSummaryDto.Position.builder() + .symbol("KRW-" + cur) + .quantity(bal) + .avgPrice(avg) + .build()); + } + } + } catch (Exception e) { + log.warn("[LiveTrading] summary 실패: {}", e.getMessage()); + } + } + + return LiveSummaryDto.builder() + .enabled(Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) + .configured(configured) + .tradingMode(app.getTradingMode() != null ? app.getTradingMode() : "PAPER") + .krwBalance(krw) + .positions(positions) + .build(); + } + + @Transactional(readOnly = true) + public List listTrades(Long userId, String deviceId) { + String dev = resolveDeviceId(deviceId); + return tradeRepo.findTop100ByDeviceIdOrderByCreatedAtDesc(dev) + .stream().map(this::toDto).toList(); + } + + /** + * 전략 시그널·SL/TP 에서 호출. + */ + public void tryExecuteOnSignal(String deviceId, Long userId, String market, + Long strategyId, String signalType, double price, + String source) { + if (deviceId == null || deviceId.isBlank()) return; + if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return; + + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + TradingMode mode = TradingMode.fromString(app.getTradingMode()); + if (!mode.useLive()) return; + if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) return; + UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); + if (!creds.isComplete()) { + log.debug("[LiveTrading] API 키 없음 — skip {}", market); + return; + } + if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && "STRATEGY".equals(source)) return; + + String access = creds.accessKey(); + String secret = creds.secretKey(); + + try { + if ("BUY".equals(signalType)) { + double budgetPct = app.getLiveAutoTradeBudgetPct() != null + ? app.getLiveAutoTradeBudgetPct().doubleValue() + : (app.getPaperAutoTradeBudgetPct() != null + ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95); + double krw = upbitApi.getKrwBalance(access, secret); + double budget = krw * budgetPct / 100.0; + if (budget < MIN_ORDER_KRW) { + log.debug("[LiveTrading] BUY skip: budget={}", budget); + return; + } + JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget); + recordTrade(deviceId, userId, market, "BUY", source, strategyId, res, price, budget); + log.info("[LiveTrading] BUY {} ~{} KRW", market, (long) budget); + } else { + double vol = upbitApi.getCoinBalance(access, secret, market); + if (vol <= 0) { + log.debug("[LiveTrading] SELL skip: no balance {}", market); + return; + } + JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol); + recordTrade(deviceId, userId, market, "SELL", source, strategyId, res, price, vol); + log.info("[LiveTrading] SELL {} vol={}", market, vol); + } + } catch (UpbitOrderApiClient.UpbitApiException e) { + log.warn("[LiveTrading] 주문 실패 {} {}: {}", signalType, market, e.getMessage()); + } + } + + /** + * 알림 모달·수동 주문 — 시장가 매수/매도. + */ + public LiveTradeDto placeManualOrder(Long userId, String deviceId, LiveOrderRequest req) { + if (req == null || req.getMarket() == null || req.getSide() == null) { + throw new IllegalArgumentException("market, side required"); + } + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); + if (!creds.isComplete()) { + throw new IllegalStateException("업비트 API 키가 등록되지 않았습니다."); + } + String access = creds.accessKey(); + String secret = creds.secretKey(); + String market = req.getMarket(); + String side = req.getSide().toUpperCase(); + String dev = resolveDeviceId(deviceId); + + try { + if ("BUY".equals(side)) { + double budget = req.getKrwAmount() != null && req.getKrwAmount() > 0 + ? req.getKrwAmount() + : computeBuyBudget(app, access, secret); + if (budget < MIN_ORDER_KRW) { + throw new IllegalStateException("주문 가능 금액이 부족합니다."); + } + JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget); + recordTrade(dev, userId, market, "BUY", "MANUAL", req.getStrategyId(), res, 0, budget); + return listTrades(userId, deviceId).get(0); + } + if ("SELL".equals(side)) { + double vol = req.getQuantity() != null && req.getQuantity() > 0 + ? req.getQuantity() + : upbitApi.getCoinBalance(access, secret, market); + if (vol <= 0) throw new IllegalStateException("매도 가능 수량이 없습니다."); + JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol); + recordTrade(dev, userId, market, "SELL", "MANUAL", req.getStrategyId(), res, 0, vol); + return listTrades(userId, deviceId).get(0); + } + throw new IllegalArgumentException("side must be BUY or SELL"); + } catch (UpbitOrderApiClient.UpbitApiException e) { + throw new IllegalStateException("업비트 주문 실패: " + e.getMessage(), e); + } + } + + private double computeBuyBudget(GcAppSettings app, String access, String secret) { + double budgetPct = app.getLiveAutoTradeBudgetPct() != null + ? app.getLiveAutoTradeBudgetPct().doubleValue() + : (app.getPaperAutoTradeBudgetPct() != null + ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95); + try { + double krw = upbitApi.getKrwBalance(access, secret); + return krw * budgetPct / 100.0; + } catch (Exception e) { + log.warn("[LiveTrading] balance read failed: {}", e.getMessage()); + return 0; + } + } + + public boolean hasApiKeys(GcAppSettings app) { + return appSettingsService.hasUpbitApiKeys(app); + } + + private void recordTrade(String deviceId, Long userId, String market, String side, + String source, Long strategyId, JsonNode res, + double refPrice, double amountOrVol) { + String uuid = res != null ? res.path("uuid").asText(null) : null; + double executed = res != null ? res.path("executed_volume").asDouble(amountOrVol) : amountOrVol; + double paid = res != null ? res.path("paid_fee").asDouble(0) : 0; + BigDecimal gross = "BUY".equals(side) + ? BigDecimal.valueOf(amountOrVol).setScale(2, RM) + : BigDecimal.valueOf(refPrice * executed).setScale(2, RM); + + tradeRepo.save(GcLiveTrade.builder() + .deviceId(resolveDeviceId(deviceId)) + .userId(userId) + .symbol(market) + .side(side) + .orderKind("market") + .source(source) + .strategyId(strategyId) + .upbitOrderUuid(uuid) + .price(BigDecimal.valueOf(refPrice).setScale(8, RM)) + .quantity(BigDecimal.valueOf(executed).setScale(12, RM)) + .grossAmount(gross) + .feeAmount(BigDecimal.valueOf(paid).setScale(2, RM)) + .state(res != null ? res.path("state").asText("done") : "done") + .build()); + } + + private LiveTradeDto toDto(GcLiveTrade t) { + return LiveTradeDto.builder() + .id(t.getId()) + .symbol(t.getSymbol()) + .side(t.getSide()) + .source(t.getSource()) + .price(t.getPrice().doubleValue()) + .quantity(t.getQuantity().doubleValue()) + .grossAmount(t.getGrossAmount().doubleValue()) + .upbitOrderUuid(t.getUpbitOrderUuid()) + .createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null) + .build(); + } + + private static String resolveDeviceId(String deviceId) { + return deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous"; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java new file mode 100644 index 0000000..59e60e4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -0,0 +1,392 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.*; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcPaperAccount; +import com.goldenchart.entity.GcPaperPosition; +import com.goldenchart.entity.GcPaperTrade; +import com.goldenchart.repository.GcPaperAccountRepository; +import com.goldenchart.repository.GcPaperPositionRepository; +import com.goldenchart.repository.GcPaperTradeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리. + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class PaperTradingService { + + private static final int SCALE_QTY = 12; + private static final int SCALE_KRW = 2; + private static final RoundingMode RM = RoundingMode.HALF_UP; + + private final GcPaperAccountRepository accountRepo; + private final GcPaperPositionRepository positionRepo; + private final GcPaperTradeRepository tradeRepo; + private final AppSettingsService appSettingsService; + + // ── 조회 ───────────────────────────────────────────────────────────────── + + public PaperSummaryDto getSummary(Long userId, String deviceId, Map markPrices) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + + List positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()); + double stockEval = 0; + double unrealized = 0; + List posDtos = new ArrayList<>(); + + for (GcPaperPosition p : positions) { + double qty = p.getQuantity().doubleValue(); + if (qty <= 0) continue; + double avg = p.getAvgPrice().doubleValue(); + Double mark = markPrices != null ? markPrices.get(p.getSymbol()) : null; + double eval = mark != null ? mark * qty : avg * qty; + double pl = mark != null ? (mark - avg) * qty : 0; + double plPct = avg > 0 && mark != null ? (mark - avg) / avg * 100 : 0; + stockEval += eval; + unrealized += pl; + posDtos.add(PaperPositionDto.builder() + .id(p.getId()) + .symbol(p.getSymbol()) + .koreanName(p.getKoreanName()) + .quantity(qty) + .avgPrice(avg) + .markPrice(mark) + .evalAmount(eval) + .profitLoss(pl) + .profitLossPct(plPct) + .build()); + } + + double cash = account.getCashBalance().doubleValue(); + double initial = app.getPaperInitialCapital() != null + ? app.getPaperInitialCapital().doubleValue() : 10_000_000; + double total = cash + stockEval; + double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0; + + return PaperSummaryDto.builder() + .enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled())) + .initialCapital(initial) + .cashBalance(cash) + .stockEvalAmount(stockEval) + .totalAsset(total) + .unrealizedPnl(unrealized) + .realizedPnl(account.getRealizedPnl().doubleValue()) + .totalReturnPct(returnPct) + .feeRatePct(app.getPaperFeeRatePct() != null ? app.getPaperFeeRatePct().doubleValue() : 0.05) + .slippagePct(app.getPaperSlippagePct() != null ? app.getPaperSlippagePct().doubleValue() : 0) + .minOrderKrw(app.getPaperMinOrderKrw() != null ? app.getPaperMinOrderKrw().doubleValue() : 5000) + .autoTradeEnabled(Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) + .autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null + ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95) + .positions(posDtos) + .build(); + } + + public List listTrades(Long userId, String deviceId) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId()) + .stream().map(this::toTradeDto).toList(); + } + + // ── 주문 ───────────────────────────────────────────────────────────────── + + public PaperTradeDto placeOrder(Long userId, String deviceId, PaperOrderRequest req) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) { + throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요."); + } + if (req.getMarket() == null || req.getSide() == null) { + throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다."); + } + String side = req.getSide().toUpperCase(); + if (!"BUY".equals(side) && !"SELL".equals(side)) { + throw new IllegalArgumentException("side는 BUY 또는 SELL 이어야 합니다."); + } + double price = req.getPrice() != null ? req.getPrice() : 0; + if (price <= 0) { + throw new IllegalArgumentException("가격은 0보다 커야 합니다."); + } + + String source = req.getSource() != null ? req.getSource() : "MANUAL"; + String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit"; + double qty = req.getQuantity() != null ? req.getQuantity() : 0; + if (qty <= 0) { + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + qty = resolveAutoQuantity(account, app, req.getMarket(), side, price, + req.getBudgetPct(), req.getKrwAmount()); + } + if (qty <= 0) { + throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다."); + } + + return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source, + req.getStrategyId(), price, qty, null); + } + + /** + * 실시간 전략 시그널 발생 시 자동 모의매매. + * liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행. + */ + public void tryExecuteOnSignal(String deviceId, Long userId, String market, + Long strategyId, String signalType, double price) { + if (deviceId == null || deviceId.isBlank()) return; + if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return; + + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return; + if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return; + if (!Boolean.TRUE.equals(app.getLiveStrategyCheck())) return; + + try { + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + double feeRate = pct(app.getPaperFeeRatePct(), 0.05); + double slip = pct(app.getPaperSlippagePct(), 0); + double minOrder = app.getPaperMinOrderKrw() != null + ? app.getPaperMinOrderKrw().doubleValue() : 5000; + + if ("BUY".equals(signalType)) { + double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); + double cash = account.getCashBalance().doubleValue(); + double budget = cash * budgetPct / 100.0; + double execPrice = price * (1 + slip / 100.0); + double denom = execPrice * (1 + feeRate / 100.0); + if (denom <= 0 || budget < minOrder) { + log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder); + return; + } + double qty = budget / denom; + if (qty * execPrice < minOrder) return; + executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY", + strategyId, price, qty, null); + log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price); + } else { + GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) + .orElse(null); + if (pos == null || pos.getQuantity().doubleValue() <= 0) { + log.debug("[Paper] auto SELL skip: no position {}", market); + return; + } + double qty = pos.getQuantity().doubleValue(); + executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY", + strategyId, price, qty, null); + log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price); + } + } catch (Exception e) { + log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage()); + } + } + + /** 계좌 초기화 — 설정의 초기 자본으로 리셋 */ + public PaperSummaryDto resetAccount(Long userId, String deviceId) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + BigDecimal initial = app.getPaperInitialCapital() != null + ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); + + positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()) + .forEach(positionRepo::delete); + account.setCashBalance(initial); + account.setRealizedPnl(BigDecimal.ZERO); + accountRepo.save(account); + log.info("[Paper] account reset device={} capital={}", deviceId, initial); + return getSummary(userId, deviceId, null); + } + + /** + * 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출. + * 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료)) + * 매도: 보유수량 × budgetPct + */ + private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app, + String market, String side, + double price, Double budgetPct, Double krwAmount) { + double feeRate = pct(app.getPaperFeeRatePct(), 0.05); + double slip = pct(app.getPaperSlippagePct(), 0); + double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95); + + if ("BUY".equals(side)) { + double cash = account.getCashBalance().doubleValue(); + double budget = krwAmount != null && krwAmount > 0 + ? krwAmount + : cash * pctVal / 100.0; + double execPrice = price * (1 + slip / 100.0); + double denom = execPrice * (1 + feeRate / 100.0); + if (denom <= 0 || budget <= 0) { + throw new IllegalStateException("주문 가능 금액이 부족합니다."); + } + return budget / denom; + } + + GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) + .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); + double held = pos.getQuantity().doubleValue(); + if (held <= 0) { + throw new IllegalStateException("보유 수량이 없습니다."); + } + return held * pctVal / 100.0; + } + + // ── 내부 체결 ─────────────────────────────────────────────────────────── + + private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app, + String market, String side, String orderKind, String source, + Long strategyId, double inputPrice, double inputQty, + String koreanName) { + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + double feeRate = pct(app.getPaperFeeRatePct(), 0.05); + double slip = pct(app.getPaperSlippagePct(), 0); + double minOrder = app.getPaperMinOrderKrw() != null + ? app.getPaperMinOrderKrw().doubleValue() : 5000; + + double execPrice = "BUY".equals(side) + ? inputPrice * (1 + slip / 100.0) + : inputPrice * (1 - slip / 100.0); + BigDecimal qty = BigDecimal.valueOf(inputQty).setScale(SCALE_QTY, RM); + BigDecimal price = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM); + BigDecimal gross = price.multiply(qty).setScale(SCALE_KRW, RM); + BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM); + + if (gross.doubleValue() < minOrder) { + throw new IllegalArgumentException("최소 주문 금액은 " + (long) minOrder + " KRW 입니다."); + } + + if ("BUY".equals(side)) { + BigDecimal totalCost = gross.add(fee); + if (account.getCashBalance().compareTo(totalCost) < 0) { + throw new IllegalStateException("주문가능 현금이 부족합니다."); + } + account.setCashBalance(account.getCashBalance().subtract(totalCost)); + upsertPositionBuy(account, market, koreanName, qty, price); + accountRepo.save(account); + + GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder() + .accountId(account.getId()) + .symbol(market) + .side("BUY") + .orderKind(orderKind) + .source(source) + .strategyId(strategyId) + .price(price) + .quantity(qty) + .grossAmount(gross) + .feeAmount(fee) + .netAmount(totalCost.negate()) + .cashAfter(account.getCashBalance()) + .build()); + return toTradeDto(trade); + } + + // SELL + GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) + .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); + if (pos.getQuantity().compareTo(qty) < 0) { + throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다."); + } + + BigDecimal netProceeds = gross.subtract(fee); + BigDecimal costBasis = pos.getAvgPrice().multiply(qty).setScale(SCALE_KRW, RM); + BigDecimal realizedDelta = netProceeds.subtract(costBasis); + account.setCashBalance(account.getCashBalance().add(netProceeds)); + account.setRealizedPnl(account.getRealizedPnl().add(realizedDelta)); + + BigDecimal remaining = pos.getQuantity().subtract(qty); + if (remaining.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) { + positionRepo.delete(pos); + } else { + pos.setQuantity(remaining); + positionRepo.save(pos); + } + accountRepo.save(account); + + GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder() + .accountId(account.getId()) + .symbol(market) + .side("SELL") + .orderKind(orderKind) + .source(source) + .strategyId(strategyId) + .price(price) + .quantity(qty) + .grossAmount(gross) + .feeAmount(fee) + .netAmount(netProceeds) + .cashAfter(account.getCashBalance()) + .build()); + return toTradeDto(trade); + } + + private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName, + BigDecimal qty, BigDecimal price) { + GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) + .orElse(null); + if (pos == null) { + positionRepo.save(GcPaperPosition.builder() + .accountId(account.getId()) + .symbol(market) + .koreanName(koreanName) + .quantity(qty) + .avgPrice(price) + .build()); + return; + } + BigDecimal totalQty = pos.getQuantity().add(qty); + BigDecimal totalCost = pos.getAvgPrice().multiply(pos.getQuantity()).add(price.multiply(qty)); + BigDecimal avg = totalCost.divide(totalQty, SCALE_KRW, RM); + pos.setQuantity(totalQty); + pos.setAvgPrice(avg); + if (koreanName != null) pos.setKoreanName(koreanName); + positionRepo.save(pos); + } + + private GcPaperAccount getOrCreateAccount(Long userId, String deviceId, GcAppSettings app) { + String dev = deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous"; + return accountRepo.findByDeviceId(dev).orElseGet(() -> { + BigDecimal initial = app.getPaperInitialCapital() != null + ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); + GcPaperAccount a = GcPaperAccount.builder() + .userId(userId) + .deviceId(dev) + .cashBalance(initial) + .realizedPnl(BigDecimal.ZERO) + .build(); + return accountRepo.save(a); + }); + } + + private static double pct(BigDecimal v, double def) { + return v != null ? v.doubleValue() : def; + } + + private PaperTradeDto toTradeDto(GcPaperTrade t) { + return PaperTradeDto.builder() + .id(t.getId()) + .symbol(t.getSymbol()) + .side(t.getSide()) + .orderKind(t.getOrderKind()) + .source(t.getSource()) + .strategyId(t.getStrategyId()) + .price(t.getPrice().doubleValue()) + .quantity(t.getQuantity().doubleValue()) + .grossAmount(t.getGrossAmount().doubleValue()) + .feeAmount(t.getFeeAmount().doubleValue()) + .netAmount(t.getNetAmount().doubleValue()) + .cashAfter(t.getCashAfter().doubleValue()) + .createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/RolePermissionService.java b/backend/src/main/java/com/goldenchart/service/RolePermissionService.java new file mode 100644 index 0000000..b53f98e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/RolePermissionService.java @@ -0,0 +1,95 @@ +package com.goldenchart.service; + +import com.goldenchart.auth.MenuIds; +import com.goldenchart.auth.UserRole; +import com.goldenchart.dto.MenuPermissionsResponse; +import com.goldenchart.dto.RolePermissionsUpdateRequest; +import com.goldenchart.entity.GcRoleMenuPermission; +import com.goldenchart.entity.GcUser; +import com.goldenchart.repository.GcRoleMenuPermissionRepository; +import com.goldenchart.repository.GcUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class RolePermissionService { + + private final GcRoleMenuPermissionRepository permRepo; + private final GcUserRepository userRepo; + + @Transactional(readOnly = true) + public MenuPermissionsResponse resolveForUserId(Long userId) { + if (userId == null) { + return buildForRole(UserRole.GUEST.name()); + } + GcUser user = userRepo.findById(userId) + .filter(u -> Boolean.TRUE.equals(u.getEnabled())) + .orElse(null); + if (user == null) { + return buildForRole(UserRole.GUEST.name()); + } + String role = user.getRole() != null ? user.getRole() : UserRole.USER.name(); + return buildForRole(role); + } + + @Transactional(readOnly = true) + public MenuPermissionsResponse buildForRole(String role) { + String r = UserRole.fromString(role).name(); + Map map = defaultMap(); + for (GcRoleMenuPermission p : permRepo.findByRole(r)) { + map.put(p.getMenuId(), Boolean.TRUE.equals(p.getAllowed())); + } + return MenuPermissionsResponse.builder().role(r).permissions(map).build(); + } + + @Transactional(readOnly = true) + public Map> listAllRolePermissions() { + Map> out = new LinkedHashMap<>(); + for (UserRole role : UserRole.values()) { + out.put(role.name(), buildForRole(role.name()).getPermissions()); + } + return out; + } + + @Transactional + public void updateRolePermissions(RolePermissionsUpdateRequest req) { + if (req == null || req.getRole() == null || req.getPermissions() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "role과 permissions가 필요합니다."); + } + String role = UserRole.fromString(req.getRole()).name(); + permRepo.deleteByRole(role); + List rows = new ArrayList<>(); + for (String menuId : MenuIds.ALL) { + Boolean allowed = req.getPermissions().getOrDefault(menuId, false); + rows.add(GcRoleMenuPermission.builder() + .role(role) + .menuId(menuId) + .allowed(allowed) + .build()); + } + permRepo.saveAll(rows); + } + + @Transactional(readOnly = true) + public void requireAdmin(Long userId) { + GcUser user = userRepo.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.")); + if (!UserRole.ADMIN.name().equalsIgnoreCase(user.getRole())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다."); + } + if (!Boolean.TRUE.equals(user.getEnabled())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "비활성화된 계정입니다."); + } + } + + private static Map defaultMap() { + return MenuIds.ALL.stream().collect(Collectors.toMap(id -> id, id -> false, (a, b) -> b, LinkedHashMap::new)); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java new file mode 100644 index 0000000..14d6107 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -0,0 +1,601 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Indicator; +import org.ta4j.core.Rule; +import org.ta4j.core.TradingRecord; +import org.ta4j.core.indicators.*; +import org.ta4j.core.indicators.adx.*; +import org.ta4j.core.indicators.averages.*; +import org.ta4j.core.indicators.bollinger.*; +import org.ta4j.core.indicators.helpers.*; +import org.ta4j.core.indicators.ichimoku.*; +import org.ta4j.core.indicators.statistics.StandardDeviationIndicator; +import org.ta4j.core.indicators.CachedIndicator; +import org.ta4j.core.indicators.helpers.VolumeIndicator; +import org.ta4j.core.indicators.volume.OnBalanceVolumeIndicator; +import org.ta4j.core.indicators.numeric.NumericIndicator; +import org.ta4j.core.indicators.numeric.UnaryOperationIndicator; +import org.ta4j.core.num.Num; +import org.ta4j.core.rules.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * frontend DSL(LogicNode 트리) → Ta4j Rule 변환 어댑터. + * + *

DSL 타입 → 레지스트리(DB) 키 매핑

+ *

frontend strategyTypes.ts 의 PaletteItem.value (DSL indicatorType) 는 + * 대문자 스네이크케이스이지만, indicatorRegistry.ts 의 type 키(=DB 저장 키)는 + * PascalCase 이므로 반드시 변환 후 DB 파라미터를 조회해야 한다.

+ * + *

DSL 구조

+ *
+ * {
+ *   "type": "AND" | "OR" | "NOT" | "CONDITION",
+ *   "children": [ ...LogicNode ],     // AND/OR/NOT 인 경우
+ *   "condition": {                    // CONDITION 인 경우
+ *     "indicatorType":  "CCI",
+ *     "conditionType":  "CROSS_UP",
+ *     "leftField":      "CCI_VALUE",
+ *     "rightField":     "OVERBOUGHT_100",
+ *     "targetValue":    null,
+ *     "slopePeriod":    3,
+ *     "holdDays":       3,
+ *     "candleRange":    1
+ *   }
+ * }
+ * 
+ */ +@Component +@Slf4j +public class StrategyDslToTa4jAdapter { + + /** + * DSL indicatorType(대문자 스네이크) → indicatorRegistry type(PascalCase, DB 키). + * frontend strategyTypes.ts DSL_TO_REGISTRY 와 동기화. + */ + private static final Map DSL_TO_REGISTRY = Map.ofEntries( + Map.entry("RSI", "RSI"), + Map.entry("MACD", "MACD"), + Map.entry("CCI", "CCI"), + Map.entry("ADX", "ADX"), + Map.entry("DMI", "DMI"), + Map.entry("OBV", "OBV"), + Map.entry("TRIX", "TRIX"), + Map.entry("EMA", "EMA"), + Map.entry("MA", "SMA"), + Map.entry("BOLLINGER", "BollingerBands"), + Map.entry("STOCHASTIC", "Stochastic"), + Map.entry("WILLIAMS_R", "WilliamsPercentRange"), + Map.entry("ICHIMOKU", "IchimokuCloud"), + Map.entry("VOLUME_OSC", "VolumeOscillator"), + Map.entry("PSYCHOLOGICAL", "Psychological"), + Map.entry("NEW_PSYCHOLOGICAL", "Psychological"), + Map.entry("INVEST_PSYCHOLOGICAL", "InvestPsychological"), + Map.entry("BWI", "BBBandWidth"), + Map.entry("VR", "VR"), + Map.entry("DISPARITY", "Disparity"), + Map.entry("VOLUME", "VOLUME") + ); + + /** + * DSL indicatorType 을 DB 저장 키(레지스트리 type)로 변환. + * 매핑 없으면 원본 반환 (이미 레지스트리 형식인 경우). + */ + private static String toRegistryKey(String dslType) { + return DSL_TO_REGISTRY.getOrDefault(dslType, dslType); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + public Rule toRule(JsonNode node, BarSeries series, + Map> indicatorParams) { + if (node == null || node.isNull()) return new BooleanRule(false); + String type = node.path("type").asText("CONDITION"); + + return switch (type) { + case "AND" -> buildAndRule(node, series, indicatorParams); + case "OR" -> buildOrRule(node, series, indicatorParams); + case "NOT" -> buildNotRule(node, series, indicatorParams); + default -> buildConditionRule(node.path("condition"), series, indicatorParams); + }; + } + + // ── AND / OR / NOT ──────────────────────────────────────────────────────── + + private Rule buildAndRule(JsonNode node, BarSeries series, + Map> p) { + List rules = childRules(node, series, p); + if (rules.isEmpty()) return new BooleanRule(false); + Rule result = rules.get(0); + for (int i = 1; i < rules.size(); i++) result = new AndRule(result, rules.get(i)); + return result; + } + + private Rule buildOrRule(JsonNode node, BarSeries series, + Map> p) { + List rules = childRules(node, series, p); + if (rules.isEmpty()) return new BooleanRule(false); + Rule result = rules.get(0); + for (int i = 1; i < rules.size(); i++) result = new OrRule(result, rules.get(i)); + return result; + } + + private Rule buildNotRule(JsonNode node, BarSeries series, + Map> p) { + List rules = childRules(node, series, p); + if (rules.isEmpty()) return new BooleanRule(false); + return new NotRule(rules.get(0)); + } + + private List childRules(JsonNode node, BarSeries series, + Map> p) { + List list = new ArrayList<>(); + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode child : children) list.add(toRule(child, series, p)); + } + return list; + } + + // ── CONDITION ───────────────────────────────────────────────────────────── + + private Rule buildConditionRule(JsonNode cond, BarSeries series, + Map> params) { + if (cond == null || cond.isNull()) return new BooleanRule(false); + + String indType = cond.path("indicatorType").asText(""); + String condType = cond.path("conditionType").asText("GT"); + String leftField = cond.path("leftField").asText("NONE"); + String rightField = cond.path("rightField").asText("NONE"); + int slopePeriod = cond.path("slopePeriod").asInt(3); + int holdDays = cond.path("holdDays").asInt(3); + + // DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회 + String registryKey = toRegistryKey(indType); + Map indParams = params != null + ? params.getOrDefault(registryKey, Map.of()) + : Map.of(); + + try { + Indicator left = resolveField(leftField, indType, indParams, series); + Indicator right = resolveField(rightField, indType, indParams, series); + + return switch (condType) { + case "GT" -> new OverIndicatorRule(left, right); + case "LT" -> new UnderIndicatorRule(left, right); + // GTE/LTE: OverIndicator OR 동일 (epsilon 비교) + case "GTE" -> new OrRule(new OverIndicatorRule(left, right), + buildEqRule(left, right, series)); + case "LTE" -> new OrRule(new UnderIndicatorRule(left, right), + buildEqRule(left, right, series)); + case "EQ" -> buildEqRule(left, right, series); + case "NEQ" -> new NotRule(buildEqRule(left, right, series)); + // CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음 + case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right); + case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right); + case "SLOPE_UP" -> new IsRisingRule(left, slopePeriod); + case "SLOPE_DOWN" -> new IsFallingRule(left, slopePeriod); + case "DIFF_GT" -> { + double v = cond.path("compareValue").asDouble(0); + yield new OverIndicatorRule( + NumericIndicator.of(left).minus(right).abs(), + new ConstantIndicator<>(series, series.numFactory().numOf(v))); + } + case "DIFF_LT" -> { + double v = cond.path("compareValue").asDouble(0); + yield new UnderIndicatorRule( + NumericIndicator.of(left).minus(right).abs(), + new ConstantIndicator<>(series, series.numFactory().numOf(v))); + } + case "HOLD_N_DAYS" -> buildHoldRule(new OverIndicatorRule(left, right), holdDays); + // ── 일목균형표 전용 ──────────────────────────────────────────── + case "ABOVE_CLOUD" -> buildCloudAboveRule(series, indParams); + case "BELOW_CLOUD" -> buildCloudBelowRule(series, indParams); + case "IN_CLOUD" -> buildInCloudRule(series, indParams); + case "CLOUD_BREAK_UP" -> buildCloudBreakRule(series, indParams, true); + case "CLOUD_BREAK_DOWN" -> buildCloudBreakRule(series, indParams, false); + case "SPAN1_GT_SPAN2" -> new OverIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); + case "SPAN1_LT_SPAN2" -> new UnderIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); + case "SPAN1_CROSS_UP_SPAN2" -> new CrossedUpIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); + case "SPAN1_CROSS_DOWN_SPAN2" -> new CrossedDownIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); + case "LAGGING_GT_PRICE" -> new OverIndicatorRule(ichimokuLagging(series, indParams), new ClosePriceIndicator(series)); + case "LAGGING_LT_PRICE" -> new UnderIndicatorRule(ichimokuLagging(series, indParams), new ClosePriceIndicator(series)); + default -> { + log.warn("[Adapter] 미지원 conditionType: {}", condType); + yield new BooleanRule(false); + } + }; + } catch (Exception e) { + log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage()); + return new BooleanRule(false); + } + } + + // ── 필드 Resolver ───────────────────────────────────────────────────────── + + private Indicator resolveField(String field, String indType, + Map p, BarSeries s) { + if (field == null || field.isBlank() || field.equals("NONE")) { + return new ConstantIndicator<>(s, s.numFactory().numOf(0)); + } + return switch (field) { + case "CLOSE_PRICE" -> new ClosePriceIndicator(s); + case "OPEN_PRICE" -> new OpenPriceIndicator(s); + case "HIGH_PRICE" -> new HighPriceIndicator(s); + case "LOW_PRICE" -> new LowPriceIndicator(s); + case "VOLUME_VALUE" -> new VolumeIndicator(s, 1); + default -> { + double constant = resolveConstantField(field); + if (!Double.isNaN(constant)) { + yield new ConstantIndicator<>(s, s.numFactory().numOf(constant)); + } + yield resolveIndicatorField(field, indType, p, s); + } + }; + } + + private double resolveConstantField(String field) { + // K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드 + // 예) K_60 → 60, K_-100 → -100, K_0 → 0 + if (field.startsWith("K_")) { + try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException e) { /* fall */ } + } + // NEG 접두사 (레거시): OVERSOLD_NEG80 → -80 + if (field.contains("_NEG")) { + String[] parts = field.split("_NEG"); + try { return -Double.parseDouble(parts[parts.length - 1]); } catch (NumberFormatException e) { /* fall */ } + } + if (field.equals("ZERO_LINE")) return 0.0; + // 마지막 _ 이후 숫자 추출 (레거시): OVERBOUGHT_70 → 70, ADX_25 → 25 + int idx = field.lastIndexOf('_'); + if (idx >= 0 && idx < field.length() - 1) { + try { return Double.parseDouble(field.substring(idx + 1)); } catch (NumberFormatException e) { /* fall */ } + } + return Double.NaN; + } + + private Indicator resolveIndicatorField(String field, String indType, + Map p, BarSeries s) { + ClosePriceIndicator close = new ClosePriceIndicator(s); + + // CCI — 프론트엔드 indicatorRegistry 기본값 20 과 통일 + if (field.equals("CCI_VALUE") || indType.equals("CCI")) + return new CCIIndicator(s, intP(p, "length", 13)); + // RSI — 표준 기본값 14 + if (field.equals("RSI_VALUE")) + return new RSIIndicator(close, intP(p, "length", 14)); + // MACD + if (field.equals("MACD_LINE")) { + return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)); + } + if (field.equals("SIGNAL_LINE")) { + return new EMAIndicator( + new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)), + intP(p, "signalLength", 9)); + } + if (field.equals("HISTOGRAM")) { + MACDIndicator macd = new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)); + return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9))); + } + // ADX / DMI + if (field.equals("ADX_VALUE")) + return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14)); + if (field.equals("PDI") || field.equals("PLUS_DI")) { + int diLen = intP(p, "diLength", intP(p, "length", 14)); + return new PlusDIIndicator(s, diLen); + } + if (field.equals("MDI") || field.equals("MINUS_DI")) { + int diLen = intP(p, "diLength", intP(p, "length", 14)); + return new MinusDIIndicator(s, diLen); + } + // Stochastic Slow — IndicatorService.calcStochastic 과 동일한 로직: + // raw %K = StochasticOscillatorKIndicator(kLen) + // slow %K = SMA(raw %K, smooth) + // %D = SMA(slow %K, dSmoothing) + if (field.equals("STOCH_K")) { + int kLen = intP(p, "kLength", 14); + int smooth = intP(p, "smooth", 3); + return new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth); + } + if (field.equals("STOCH_D")) { + int kLen = intP(p, "kLength", 14); + int smooth = intP(p, "smooth", 3); + int dSmooth = intP(p, "dSmoothing", 3); + SMAIndicator slowK = new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth); + return new SMAIndicator(slowK, dSmooth); + } + // TRIX — ln(종가) → 3×EMA → Δ×10000 (업비트) + if (field.equals("TRIX_VALUE")) { + int len = intP(p, "length", 12); + EMAIndicator e3 = tripleLogEma(close, len); + PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); + return NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000); + } + if (field.equals("TRIX_SIGNAL")) { + int len = intP(p, "length", 12); + EMAIndicator e3 = tripleLogEma(close, len); + PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); + NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000); + return new EMAIndicator(trix, intP(p, "signalLength", 9)); + } + // OBV + if (field.equals("OBV_LINE")) return new OnBalanceVolumeIndicator(s); + if (field.equals("OBV_SIGNAL")) { + OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s); + String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA"; + int maLen = intP(p, "maLength", 9); + return switch (maType) { + case "EMA" -> new EMAIndicator(obv, maLen); + case "WMA" -> new WMAIndicator(obv, maLen); + default -> new SMAIndicator(obv, maLen); + }; + } + // VR + if (field.equals("VR_VALUE") || indType.equals("VR")) + return vrIndicator(s, intP(p, "length", 10)); + // 이격도 DISPARITY5 → period 5 + if (field.startsWith("DISPARITY")) { + int period = parseTrailingInt(field, "DISPARITY", 20); + return disparityIndicator(s, period, p); + } + if (field.equals("PSY_VALUE") || field.equals("NEW_PSY_VALUE") + || indType.equals("PSYCHOLOGICAL") || indType.equals("NEW_PSYCHOLOGICAL")) + return newPsychIndicator(s, intP(p, "length", 12), false); + if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL")) + return newPsychIndicator(s, intP(p, "length", 10), true); + // Williams %R + if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14)); + // Bollinger Bands + if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) { + int len = intP(p, "length", 20); + double mult = dblP(p, "mult", 2.0); + String maType = p.getOrDefault("maType", "SMA").toString(); + Indicator basis = maOfSource(close, s, maType, len); + StandardDeviationIndicator sd = StandardDeviationIndicator.ofSample(close, len); + BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis); + Num k = s.numFactory().numOf(mult); + return switch (field) { + case "UPPER_BAND" -> new BollingerBandsUpperIndicator(mid, sd, k); + case "LOWER_BAND" -> new BollingerBandsLowerIndicator(mid, sd, k); + default -> mid; + }; + } + // MA (SMA) + if (field.startsWith("MA") && !field.startsWith("MACD")) { + try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); } + catch (NumberFormatException ignored) { /* fall */ } + } + if (field.startsWith("EMA")) { + try { return new EMAIndicator(close, Integer.parseInt(field.substring(3))); } + catch (NumberFormatException ignored) { /* fall */ } + } + // 일목균형표 + if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p); + if (field.equals("BASE_LINE")) return ichimokuKijun(s, p); + if (field.equals("LEADING_SPAN1")) return ichimokuSpanA(s, p); + if (field.equals("LEADING_SPAN2")) return ichimokuSpanB(s, p); + if (field.equals("LAGGING_SPAN")) return ichimokuLagging(s, p); + // 거래량 MA + if (field.equals("VOLUME_MA")) return new SMAIndicator(new VolumeIndicator(s, 1), intP(p, "length", 20)); + + log.warn("[Adapter] 미지원 필드: {} (indicatorType={})", field, indType); + return new ClosePriceIndicator(s); + } + + // ── 일목균형표 지표 빌더 ────────────────────────────────────────────────── + + private IchimokuTenkanSenIndicator ichimokuTenkan(BarSeries s, Map p) { + return new IchimokuTenkanSenIndicator(s, intP(p, "conversionPeriods", 9)); + } + private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map p) { + return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26)); + } + private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map p) { + return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p), + intP(p, "displacement", 26)); + } + private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map p) { + // IndicatorService 와 동일하게 displacement 파라미터 반영 + return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52), + intP(p, "displacement", 26)); + } + private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map p) { + return new IchimokuChikouSpanIndicator(s, intP(p, "displacement", 26)); + } + + /** 종가가 구름 위 */ + private Rule buildCloudAboveRule(BarSeries s, Map p) { + NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); + return new OverIndicatorRule(new ClosePriceIndicator(s), cloudTop); + } + /** 종가가 구름 아래 */ + private Rule buildCloudBelowRule(BarSeries s, Map p) { + NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p)); + return new UnderIndicatorRule(new ClosePriceIndicator(s), cloudBottom); + } + /** 종가가 구름 안 */ + private Rule buildInCloudRule(BarSeries s, Map p) { + ClosePriceIndicator close = new ClosePriceIndicator(s); + NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); + NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p)); + return new AndRule( + new OrRule(new OverIndicatorRule(close, cloudBottom), + buildEqRule(close, cloudBottom, s)), + new OrRule(new UnderIndicatorRule(close, cloudTop), + buildEqRule(close, cloudTop, s))); + } + /** 구름 상향(true)/하향(false) 돌파 */ + private Rule buildCloudBreakRule(BarSeries s, Map p, boolean up) { + NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); + NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p)); + ClosePriceIndicator close = new ClosePriceIndicator(s); + if (up) return new CrossedUpIndicatorRule(close, cloudTop); + else return new CrossedDownIndicatorRule(close, cloudBottom); + } + + // ── 범용 Rule 빌더 ──────────────────────────────────────────────────────── + + /** |a - b| < epsilon → equal */ + private Rule buildEqRule(Indicator a, Indicator b, BarSeries series) { + NumericIndicator diff = NumericIndicator.of(a).minus(b).abs(); + return new UnderIndicatorRule(diff, + new ConstantIndicator<>(series, series.numFactory().numOf(1e-9))); + } + + /** N봉 연속으로 inner 규칙이 성립 */ + private Rule buildHoldRule(Rule inner, int n) { + if (n <= 1) return inner; + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord record) { + if (index < n - 1) return false; + for (int i = index - n + 1; i <= index; i++) { + if (!inner.isSatisfied(i, record)) return false; + } + return true; + } + }; + } + + // ── TRIX 헬퍼 ───────────────────────────────────────────────────────────── + + private EMAIndicator tripleLogEma(ClosePriceIndicator close, int len) { + Indicator logClose = UnaryOperationIndicator.log(close); + return new EMAIndicator(new EMAIndicator(new EMAIndicator(logClose, len), len), len); + } + + private Indicator maOfSource(Indicator source, BarSeries s, String maType, int len) { + return switch (maType) { + case "EMA" -> new EMAIndicator(source, len); + case "WMA" -> new WMAIndicator(source, len); + case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len); + default -> new SMAIndicator(source, len); + }; + } + + private int parseTrailingInt(String field, String prefix, int def) { + if (!field.startsWith(prefix)) return def; + try { + return Integer.parseInt(field.substring(prefix.length())); + } catch (NumberFormatException e) { + return def; + } + } + + /** 이격도 = 종가 / SMA(period) * 100 */ + private Indicator disparityIndicator(BarSeries s, int period, Map p) { + ClosePriceIndicator close = new ClosePriceIndicator(s); + SMAIndicator sma = new SMAIndicator(close, period); + return NumericIndicator.of(close).dividedBy(sma).multipliedBy(100); + } + + private Indicator vrIndicator(BarSeries s, int period) { + return new VrIndicator(s, period); + } + + private Indicator newPsychIndicator(BarSeries s, int period, boolean volumeWeighted) { + return new PsychologicalIndicator(s, period, volumeWeighted); + } + + /** VR(거래량비율) = 상승일 거래량합 / 하락일 거래량합 × 100 */ + private static final class VrIndicator extends CachedIndicator { + private final int period; + private final ClosePriceIndicator close; + private final VolumeIndicator volume; + + VrIndicator(BarSeries series, int period) { + super(series); + this.period = period; + this.close = new ClosePriceIndicator(series); + this.volume = new VolumeIndicator(series, 1); + } + + @Override + protected Num calculate(int index) { + if (index < period) return null; + double upVol = 0, downVol = 0; + for (int j = index - period + 1; j <= index; j++) { + Num c = close.getValue(j); + Num cPrev = close.getValue(j - 1); + Num v = volume.getValue(j); + if (c == null || cPrev == null || v == null) continue; + if (c.isGreaterThan(cPrev)) upVol += v.doubleValue(); + else if (c.isLessThan(cPrev)) downVol += v.doubleValue(); + } + if (downVol == 0) return null; + return getBarSeries().numFactory().numOf(upVol / downVol * 100.0); + } + + @Override + public int getCountOfUnstableBars() { + return period + 1; + } + } + + /** 신심리도 / 투자심리도 */ + private static final class PsychologicalIndicator extends CachedIndicator { + private final int period; + private final boolean volumeWeighted; + private final ClosePriceIndicator close; + private final VolumeIndicator volume; + + PsychologicalIndicator(BarSeries series, int period, boolean volumeWeighted) { + super(series); + this.period = period; + this.volumeWeighted = volumeWeighted; + this.close = new ClosePriceIndicator(series); + this.volume = new VolumeIndicator(series, 1); + } + + @Override + protected Num calculate(int index) { + if (index < period) return null; + if (!volumeWeighted) { + int up = 0; + for (int j = index - period + 1; j <= index; j++) { + Num c = close.getValue(j); + Num cPrev = close.getValue(j - 1); + if (c != null && cPrev != null && c.isGreaterThan(cPrev)) up++; + } + return getBarSeries().numFactory().numOf((double) up / period * 100.0); + } + double upVol = 0, total = 0; + for (int j = index - period + 1; j <= index; j++) { + Num c = close.getValue(j); + Num cPrev = close.getValue(j - 1); + Num v = volume.getValue(j); + if (v == null) continue; + total += v.doubleValue(); + if (c != null && cPrev != null && c.isGreaterThan(cPrev)) upVol += v.doubleValue(); + } + if (total == 0) return null; + return getBarSeries().numFactory().numOf(upVol / total * 100.0); + } + + @Override + public int getCountOfUnstableBars() { + return period + 1; + } + } + + // ── 파라미터 헬퍼 ───────────────────────────────────────────────────────── + + private int intP(Map p, String k, int def) { + if (p == null) return def; + Object v = p.get(k); + return v == null ? def : ((Number) v).intValue(); + } + + private double dblP(Map p, String k, double def) { + if (p == null) return def; + Object v = p.get(k); + return v == null ? def : ((Number) v).doubleValue(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java new file mode 100644 index 0000000..0a28835 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -0,0 +1,94 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.StrategyDto; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StrategyService { + + private final GcStrategyRepository repository; + private final ObjectMapper objectMapper; + + // ── 조회 ────────────────────────────────────────────────────────────────── + + public List list(Long userId, String deviceId) { + List entities = userId != null + ? repository.findByUserIdOrderByUpdatedAtDesc(userId) + : repository.findByDeviceIdOrderByUpdatedAtDesc(deviceId != null ? deviceId : ""); + return entities.stream().map(this::toDto).toList(); + } + + public Optional findById(Long id) { + return repository.findById(id).map(this::toDto); + } + + // ── 저장 / 수정 ─────────────────────────────────────────────────────────── + + @Transactional + public StrategyDto save(StrategyDto dto, Long userId, String deviceId) { + GcStrategy entity; + if (dto.getId() != null) { + entity = repository.findById(dto.getId()).orElseGet(GcStrategy::new); + } else { + entity = new GcStrategy(); + entity.setUserId(userId); + entity.setDeviceId(deviceId); + } + + entity.setName(dto.getName() != null ? dto.getName() : "전략"); + entity.setDescription(dto.getDescription()); + if (dto.getEnabled() != null) entity.setEnabled(dto.getEnabled()); + else if (entity.getEnabled() == null) entity.setEnabled(true); + + try { + entity.setBuyConditionJson( + dto.getBuyCondition() != null ? objectMapper.writeValueAsString(dto.getBuyCondition()) : null); + entity.setSellConditionJson( + dto.getSellCondition() != null ? objectMapper.writeValueAsString(dto.getSellCondition()) : null); + } catch (Exception e) { + log.warn("전략 DSL 직렬화 실패: {}", e.getMessage()); + } + + return toDto(repository.save(entity)); + } + + // ── 삭제 ────────────────────────────────────────────────────────────────── + + @Transactional + public void delete(Long id) { + repository.deleteById(id); + } + + // ── 변환 ────────────────────────────────────────────────────────────────── + + private StrategyDto toDto(GcStrategy e) { + StrategyDto dto = new StrategyDto(); + dto.setId(e.getId()); + dto.setName(e.getName()); + dto.setDescription(e.getDescription()); + dto.setEnabled(e.getEnabled()); + dto.setCreatedAt(e.getCreatedAt()); + dto.setUpdatedAt(e.getUpdatedAt()); + try { + if (e.getBuyConditionJson() != null) + dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson())); + if (e.getSellConditionJson() != null) + dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson())); + } catch (Exception ex) { + log.warn("전략 DSL 역직렬화 실패: {}", ex.getMessage()); + } + return dto; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java b/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java new file mode 100644 index 0000000..f91c496 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java @@ -0,0 +1,91 @@ +package com.goldenchart.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.ta4j.core.Rule; +import org.ta4j.core.Strategy; +import org.ta4j.core.TradingRecord; + +/** + * 포지션 종속성 선택에 따른 매매 시그널 판정 코어 컴포넌트. + * + *

모드 설명

+ *
    + *
  • LONG_ONLY — Ta4j 표준 엄격 모드. + * {@code strategy.shouldEnter/shouldExit} 를 사용하므로, + * TradingRecord 에 열린 포지션이 없으면 매도 시그널을 내보내지 않는다.
  • + *
  • SIGNAL_ONLY — 포지션 락 우회 모드. + * 내부 Rule 인스턴스를 직접 호출({@code rule.isSatisfied(index)})하므로, + * 이전 매수 기록 여부와 무관하게 지표 수식만 충족되면 시그널을 확정한다.
  • + *
+ */ +@Component +@Slf4j +public class StrategySignalDeterminer { + + /** + * positionMode 에 따라 포지션 제약 유무를 분기하여 시그널을 결정한다. + * + * @param strategy Ta4j Strategy (entryRule / exitRule 포함) + * @param tradingRecord 현재 TradingRecord 상태 (LONG_ONLY 모드에서 참조) + * @param index BarSeries 인덱스 + * @param positionMode "LONG_ONLY" | "SIGNAL_ONLY" (null → LONG_ONLY 기본 적용) + * @return "BUY" | "SELL" | "NONE" + */ + public String determineSignal(Strategy strategy, TradingRecord tradingRecord, + int index, String positionMode) { + try { + if ("SIGNAL_ONLY".equals(positionMode)) { + return determineSignalOnly(strategy, index); + } + // LONG_ONLY (기본) + return determineLongOnly(strategy, tradingRecord, index); + } catch (Exception e) { + log.warn("[SignalDeterminer] 시그널 판정 오류 idx={} mode={}: {}", index, positionMode, e.getMessage()); + return "NONE"; + } + } + + // ── 보조 오버로드: Rule[] 직접 전달 (LiveStrategyEvaluator 호환) ───────────── + + /** + * Rule 배열을 직접 받아 positionMode 에 따라 시그널 판정. + * entryRule = rules[0], exitRule = rules[1] + */ + public String determineSignalFromRules(Rule entryRule, Rule exitRule, + TradingRecord tradingRecord, + int index, String positionMode) { + try { + if ("SIGNAL_ONLY".equals(positionMode)) { + boolean enterOk = entryRule != null && entryRule.isSatisfied(index); + boolean exitOk = exitRule != null && exitRule.isSatisfied(index); + if (enterOk) return "BUY"; + if (exitOk) return "SELL"; + return "NONE"; + } + // LONG_ONLY — tradingRecord 를 이용한 엄격 모드 + boolean enterOk = entryRule != null && entryRule.isSatisfied(index, tradingRecord); + boolean exitOk = exitRule != null && exitRule.isSatisfied(index, tradingRecord); + if (enterOk) return "BUY"; + if (exitOk) return "SELL"; + return "NONE"; + } catch (Exception e) { + log.warn("[SignalDeterminer] Rule 판정 오류 idx={} mode={}: {}", index, positionMode, e.getMessage()); + return "NONE"; + } + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private String determineLongOnly(Strategy strategy, TradingRecord record, int index) { + if (strategy.shouldEnter(index, record)) return "BUY"; + if (strategy.shouldExit(index, record)) return "SELL"; + return "NONE"; + } + + private String determineSignalOnly(Strategy strategy, int index) { + if (strategy.getEntryRule().isSatisfied(index)) return "BUY"; + if (strategy.getExitRule().isSatisfied(index)) return "SELL"; + return "NONE"; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java new file mode 100644 index 0000000..86efedc --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java @@ -0,0 +1,88 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.TradeSignalDto; +import com.goldenchart.entity.GcTradeSignal; +import com.goldenchart.repository.GcTradeSignalRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 매매 시그널 이력 서비스. + * 실시간 전략 체크(LiveStrategyEvaluator / LiveStrategyScheduler)에서 + * BUY/SELL 시그널이 발생하면 이 서비스를 통해 DB에 저장한다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class TradeSignalService { + + private final GcTradeSignalRepository repo; + private final FcmPushService fcmPushService; + + // ── 저장 ───────────────────────────────────────────────────────────────── + + public TradeSignalDto save(String deviceId, Long userId, String market, + Long strategyId, String strategyName, + String signalType, double price, + long candleTime, String candleType, + String executionType) { + GcTradeSignal entity = GcTradeSignal.builder() + .deviceId(deviceId) + .userId(userId) + .market(market) + .strategyId(strategyId) + .strategyName(strategyName) + .signalType(signalType) + .price(price) + .candleTime(candleTime) + .candleType(candleType) + .executionType(executionType) + .build(); + GcTradeSignal saved = repo.save(entity); + log.info("[TradeSignal] saved {} {} @ {} market={}", signalType, candleType, price, market); + if (deviceId != null) { + fcmPushService.sendTradeSignalIfEnabled( + deviceId, userId, market, signalType, price, strategyName, saved.getId()); + } + return toDto(saved); + } + + // ── 조회 ───────────────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public List list(Long userId, String deviceId) { + List list = userId != null + ? repo.findByUserIdOrderByCreatedAtDesc(userId) + : repo.findByDeviceIdOrderByCreatedAtDesc(deviceId != null ? deviceId : ""); + return list.stream().map(this::toDto).toList(); + } + + @Transactional(readOnly = true) + public List listByMarket(Long userId, String deviceId, String market) { + return repo.findByDeviceIdAndMarketOrderByCreatedAtDesc( + deviceId != null ? deviceId : "", market) + .stream().map(this::toDto).toList(); + } + + // ── 변환 ───────────────────────────────────────────────────────────────── + + private TradeSignalDto toDto(GcTradeSignal e) { + return TradeSignalDto.builder() + .id(e.getId()) + .market(e.getMarket()) + .strategyId(e.getStrategyId()) + .strategyName(e.getStrategyName()) + .signalType(e.getSignalType()) + .price(e.getPrice()) + .candleTime(e.getCandleTime()) + .candleType(e.getCandleType()) + .executionType(e.getExecutionType()) + .createdAt(e.getCreatedAt() != null ? e.getCreatedAt().toString() : null) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/WatchlistService.java b/backend/src/main/java/com/goldenchart/service/WatchlistService.java new file mode 100644 index 0000000..ebddc83 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/WatchlistService.java @@ -0,0 +1,80 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcWatchlist; +import com.goldenchart.repository.GcWatchlistRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * 관심종목(DB) 관리 + 실시간 전략 체크 대상 자동 연동. + * + *

별표로 관심 등록 시 {@code gc_watchlist} 저장과 동시에 + * 해당 종목을 실시간 전략 체크 대상({@code gc_live_strategy_settings})으로 등록한다. + * 해제 시 전략 체크도 함께 비활성화한다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class WatchlistService { + + private final GcWatchlistRepository watchlistRepo; + private final LiveStrategySettingsService liveStrategySettingsService; + + @Transactional(readOnly = true) + public List list(Long userId, String deviceId) { + if (userId != null) return watchlistRepo.findByUserIdOrderByDisplayOrderAsc(userId); + return watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId != null ? deviceId : "anonymous"); + } + + public GcWatchlist add(Long userId, String deviceId, String symbol, + String koreanName, String englishName, Integer displayOrder) { + if (symbol == null || symbol.isBlank()) { + throw new IllegalArgumentException("symbol is required"); + } + Optional existing = findOne(userId, deviceId, symbol); + if (existing.isPresent()) { + liveStrategySettingsService.enableForWatchlistMarket(userId, deviceId, symbol); + return existing.get(); + } + + int order = displayOrder != null ? displayOrder : nextOrder(userId, deviceId); + GcWatchlist item = GcWatchlist.builder() + .userId(userId) + .deviceId(userId == null ? deviceId : null) + .symbol(symbol) + .koreanName(koreanName) + .englishName(englishName) + .displayOrder(order) + .build(); + watchlistRepo.save(item); + + liveStrategySettingsService.enableForWatchlistMarket(userId, deviceId, symbol); + log.info("[Watchlist] added {} → live strategy enabled", symbol); + return item; + } + + public void remove(Long userId, String deviceId, String symbol) { + if (userId != null) watchlistRepo.deleteByUserIdAndSymbol(userId, symbol); + else watchlistRepo.deleteByDeviceIdAndSymbol(deviceId != null ? deviceId : "anonymous", symbol); + + liveStrategySettingsService.disableForWatchlistMarket(userId, deviceId, symbol); + log.info("[Watchlist] removed {} → live strategy disabled", symbol); + } + + private Optional findOne(Long userId, String deviceId, String symbol) { + return list(userId, deviceId).stream() + .filter(w -> symbol.equals(w.getSymbol())) + .findFirst(); + } + + private int nextOrder(Long userId, String deviceId) { + List list = list(userId, deviceId); + return list.isEmpty() ? 0 : list.get(list.size() - 1).getDisplayOrder() + 1; + } +} diff --git a/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java b/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java new file mode 100644 index 0000000..c6a56a6 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java @@ -0,0 +1,252 @@ +package com.goldenchart.storage; + +import com.goldenchart.trading.pipeline.TradingPipelineProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 인메모리 실시간 시세 저장소 (싱글톤 서비스). + * + * 구조: ConcurrentHashMap> + * - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드 + * - candleType: "1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d" + * + * 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용. + * 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸. + */ +@Service +@Slf4j +public class Ta4jStorage { + + /** 인메모리 최대 캔들 보관 수 (기본 — 파이프라인 비활성 시) */ + public static final int MAX_BAR_COUNT = 1800; + + private final TradingPipelineProperties pipelineProperties; + + public Ta4jStorage(TradingPipelineProperties pipelineProperties) { + this.pipelineProperties = pipelineProperties; + } + + /** 외부 캔들 요청 1회당 기본 배치 크기 */ + public static final int FETCH_BATCH = 200; + + /** 지원 캔들 타입 → 업비트 API 경로 세그먼트 매핑 */ + public static final Map CANDLE_TYPE_MAP = Map.of( + "1m", "minutes/1", + "3m", "minutes/3", + "5m", "minutes/5", + "15m", "minutes/15", + "30m", "minutes/30", + "1h", "minutes/60", + "4h", "minutes/240", + "1d", "days" + ); + + // ── 인메모리 저장소 ─────────────────────────────────────────────────────── + // 2중 ConcurrentHashMap: market → (candleType → BarSeries) + private final ConcurrentHashMap> storage = + new ConcurrentHashMap<>(); + + // ── BarSeries 접근 ──────────────────────────────────────────────────────── + + /** + * 종목·타입의 BarSeries 를 반환. 없으면 새로 생성한다. + */ + public BarSeries getOrCreate(String market, String candleType) { + return storage + .computeIfAbsent(market, k -> new ConcurrentHashMap<>()) + .computeIfAbsent(candleType, k -> createSeries(market, candleType)); + } + + /** 종목·타입의 BarSeries 가 이미 존재하는지 확인 */ + public boolean exists(String market, String candleType) { + Map inner = storage.get(market); + return inner != null && inner.containsKey(candleType); + } + + /** 종목·타입의 BarSeries 삭제 (GC 시 사용) */ + public void remove(String market, String candleType) { + Map inner = storage.get(market); + if (inner != null) { + inner.remove(candleType); + if (inner.isEmpty()) storage.remove(market); + log.info("[Ta4jStorage] 종목 제거: {} / {}", market, candleType); + } + } + + // ── 캔들 추가 ───────────────────────────────────────────────────────────── + + /** + * Bar 를 시리즈에 추가한다. + * + *

Ta4j {@code addBar(bar, replace=true)} 는 endTime 비교 없이 무조건 마지막 슬롯을 + * 덮어쓴다. 이 동작은 진행 중 봉(provisional)을 추가할 때 방금 확정된 봉을 파괴하는 + * 버그를 유발한다. 따라서 마지막 봉의 endTime 을 직접 비교해 교체 여부를 결정한다. + * + *

    + *
  • endTime 동일 → replace (진행 중 봉 → 확정봉으로 교체, 또는 warm-up 재추가)
  • + *
  • endTime 다름 → append (신규 봉 추가)
  • + *
+ */ + public synchronized void addBar(String market, String candleType, Bar bar) { + BarSeries series = getOrCreate(market, candleType); + try { + boolean sameEndTime = !series.isEmpty() + && series.getLastBar().getEndTime().equals(bar.getEndTime()); + series.addBar(bar, sameEndTime); + } catch (Exception e) { + log.warn("[Ta4jStorage] addBar 오류 ({}/{}): {}", market, candleType, e.getMessage()); + } + } + + /** + * 현재 진행 중인 Bar 의 가격·거래량 실시간 갱신. (틱 수신 시) + */ + public synchronized void updateLastBar(String market, String candleType, + double price, double volume) { + BarSeries series = getOrCreate(market, candleType); + if (series.isEmpty()) return; + try { + // Ta4j 0.22: addPrice(Num) — 종가 갱신 (고가/저가/거래량은 내부에서 자동 처리) + series.addPrice(DoubleNumFactory.getInstance().numOf(price)); + } catch (Exception e) { + log.warn("[Ta4jStorage] updateLastBar 오류 ({}/{}): {}", market, candleType, e.getMessage()); + } + } + + // ── 조회 ────────────────────────────────────────────────────────────────── + + /** + * 인메모리 캔들 전체를 시간순(오래된 순) 리스트로 반환. + */ + public List getBars(String market, String candleType) { + if (!exists(market, candleType)) return Collections.emptyList(); + BarSeries series = getOrCreate(market, candleType); + if (series.isEmpty()) return Collections.emptyList(); + List bars = new ArrayList<>(); + for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { + bars.add(series.getBar(i)); + } + return bars; + } + + /** 인메모리에 실제 봉이 1개 이상 있는지 */ + public boolean hasBars(String market, String candleType) { + return !getBars(market, candleType).isEmpty(); + } + + /** + * 인메모리에서 가장 오래된 캔들 시간 반환. 없으면 empty. + * Ta4j 0.22: Bar.getEndTime() 은 Instant 반환 → ZonedDateTime 변환 + */ + public Optional getOldestTime(String market, String candleType) { + if (!exists(market, candleType)) return Optional.empty(); + BarSeries series = getOrCreate(market, candleType); + if (series.isEmpty()) return Optional.empty(); + return Optional.of(series.getFirstBar().getEndTime().atZone(ZoneOffset.UTC)); + } + + /** + * 인메모리에서 가장 최신 캔들 시간 반환. 없으면 empty. + */ + public Optional getLatestTime(String market, String candleType) { + if (!exists(market, candleType)) return Optional.empty(); + BarSeries series = getOrCreate(market, candleType); + if (series.isEmpty()) return Optional.empty(); + return Optional.of(series.getLastBar().getEndTime().atZone(ZoneOffset.UTC)); + } + + /** + * 인메모리 캔들 수 반환. + */ + public int getBarCount(String market, String candleType) { + if (!exists(market, candleType)) return 0; + return getOrCreate(market, candleType).getBarCount(); + } + + /** + * 현재 구독(메모리) 중인 종목 목록 반환. + */ + public List getSubscribedMarkets() { + return new ArrayList<>(storage.keySet()); + } + + /** 대시보드·파이프라인 모니터링용 인메모리 저장소 통계 */ + public Map storageMetrics() { + int markets = storage.size(); + int series = 0; + int totalBars = 0; + for (var inner : storage.values()) { + series += inner.size(); + for (BarSeries s : inner.values()) { + totalBars += s.getBarCount(); + } + } + int maxBars = pipelineProperties.isEnabled() + ? Math.max(200, pipelineProperties.getMaxBarsPerSeries()) + : MAX_BAR_COUNT; + return Map.of( + "markets", markets, + "series", series, + "totalBars", totalBars, + "maxBarsPerSeries", maxBars + ); + } + + // ── 내부 유틸 ───────────────────────────────────────────────────────────── + + private BarSeries createSeries(String market, String candleType) { + int maxBars = pipelineProperties.isEnabled() + ? Math.max(200, pipelineProperties.getMaxBarsPerSeries()) + : MAX_BAR_COUNT; + BarSeries series = new BaseBarSeriesBuilder() + .withName(market + ":" + candleType) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22: withNumFactory + .withMaxBarCount(maxBars) + .build(); + log.info("[Ta4jStorage] BarSeries 생성: {} / {} (maxBarCount={})", market, candleType, maxBars); + return series; + } + + /** 캔들 타입 문자열 → Duration 변환 (package→public) */ + public static Duration parseDuration(String candleType) { + return switch (candleType) { + case "1m" -> Duration.ofMinutes(1); + case "3m" -> Duration.ofMinutes(3); + case "5m" -> Duration.ofMinutes(5); + case "15m" -> Duration.ofMinutes(15); + case "30m" -> Duration.ofMinutes(30); + case "1h" -> Duration.ofHours(1); + case "4h" -> Duration.ofHours(4); + case "1d" -> Duration.ofDays(1); + default -> Duration.ofMinutes(1); + }; + } + + /** Unix 초 타임스탬프 → ZonedDateTime (UTC) */ + public static ZonedDateTime epochToZdt(long epochSeconds) { + return Instant.ofEpochSecond(epochSeconds).atZone(ZoneOffset.UTC); + } + + /** Unix 초 타임스탬프 → Instant */ + public static Instant epochToInstant(long epochSeconds) { + return Instant.ofEpochSecond(epochSeconds); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java b/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java new file mode 100644 index 0000000..2fa0ab4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java @@ -0,0 +1,52 @@ +package com.goldenchart.trading; + +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.service.AppSettingsService; +import com.goldenchart.service.LiveTradingService; +import com.goldenchart.service.PaperTradingService; +import com.goldenchart.trading.pipeline.OrderRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 운영 모드(PAPER / LIVE / BOTH)에 따라 모의·실거래 실행을 분기한다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TradingExecutionService { + + private final AppSettingsService appSettingsService; + private final PaperTradingService paperTradingService; + private final LiveTradingService liveTradingService; + + public void executeSignal(String deviceId, Long userId, String market, + Long strategyId, String side, double price) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + TradingMode mode = TradingMode.fromString(app.getTradingMode()); + + if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) { + paperTradingService.tryExecuteOnSignal(deviceId, userId, market, strategyId, side, price); + } + if (mode.useLive()) { + liveTradingService.tryExecuteOnSignal( + deviceId, userId, market, strategyId, side, price, "STRATEGY"); + } + } + + public void executeRiskExit(OrderRequest req) { + GcAppSettings app = appSettingsService.getEntity(req.userId(), req.deviceId()); + TradingMode mode = TradingMode.fromString(app.getTradingMode()); + String source = req.kind() == OrderRequest.OrderKind.STOP_LOSS ? "STOP_LOSS" : "TAKE_PROFIT"; + + if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) { + paperTradingService.tryExecuteOnSignal( + req.deviceId(), req.userId(), req.market(), null, "SELL", req.price()); + } + if (mode.useLive()) { + liveTradingService.tryExecuteOnSignal( + req.deviceId(), req.userId(), req.market(), null, "SELL", req.price(), source); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/TradingMode.java b/backend/src/main/java/com/goldenchart/trading/TradingMode.java new file mode 100644 index 0000000..9933e43 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/TradingMode.java @@ -0,0 +1,27 @@ +package com.goldenchart.trading; + +/** + * 자동매매 실행 대상: 모의 / 실거래 / 병행. + */ +public enum TradingMode { + PAPER, + LIVE, + BOTH; + + public static TradingMode fromString(String raw) { + if (raw == null || raw.isBlank()) return PAPER; + try { + return valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return PAPER; + } + } + + public boolean usePaper() { + return this == PAPER || this == BOTH; + } + + public boolean useLive() { + return this == LIVE || this == BOTH; + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java new file mode 100644 index 0000000..3ad4f43 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java @@ -0,0 +1,161 @@ +package com.goldenchart.trading.pipeline; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.storage.Ta4jStorage; +import com.goldenchart.websocket.UpbitWebSocketClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 5분마다 REST 로 최근 캔들을 조회해 WS 갭을 멱등적으로 보정한다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class CandleGapBackfillService { + + private final Ta4jStorage ta4jStorage; + private final UpbitWebSocketClient upbitWebSocketClient; + private final TradingPipelineProperties props; + private final WebClient.Builder webClientBuilder; + private final ObjectMapper objectMapper; + + @Value("${upbit.api.base-url:https://api.upbit.com}") + private String upbitBaseUrl; + + @Value("${upbit.api.request-delay-ms:110}") + private long requestDelayMs; + + private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}; + + private volatile long lastRunAtMs; + private final AtomicInteger lastRunMarkets = new AtomicInteger(); + private final AtomicLong lastRunBarsMerged = new AtomicLong(); + private volatile String lastRunError = ""; + + public Map monitorSnapshot() { + Map m = new LinkedHashMap<>(); + m.put("cron", props.getGapBackfillCron()); + m.put("candleCount", props.getGapBackfillCandleCount()); + m.put("lastRunAtMs", lastRunAtMs); + m.put("lastRunMarkets", lastRunMarkets.get()); + m.put("lastRunBarsMerged", lastRunBarsMerged.get()); + m.put("lastRunError", lastRunError); + return m; + } + + @Scheduled(cron = "${goldenchart.trading.pipeline.gap-backfill-cron:0 */5 * * * *}") + public void scheduledBackfill() { + if (!props.isEnabled()) return; + Set markets = upbitWebSocketClient.getSubscribedMarkets(); + if (markets.isEmpty()) return; + + lastRunAtMs = System.currentTimeMillis(); + lastRunMarkets.set(markets.size()); + lastRunBarsMerged.set(0); + lastRunError = ""; + + int count = props.getGapBackfillCandleCount(); + long mergedTotal = 0; + for (String market : markets) { + for (String type : BACKFILL_TYPES) { + if (!ta4jStorage.exists(market, type)) continue; + try { + Thread.sleep(requestDelayMs); + mergedTotal += backfillMarket(market, type, count); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (Exception e) { + lastRunError = e.getMessage(); + log.debug("[GapBackfill] {} {}: {}", market, type, e.getMessage()); + } + } + } + lastRunBarsMerged.set(mergedTotal); + } + + int backfillMarket(String market, String candleType, int count) throws Exception { + String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1"); + Duration duration = Ta4jStorage.parseDuration(candleType); + String url = upbitBaseUrl + "/v1/candles/" + path + + "?market=" + market + "&count=" + count; + + String json = webClientBuilder.build() + .get().uri(url) + .retrieve().bodyToMono(String.class).block(); + if (json == null || json.isBlank()) return 0; + + List> page = + objectMapper.readValue(json, new TypeReference<>() {}); + if (page.isEmpty()) return 0; + + page.sort(Comparator.comparingLong(r -> + parseEpoch((String) r.get("candle_date_time_utc")))); + + int merged = 0; + for (Map r : page) { + long epochSec = parseEpoch((String) r.get("candle_date_time_utc")); + Instant endInstant = Instant.ofEpochSecond(epochSec).plus(duration); + double open = toDouble(r.get("opening_price")); + double high = toDouble(r.get("high_price")); + double low = toDouble(r.get("low_price")); + double close = toDouble(r.get("trade_price")); + double vol = toDouble(r.get("candle_acc_trade_volume")); + + Bar bar = new BaseBarSeriesBuilder() + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) + .build() + .barBuilder() + .timePeriod(duration) + .endTime(endInstant) + .openPrice(open).highPrice(high).lowPrice(low) + .closePrice(close).volume(vol) + .build(); + + ta4jStorage.addBar(market, candleType, bar); + merged++; + } + if (merged > 0) { + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + log.debug("[GapBackfill] {} {} — REST {}봉 병합 (series={})", + market, candleType, merged, series.getBarCount()); + } + return merged; + } + + private static long parseEpoch(String utc) { + if (utc == null) return 0; + String s = utc.endsWith("Z") ? utc : utc + "Z"; + return ZonedDateTime.parse(s).toEpochSecond(); + } + + private static double toDouble(Object o) { + if (o == null) return 0; + if (o instanceof Number n) return n.doubleValue(); + return Double.parseDouble(o.toString()); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/CoreEngineWorkerPool.java b/backend/src/main/java/com/goldenchart/trading/pipeline/CoreEngineWorkerPool.java new file mode 100644 index 0000000..88c2a7f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/CoreEngineWorkerPool.java @@ -0,0 +1,95 @@ +package com.goldenchart.trading.pipeline; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 고정 크기 워커 풀이 이벤트 큐에서 틱을 꺼내 종목별 연산을 수행한다. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class CoreEngineWorkerPool { + + private final TradeEventQueue eventQueue; + private final MarketTickProcessor tickProcessor; + private final TradingPipelineProperties props; + + private final AtomicLong processed = new AtomicLong(); + private final AtomicLong errors = new AtomicLong(); + + private ExecutorService executor; + private volatile boolean running; + + @PostConstruct + void start() { + if (!props.isEnabled()) { + log.info("[CoreEngineWorkerPool] 비활성 — 동기 BarBuilder 경로 사용"); + return; + } + int n = Math.max(2, Math.min(16, props.getWorkerThreads())); + running = true; + executor = Executors.newFixedThreadPool(n, r -> { + Thread t = new Thread(r, "core-engine-worker"); + t.setDaemon(true); + return t; + }); + for (int i = 0; i < n; i++) { + executor.submit(this::workerLoop); + } + log.info("[CoreEngineWorkerPool] {} 워커 시작", n); + } + + @PreDestroy + void shutdown() { + running = false; + if (executor != null) { + executor.shutdownNow(); + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private void workerLoop() { + while (running) { + try { + TradeTickEvent event = eventQueue.take(); + tickProcessor.process(event); + processed.incrementAndGet(); + } catch (InterruptedException e) { + if (!running) break; + Thread.currentThread().interrupt(); + } catch (Exception e) { + errors.incrementAndGet(); + log.warn("[CoreEngineWorkerPool] 틱 처리 오류: {}", e.getMessage()); + } + } + } + + public boolean isActive() { + return running && executor != null && !executor.isShutdown(); + } + + public int configuredWorkers() { + return Math.max(2, Math.min(16, props.getWorkerThreads())); + } + + public WorkerStats stats() { + return new WorkerStats(processed.get(), errors.get(), isActive(), configuredWorkers()); + } + + public record WorkerStats(long processed, long errors, boolean active, int workerThreads) {} +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/MarketTickProcessor.java b/backend/src/main/java/com/goldenchart/trading/pipeline/MarketTickProcessor.java new file mode 100644 index 0000000..7a58fc6 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/MarketTickProcessor.java @@ -0,0 +1,26 @@ +package com.goldenchart.trading.pipeline; + +import com.goldenchart.service.LiveRiskMonitorService; +import com.goldenchart.websocket.BarBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Core Engine — 단일 틱에 대한 캔들 조립 + 실시간 리스크 감시. + */ +@Component +@RequiredArgsConstructor +public class MarketTickProcessor { + + private final BarBuilder barBuilder; + private final RealtimePositionMonitor positionMonitor; + private final LiveRiskMonitorService liveRiskMonitor; + private final TickActivityTracker activityTracker; + + public void process(TradeTickEvent event) { + activityTracker.record(event.market()); + barBuilder.onTick(event.market(), event.tradePrice(), event.tradeVolume(), event.tradeTimeMs()); + positionMonitor.onTick(event.market(), event.tradePrice()); + liveRiskMonitor.onTick(event.market(), event.tradePrice()); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java new file mode 100644 index 0000000..cc37742 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java @@ -0,0 +1,127 @@ +package com.goldenchart.trading.pipeline; + +import com.goldenchart.trading.TradingExecutionService; +import com.google.common.util.concurrent.RateLimiter; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 주문 전용 단일 쓰레드 + RateLimiter — 업비트 429 방지. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class OrderExecutionQueue { + + private final TradingExecutionService tradingExecutionService; + private final TradingPipelineProperties props; + + private final BlockingQueue queue = new LinkedBlockingQueue<>(4096); + private final AtomicLong submitted = new AtomicLong(); + private final AtomicLong executed = new AtomicLong(); + private final AtomicLong failed = new AtomicLong(); + private final AtomicLong dropped = new AtomicLong(); + + private RateLimiter rateLimiter; + private volatile boolean running; + private Thread worker; + + @PostConstruct + void start() { + if (!props.isEnabled()) return; + rateLimiter = RateLimiter.create(Math.max(0.5, props.getOrderRatePerSecond())); + running = true; + worker = new Thread(this::runLoop, "order-execution"); + worker.setDaemon(true); + worker.start(); + log.info("[OrderExecutionQueue] 시작 (rate={}/s)", props.getOrderRatePerSecond()); + } + + @PreDestroy + void stop() { + running = false; + if (worker != null) worker.interrupt(); + } + + public void submit(OrderRequest req) { + if (!props.isEnabled()) { + executeNow(req); + return; + } + if (!queue.offer(req)) { + dropped.incrementAndGet(); + log.warn("[OrderExecutionQueue] 주문 큐 포화 — {} {}", req.market(), req.kind()); + } else { + submitted.incrementAndGet(); + } + } + + public void submitSignal(String deviceId, Long userId, String market, + Long strategyId, String signal, double price) { + submit(OrderRequest.strategySignal(deviceId, userId, market, strategyId, signal, price)); + } + + private void runLoop() { + while (running) { + try { + OrderRequest req = queue.poll(1, TimeUnit.SECONDS); + if (req == null) continue; + rateLimiter.acquire(); + executeNow(req); + executed.incrementAndGet(); + } catch (InterruptedException e) { + if (!running) break; + Thread.currentThread().interrupt(); + } catch (Exception e) { + failed.incrementAndGet(); + log.warn("[OrderExecutionQueue] 실행 실패: {}", e.getMessage()); + } + } + } + + private void executeNow(OrderRequest req) { + if (req.deviceId() == null || req.deviceId().isBlank()) return; + if (!"BUY".equals(req.side()) && !"SELL".equals(req.side())) return; + try { + if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) { + tradingExecutionService.executeSignal( + req.deviceId(), req.userId(), req.market(), + req.strategyId(), req.side(), req.price()); + } else { + tradingExecutionService.executeRiskExit(req); + } + log.debug("[OrderExecutionQueue] {} {} {} ({})", + req.kind(), req.side(), req.market(), req.reason()); + } catch (Exception e) { + log.warn("[OrderExecutionQueue] {} {}: {}", req.kind(), req.market(), e.getMessage()); + } + } + + public boolean isWorkerAlive() { + return worker != null && worker.isAlive(); + } + + public OrderQueueStats stats() { + return new OrderQueueStats( + queue.size(), + submitted.get(), + executed.get(), + failed.get(), + dropped.get(), + isWorkerAlive(), + props.isEnabled() ? props.getOrderRatePerSecond() : 0 + ); + } + + public record OrderQueueStats( + int pending, long submitted, long executed, long failed, long dropped, + boolean workerAlive, double ratePerSecond) {} +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java new file mode 100644 index 0000000..1306b8d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java @@ -0,0 +1,39 @@ +package com.goldenchart.trading.pipeline; + +/** + * 주문 실행 큐에 적재되는 모의투자 주문 요청. + */ +public record OrderRequest( + OrderKind kind, + String deviceId, + Long userId, + String market, + Long strategyId, + String side, + double price, + String reason +) { + public enum OrderKind { + STRATEGY_SIGNAL, + STOP_LOSS, + TAKE_PROFIT + } + + public static OrderRequest strategySignal(String deviceId, Long userId, String market, + Long strategyId, String side, double price) { + return new OrderRequest(OrderKind.STRATEGY_SIGNAL, deviceId, userId, market, + strategyId, side, price, "STRATEGY"); + } + + public static OrderRequest stopLoss(String deviceId, Long userId, String market, + double price, double lossPct) { + return new OrderRequest(OrderKind.STOP_LOSS, deviceId, userId, market, + null, "SELL", price, "STOP_LOSS_" + lossPct + "%"); + } + + public static OrderRequest takeProfit(String deviceId, Long userId, String market, + double price, double profitPct) { + return new OrderRequest(OrderKind.TAKE_PROFIT, deviceId, userId, market, + null, "SELL", price, "TAKE_PROFIT_" + profitPct + "%"); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/PipelineMonitorService.java b/backend/src/main/java/com/goldenchart/trading/pipeline/PipelineMonitorService.java new file mode 100644 index 0000000..899b772 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/PipelineMonitorService.java @@ -0,0 +1,263 @@ +package com.goldenchart.trading.pipeline; + +import com.goldenchart.storage.Ta4jStorage; +import com.goldenchart.websocket.UpbitWebSocketClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 멀티스레드 파이프라인 운영 모니터링 (대시보드·/trading/pipeline/status). + */ +@Service +@RequiredArgsConstructor +public class PipelineMonitorService { + + private static final String[] MONITORED_THREAD_PREFIXES = { + "core-engine-worker", + "order-execution", + "upbit-ws-reconnect", + "ws-staleness-monitor", + "warmup-thread", + "WebSocketConnectReadThread", + }; + + private final TradingPipelineProperties props; + private final TradeEventQueue eventQueue; + private final CoreEngineWorkerPool workerPool; + private final OrderExecutionQueue orderQueue; + private final TickActivityTracker activityTracker; + private final UpbitWebSocketClient upbitWebSocketClient; + private final WebSocketStalenessMonitor stalenessMonitor; + private final CandleGapBackfillService gapBackfillService; + private final Ta4jStorage ta4jStorage; + + private long prevSampleMs; + private long prevEnqueued; + private long prevDequeued; + private long prevProcessed; + private long prevWsMessages; + private long prevOrdersExecuted; + + public Map buildMonitorSnapshot() { + Map out = new LinkedHashMap<>(); + boolean enabled = props.isEnabled(); + out.put("pipelineEnabled", enabled); + out.put("sampledAtMs", System.currentTimeMillis()); + out.put("config", Map.of( + "workerThreads", props.getWorkerThreads(), + "queueCapacity", props.getQueueCapacity(), + "orderRatePerSecond", props.getOrderRatePerSecond(), + "maxBarsPerSeries", props.getMaxBarsPerSeries(), + "staleTickSeconds", props.getStaleTickSeconds(), + "gapBackfillCron", props.getGapBackfillCron() + )); + out.put("jvm", jvmMetrics()); + out.put("traffic", computeTrafficRates()); + out.put("memory", ta4jStorage.storageMetrics()); + out.put("queue", eventQueue.stats()); + out.put("workers", workerPool.stats()); + out.put("orders", orderQueue.stats()); + out.put("lastTicks", activityTracker.snapshot()); + out.put("staleMarkets", findStaleMarkets()); + out.put("threads", listMonitoredThreads()); + out.put("processes", buildProcessList(enabled)); + return out; + } + + private Map jvmMetrics() { + MemoryMXBean mem = ManagementFactory.getMemoryMXBean(); + long heapUsed = mem.getHeapMemoryUsage().getUsed(); + long heapMax = mem.getHeapMemoryUsage().getMax(); + long nonHeap = mem.getNonHeapMemoryUsage().getUsed(); + ThreadMXBean threads = ManagementFactory.getThreadMXBean(); + double heapPct = heapMax > 0 ? (heapUsed * 100.0 / heapMax) : 0; + Map m = new LinkedHashMap<>(); + m.put("heapUsedMb", roundMb(heapUsed)); + m.put("heapMaxMb", roundMb(heapMax)); + m.put("heapPct", Math.round(heapPct * 10) / 10.0); + m.put("nonHeapUsedMb", roundMb(nonHeap)); + m.put("threadCount", threads.getThreadCount()); + m.put("daemonThreadCount", threads.getDaemonThreadCount()); + return m; + } + + private Map computeTrafficRates() { + long now = System.currentTimeMillis(); + var q = eventQueue.stats(); + var w = workerPool.stats(); + var o = orderQueue.stats(); + var ws = upbitWebSocketClient.monitorSnapshot(); + long wsMsg = ((Number) ws.get("messagesReceived")).longValue(); + + Map rates = new LinkedHashMap<>(); + if (prevSampleMs > 0) { + double sec = Math.max(0.5, (now - prevSampleMs) / 1000.0); + rates.put("ticksEnqueuedPerSec", round2((q.enqueued() - prevEnqueued) / sec)); + rates.put("ticksDequeuedPerSec", round2((q.dequeued() - prevDequeued) / sec)); + rates.put("ticksProcessedPerSec", round2((w.processed() - prevProcessed) / sec)); + rates.put("wsMessagesPerSec", round2((wsMsg - prevWsMessages) / sec)); + rates.put("ordersExecutedPerSec", round2((o.executed() - prevOrdersExecuted) / sec)); + } else { + rates.put("ticksEnqueuedPerSec", 0.0); + rates.put("ticksDequeuedPerSec", 0.0); + rates.put("ticksProcessedPerSec", 0.0); + rates.put("wsMessagesPerSec", 0.0); + rates.put("ordersExecutedPerSec", 0.0); + } + + prevSampleMs = now; + prevEnqueued = q.enqueued(); + prevDequeued = q.dequeued(); + prevProcessed = w.processed(); + prevWsMessages = wsMsg; + prevOrdersExecuted = o.executed(); + return rates; + } + + private List> findStaleMarkets() { + long now = System.currentTimeMillis(); + List> stale = new ArrayList<>(); + for (String market : upbitWebSocketClient.getSubscribedMarkets()) { + long last = activityTracker.lastTickMs(market); + if (last == 0) continue; + long ageSec = (now - last) / 1000; + if (ageSec > props.getStaleTickSeconds()) { + stale.add(Map.of("market", market, "lastTickAgeSec", ageSec)); + } + } + stale.sort((a, b) -> Long.compare( + ((Number) b.get("lastTickAgeSec")).longValue(), + ((Number) a.get("lastTickAgeSec")).longValue())); + return stale; + } + + private List> listMonitoredThreads() { + ThreadMXBean mx = ManagementFactory.getThreadMXBean(); + List> list = new ArrayList<>(); + for (ThreadInfo info : mx.dumpAllThreads(false, false)) { + if (info == null) continue; + String name = info.getThreadName(); + boolean match = false; + for (String prefix : MONITORED_THREAD_PREFIXES) { + if (name.contains(prefix)) { + match = true; + break; + } + } + if (!match) continue; + list.add(Map.of( + "name", name, + "id", info.getThreadId(), + "state", info.getThreadState().name(), + "daemon", info.isDaemon() + )); + } + return list; + } + + private List> buildProcessList(boolean pipelineEnabled) { + List> processes = new ArrayList<>(); + var ws = upbitWebSocketClient.monitorSnapshot(); + var q = eventQueue.stats(); + var w = workerPool.stats(); + var o = orderQueue.stats(); + + processes.add(process("ingestion", "Upbit WebSocket (Ingestion)", "Ingestion", + pipelineEnabled + ? (Boolean.TRUE.equals(ws.get("connected")) ? "healthy" : "down") + : (Boolean.TRUE.equals(ws.get("connected")) ? "healthy" : "down"), + Map.of( + "connected", ws.get("connected"), + "subscribedMarkets", ws.get("subscribedMarkets"), + "messagesReceived", ws.get("messagesReceived"), + "parseErrors", ws.get("parseErrors"), + "reconnectDelaySec", ws.get("reconnectDelaySec") + ))); + + String queueStatus = !pipelineEnabled ? "disabled" + : q.fillRatio() > 0.85 ? "degraded" + : q.dropped() > 0 ? "degraded" + : "healthy"; + processes.add(process("eventBuffer", "Trade Event Queue", "Event Buffer", queueStatus, + Map.of( + "pending", q.pending(), + "capacity", q.capacity(), + "fillPct", Math.round(q.fillRatio() * 1000) / 10.0, + "enqueued", q.enqueued(), + "dequeued", q.dequeued(), + "dropped", q.dropped() + ))); + + String workerStatus = !pipelineEnabled ? "disabled" + : !w.active() ? "down" + : w.errors() > 0 ? "degraded" + : "healthy"; + processes.add(process("coreEngine", "Core Engine Worker Pool", "Core Engine", workerStatus, + Map.of( + "active", w.active(), + "workerThreads", w.workerThreads(), + "processed", w.processed(), + "errors", w.errors() + ))); + + String orderStatus = !pipelineEnabled ? "disabled" + : !o.workerAlive() ? "down" + : o.pending() > 100 ? "degraded" + : "healthy"; + processes.add(process("orderExecution", "Order Execution", "Execution", orderStatus, + Map.of( + "workerAlive", o.workerAlive(), + "pending", o.pending(), + "submitted", o.submitted(), + "executed", o.executed(), + "failed", o.failed(), + "dropped", o.dropped(), + "ratePerSecond", o.ratePerSecond() + ))); + + var stale = stalenessMonitor.monitorSnapshot(); + processes.add(process("stalenessMonitor", "WS Staleness Monitor", "Health", + pipelineEnabled + ? (Boolean.TRUE.equals(stale.get("running")) ? "healthy" : "down") + : "disabled", + stale)); + + processes.add(process("gapBackfill", "Candle Gap Backfill", "Maintenance", + pipelineEnabled ? "healthy" : "disabled", + gapBackfillService.monitorSnapshot())); + + processes.add(process("ta4jStorage", "Ta4j In-Memory Store", "Storage", + "healthy", ta4jStorage.storageMetrics())); + + return processes; + } + + private static Map process( + String id, String name, String layer, String status, Map metrics) { + Map p = new LinkedHashMap<>(); + p.put("id", id); + p.put("name", name); + p.put("layer", layer); + p.put("status", status); + p.put("metrics", new HashMap<>(metrics)); + return p; + } + + private static double roundMb(long bytes) { + return Math.round(bytes / 1024.0 / 1024.0 * 10) / 10.0; + } + + private static double round2(double v) { + return Math.round(v * 100) / 100.0; + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java b/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java new file mode 100644 index 0000000..799d015 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java @@ -0,0 +1,106 @@ +package com.goldenchart.trading.pipeline; + +import com.goldenchart.dto.BacktestSettingsDto; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcPaperAccount; +import com.goldenchart.entity.GcPaperPosition; +import com.goldenchart.repository.GcPaperAccountRepository; +import com.goldenchart.repository.GcPaperPositionRepository; +import com.goldenchart.service.AppSettingsService; +import com.goldenchart.service.BacktestSettingsService; +import com.goldenchart.trading.TradingExecutionService; +import com.goldenchart.trading.TradingMode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 봉 마감을 기다리지 않고 틱 단위로 손절/익절을 검사한다. + * 백테스트 설정의 SL/TP 비율을 모의투자 실시간 감시에 재사용한다. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RealtimePositionMonitor { + + private final GcPaperPositionRepository positionRepo; + private final GcPaperAccountRepository accountRepo; + private final BacktestSettingsService backtestSettingsService; + private final AppSettingsService appSettingsService; + private final TradingExecutionService tradingExecutionService; + private final TradingPipelineProperties props; + + /** 동일 종목·계정에 중복 청산 주문 방지 (ms) */ + private final Map lastExitMs = new ConcurrentHashMap<>(); + private static final long EXIT_COOLDOWN_MS = 3_000; + + public void onTick(String market, double tradePrice) { + if (!props.isEnabled() || tradePrice <= 0) return; + + List positions = + positionRepo.findBySymbolAndQuantityGreaterThan(market, BigDecimal.ZERO); + if (positions.isEmpty()) return; + + for (GcPaperPosition pos : positions) { + double qty = pos.getQuantity().doubleValue(); + if (qty <= 0) continue; + + GcPaperAccount account = accountRepo.findById(pos.getAccountId()).orElse(null); + if (account == null) continue; + + String deviceId = account.getDeviceId(); + Long userId = account.getUserId(); + if (deviceId == null || deviceId.isBlank()) continue; + + GcAppSettings app; + try { + app = appSettingsService.getEntity(userId, deviceId); + } catch (Exception e) { + continue; + } + TradingMode mode = TradingMode.fromString(app.getTradingMode()); + boolean paperPos = mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled()); + if (!paperPos && !mode.useLive()) continue; + + String cooldownKey = account.getId() + ":" + market; + long now = System.currentTimeMillis(); + Long last = lastExitMs.get(cooldownKey); + if (last != null && now - last < EXIT_COOLDOWN_MS) continue; + + double avg = pos.getAvgPrice().doubleValue(); + if (avg <= 0) continue; + + BacktestSettingsDto risk = backtestSettingsService.get(deviceId); + double pnlPct = (tradePrice - avg) / avg * 100.0; + + if (Boolean.TRUE.equals(risk.getStopLossEnabled()) + && risk.getStopLossPct() != null + && pnlPct <= -risk.getStopLossPct().doubleValue()) { + lastExitMs.put(cooldownKey, now); + double sl = risk.getStopLossPct().doubleValue(); + tradingExecutionService.executeRiskExit( + OrderRequest.stopLoss(deviceId, userId, market, tradePrice, sl)); + log.info("[SL] {} @ {} pnl={}% (limit -{}%)", market, tradePrice, + String.format("%.2f", pnlPct), sl); + return; + } + + if (Boolean.TRUE.equals(risk.getTakeProfitEnabled()) + && risk.getTakeProfitPct() != null + && pnlPct >= risk.getTakeProfitPct().doubleValue()) { + lastExitMs.put(cooldownKey, now); + double tp = risk.getTakeProfitPct().doubleValue(); + tradingExecutionService.executeRiskExit( + OrderRequest.takeProfit(deviceId, userId, market, tradePrice, tp)); + log.info("[TP] {} @ {} pnl={}% (target +{}%)", market, tradePrice, + String.format("%.2f", pnlPct), tp); + return; + } + } + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java new file mode 100644 index 0000000..591acd2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java @@ -0,0 +1,27 @@ +package com.goldenchart.trading.pipeline; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 종목별 마지막 체결 수신 시각 — WebSocket 헬스 모니터용. + */ +@Component +public class TickActivityTracker { + + private final Map lastTickMs = new ConcurrentHashMap<>(); + + public void record(String market) { + lastTickMs.put(market, System.currentTimeMillis()); + } + + public long lastTickMs(String market) { + return lastTickMs.getOrDefault(market, 0L); + } + + public Map snapshot() { + return Map.copyOf(lastTickMs); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TradeEventQueue.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TradeEventQueue.java new file mode 100644 index 0000000..4e8f864 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TradeEventQueue.java @@ -0,0 +1,70 @@ +package com.goldenchart.trading.pipeline; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Ingestion → Core Engine 사이 고속 버퍼. + * 수신 쓰레드는 offer 만 수행하고 즉시 반환한다. + */ +@Component +@Slf4j +public class TradeEventQueue { + + private final LinkedBlockingQueue queue; + private final AtomicLong enqueued = new AtomicLong(); + private final AtomicLong dequeued = new AtomicLong(); + private final AtomicLong dropped = new AtomicLong(); + + public TradeEventQueue(TradingPipelineProperties props) { + this.queue = new LinkedBlockingQueue<>(Math.max(1024, props.getQueueCapacity())); + } + + /** 수신 쓰레드 전용 — 블로킹 없음 */ + public boolean offer(TradeTickEvent event) { + boolean ok = queue.offer(event); + if (ok) { + enqueued.incrementAndGet(); + } else { + dropped.incrementAndGet(); + if (dropped.get() % 1000 == 1) { + log.warn("[TradeEventQueue] 큐 포화 — 틱 드롭 누적={}", dropped.get()); + } + } + return ok; + } + + /** 워커 쓰레드 — 블로킹 take */ + public TradeTickEvent take() throws InterruptedException { + TradeTickEvent e = queue.take(); + dequeued.incrementAndGet(); + return e; + } + + public int size() { + return queue.size(); + } + + public int capacity() { + return queue.remainingCapacity() + queue.size(); + } + + public PipelineQueueStats stats() { + int cap = capacity(); + return new PipelineQueueStats( + queue.size(), + cap, + cap > 0 ? (double) queue.size() / cap : 0, + enqueued.get(), + dequeued.get(), + dropped.get() + ); + } + + public record PipelineQueueStats( + int pending, int capacity, double fillRatio, + long enqueued, long dequeued, long dropped) {} +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TradeTickEvent.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TradeTickEvent.java new file mode 100644 index 0000000..c5306f6 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TradeTickEvent.java @@ -0,0 +1,16 @@ +package com.goldenchart.trading.pipeline; + +/** + * WebSocket 수신 쓰레드 → 이벤트 큐로 전달하는 불변 체결 틱. + */ +public record TradeTickEvent( + String market, + double tradePrice, + double tradeVolume, + long tradeTimeMs, + long enqueuedAtMs +) { + public static TradeTickEvent of(String market, double price, double volume, long timeMs) { + return new TradeTickEvent(market, price, volume, timeMs, System.currentTimeMillis()); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java new file mode 100644 index 0000000..87331d3 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java @@ -0,0 +1,40 @@ +package com.goldenchart.trading.pipeline; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 하이브리드 자동매매 파이프라인 설정 (multi_thread_base_archiitecture.md). + */ +@Component +@ConfigurationProperties(prefix = "goldenchart.trading.pipeline") +@Getter +@Setter +public class TradingPipelineProperties { + + /** 이벤트 큐 기반 비동기 파이프라인 사용 */ + private boolean enabled = true; + + /** 체결 틱 이벤트 큐 용량 */ + private int queueCapacity = 65_536; + + /** Core Engine 워커 스레드 수 (4~8 권장) */ + private int workerThreads = 6; + + /** 주문 REST 호출 초당 상한 (업비트 429 방지) */ + private double orderRatePerSecond = 5.0; + + /** BarSeries 메모리 상한 (OOM 방지) */ + private int maxBarsPerSeries = 500; + + /** 종목별 틱 미수신 시 재연결 요청(초) */ + private int staleTickSeconds = 30; + + /** 갭 백필 스케줄 (Spring cron) */ + private String gapBackfillCron = "0 */5 * * * *"; + + /** 갭 백필 시 REST로 가져올 캔들 수 */ + private int gapBackfillCandleCount = 20; +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineStatusController.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineStatusController.java new file mode 100644 index 0000000..58c3886 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineStatusController.java @@ -0,0 +1,24 @@ +package com.goldenchart.trading.pipeline; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 파이프라인 운영 상태 조회 (모니터링·디버깅). + */ +@RestController +@RequestMapping("/trading/pipeline") +@RequiredArgsConstructor +public class TradingPipelineStatusController { + + private final PipelineMonitorService monitorService; + + @GetMapping("/status") + public Map status() { + return monitorService.buildMonitorSnapshot(); + } +} diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java b/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java new file mode 100644 index 0000000..3c86ec7 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java @@ -0,0 +1,87 @@ +package com.goldenchart.trading.pipeline; + +import com.goldenchart.websocket.UpbitWebSocketClient; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 30초 이상 체결이 없는 구독 종목에 대해 WebSocket 재연결을 요청한다. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class WebSocketStalenessMonitor { + + private final TradingPipelineProperties props; + private final TickActivityTracker activityTracker; + private final UpbitWebSocketClient upbitWebSocketClient; + + private ScheduledExecutorService scheduler; + private volatile boolean running; + private volatile long lastCheckAtMs; + private volatile int lastStaleCount; + + @PostConstruct + void start() { + if (!props.isEnabled()) return; + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "ws-staleness-monitor"); + t.setDaemon(true); + return t; + }); + running = true; + scheduler.scheduleAtFixedRate(this::check, 15, 15, TimeUnit.SECONDS); + log.info("[WebSocketStalenessMonitor] 시작 (threshold={}s)", props.getStaleTickSeconds()); + } + + @PreDestroy + void stop() { + running = false; + if (scheduler != null) scheduler.shutdownNow(); + } + + public Map monitorSnapshot() { + return Map.of( + "running", running, + "lastCheckAtMs", lastCheckAtMs, + "lastStaleCount", lastStaleCount, + "staleThresholdSec", props.getStaleTickSeconds() + ); + } + + private void check() { + lastCheckAtMs = System.currentTimeMillis(); + long now = System.currentTimeMillis(); + long thresholdMs = props.getStaleTickSeconds() * 1000L; + var subscribed = upbitWebSocketClient.getSubscribedMarkets(); + if (subscribed.isEmpty()) return; + + List stale = new ArrayList<>(); + for (String market : subscribed) { + long last = activityTracker.lastTickMs(market); + if (last == 0) { + // 아직 한 번도 틱 없음 — 구독 직후 30초 유예는 threshold 로 커버 + continue; + } + if (now - last > thresholdMs) { + stale.add(market); + } + } + lastStaleCount = stale.size(); + if (!stale.isEmpty()) { + log.warn("[WebSocketStalenessMonitor] 틱 정체 {}종목 → WS 재연결 요청: {}", + stale.size(), stale); + upbitWebSocketClient.requestReconnect("stale_ticks:" + stale); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/upbit/UpbitJwtSigner.java b/backend/src/main/java/com/goldenchart/upbit/UpbitJwtSigner.java new file mode 100644 index 0000000..5558f09 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/upbit/UpbitJwtSigner.java @@ -0,0 +1,49 @@ +package com.goldenchart.upbit; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 업비트 Open API JWT (query_hash + HS512). + */ +public final class UpbitJwtSigner { + + private UpbitJwtSigner() {} + + public static String sign(String accessKey, String secretKey, Map params) { + String nonce = UUID.randomUUID().toString(); + var builder = JWT.create() + .withClaim("access_key", accessKey) + .withClaim("nonce", nonce); + + if (params != null && !params.isEmpty()) { + String query = params.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + try { + MessageDigest md = MessageDigest.getInstance("SHA-512"); + byte[] digest = md.digest(query.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String queryHash = String.format("%0128x", new BigInteger(1, digest)); + builder.withClaim("query_hash", queryHash); + builder.withClaim("query_hash_alg", "SHA512"); + } catch (Exception e) { + throw new IllegalStateException("query_hash 생성 실패", e); + } + } + + return builder.sign(Algorithm.HMAC512(secretKey)); + } + + /** GET 등 쿼리 파라미터 없는 요청 */ + public static String signNoQuery(String accessKey, String secretKey) { + return sign(accessKey, secretKey, null); + } +} diff --git a/backend/src/main/java/com/goldenchart/upbit/UpbitOrderApiClient.java b/backend/src/main/java/com/goldenchart/upbit/UpbitOrderApiClient.java new file mode 100644 index 0000000..3e42c60 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/upbit/UpbitOrderApiClient.java @@ -0,0 +1,136 @@ +package com.goldenchart.upbit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; + +/** + * 업비트 주문·계좌 REST API (실거래). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class UpbitOrderApiClient { + + private final WebClient.Builder webClientBuilder; + private final ObjectMapper objectMapper; + + @Value("${upbit.api.base-url:https://api.upbit.com}") + private String baseUrl; + + /** + * 시장가 매수 (KRW 금액 지정). + */ + public JsonNode placeMarketBuy(String accessKey, String secretKey, + String market, double krwAmount) { + Map params = new TreeMap<>(); + params.put("market", market); + params.put("side", "bid"); + params.put("price", String.valueOf((long) Math.floor(krwAmount))); + params.put("ord_type", "price"); + return postOrder(accessKey, secretKey, params); + } + + /** + * 시장가 매도 (수량 지정). + */ + public JsonNode placeMarketSell(String accessKey, String secretKey, + String market, double volume) { + Map params = new TreeMap<>(); + params.put("market", market); + params.put("side", "ask"); + params.put("volume", formatVolume(volume)); + params.put("ord_type", "market"); + return postOrder(accessKey, secretKey, params); + } + + /** 보유 KRW 잔고 */ + public double getKrwBalance(String accessKey, String secretKey) { + JsonNode accounts = getAccounts(accessKey, secretKey); + if (accounts == null || !accounts.isArray()) return 0; + for (JsonNode a : accounts) { + if ("KRW".equals(a.path("currency").asText())) { + return a.path("balance").asDouble(0); + } + } + return 0; + } + + /** 종목 보유 수량 */ + public double getCoinBalance(String accessKey, String secretKey, String market) { + String currency = market.startsWith("KRW-") ? market.substring(4) : market; + JsonNode accounts = getAccounts(accessKey, secretKey); + if (accounts == null || !accounts.isArray()) return 0; + for (JsonNode a : accounts) { + if (currency.equals(a.path("currency").asText())) { + return a.path("balance").asDouble(0); + } + } + return 0; + } + + public JsonNode getAccounts(String accessKey, String secretKey) { + String token = UpbitJwtSigner.signNoQuery(accessKey, secretKey); + try { + String body = webClientBuilder.build() + .get() + .uri(baseUrl + "/v1/accounts") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .retrieve() + .bodyToMono(String.class) + .block(); + return objectMapper.readTree(body); + } catch (WebClientResponseException e) { + log.warn("[UpbitAPI] accounts {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new UpbitApiException(e.getStatusCode().value(), e.getResponseBodyAsString()); + } catch (Exception e) { + throw new UpbitApiException(0, e.getMessage()); + } + } + + private JsonNode postOrder(String accessKey, String secretKey, Map params) { + String token = UpbitJwtSigner.sign(accessKey, secretKey, params); + Map body = new LinkedHashMap<>(params); + try { + String response = webClientBuilder.build() + .post() + .uri(baseUrl + "/v1/orders") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .block(); + return objectMapper.readTree(response); + } catch (WebClientResponseException e) { + log.warn("[UpbitAPI] order {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new UpbitApiException(e.getStatusCode().value(), e.getResponseBodyAsString()); + } catch (Exception e) { + throw new UpbitApiException(0, e.getMessage()); + } + } + + private static String formatVolume(double v) { + if (v >= 1) return String.format("%.8f", v).replaceAll("0+$", "").replaceAll("\\.$", ""); + return String.format("%.8f", v); + } + + public static class UpbitApiException extends RuntimeException { + public final int status; + public UpbitApiException(int status, String message) { + super("Upbit API " + status + ": " + message); + this.status = status; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java new file mode 100644 index 0000000..e4633ea --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -0,0 +1,362 @@ +package com.goldenchart.websocket; + +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.service.LiveStrategyEvaluator; +import com.goldenchart.service.LiveStrategyTimeframeService; +import com.goldenchart.service.TradeSignalService; +import com.goldenchart.trading.pipeline.OrderExecutionQueue; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; + +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 업비트 WebSocket 틱(Trade) 데이터를 수신하여 분봉 단위 캔들을 조립하는 빌더. + * + * 명세서 3.2 준수: + * - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신 + * - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기 + * - 1분봉 확정 시 상위 타임프레임(3m, 5m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파 + * + * 지원 캔들 타입: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class BarBuilder { + + private final Ta4jStorage ta4jStorage; + private final TradingWebSocketBroker broker; + private final LiveStrategyEvaluator liveStrategyEvaluator; + private final TradeSignalService tradeSignalService; + private final OrderExecutionQueue orderExecutionQueue; + private final GcLiveStrategySettingsRepository liveSettingsRepo; + private final LiveStrategyTimeframeService timeframeService; + + /** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */ + private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440}; + private static final String[] UPPER_TYPES = {"3m", "5m", "15m", "30m", "1h", "4h", "1d"}; + + /** 진행 중인 1분봉 상태 (market → PartialBar) */ + private final ConcurrentHashMap partialBars = new ConcurrentHashMap<>(); + + /** + * 봉 마감 직후 전략 판정 시그널 임시 보관 (market → "BUY"|"SELL"|"NONE"). + * publishCurrentBar 에서 단 1회 실어 보낸 뒤 제거된다. + */ + private final ConcurrentHashMap signalOnClose = new ConcurrentHashMap<>(); + + /** + * 시그널이 발생한 봉의 barStart epoch-second 임시 보관. + * publishCurrentBar 에서 signal 과 함께 사용되고 제거된다. + */ + private final ConcurrentHashMap signalBarTimeEpoch = new ConcurrentHashMap<>(); + + /** + * 시그널이 발생한 봉의 최종 close 가격 임시 보관. + * publishCurrentBar 에서 시그널 메시지의 price 로 사용된다. + */ + private final ConcurrentHashMap signalBarClose = new ConcurrentHashMap<>(); + + // ── 공개 API ────────────────────────────────────────────────────────────── + + /** + * 업비트 trade 틱을 처리하여 1분봉을 조립한다. + * + * @param market 마켓 코드 (e.g. "KRW-BTC") + * @param tradePrice 체결가 + * @param tradeVolume 체결량 + * @param tradeTimeMs 체결 Unix 밀리초 + */ + public void onTick(String market, double tradePrice, double tradeVolume, long tradeTimeMs) { + ZonedDateTime tickTime = ZonedDateTime.ofInstant( + java.time.Instant.ofEpochMilli(tradeTimeMs), ZoneOffset.UTC); + + // 해당 분의 시작 시간 (초 이하 제거) + ZonedDateTime barStart = tickTime.withSecond(0).withNano(0); + + PartialBar current = partialBars.get(market); + + if (current == null || !current.barStart.equals(barStart)) { + // ── 분 변경: 이전 Bar 확정 및 신규 Bar 열기 ───────────────────── + if (current != null) { + commitBar(market, current); // 이전 Bar 확정 + } + // 신규 Bar 시작 + PartialBar newBar = new PartialBar(barStart, tradePrice, tradeVolume); + partialBars.put(market, newBar); + + // 새 분이 시작되는 첫 틱을 Ta4jStorage 에 provisional bar 로 추가. + // 이렇게 해야 이후 동일 분 틱에서 updateLastBar 가 직전 확정봉을 오염시키지 않는다. + Duration d1m = Duration.ofMinutes(1); + ZonedDateTime endTime = barStart.plus(d1m); + Bar provisionalBar = buildTa4jBar(newBar, d1m, endTime); + ta4jStorage.addBar(market, "1m", provisionalBar); + + } else { + // ── 동일 분: 진행 중인 Bar 갱신 ──────────────────────────────── + current.update(tradePrice, tradeVolume); + // Ta4jStorage 마지막 봉(현재 진행 중인 provisional bar)을 실시간 갱신 + ta4jStorage.updateLastBar(market, "1m", tradePrice, tradeVolume); + } + + // 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용) + publishCurrentBar(market); + } + + // ── 내부 처리 ────────────────────────────────────────────────────────────── + + /** 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파 */ + private void commitBar(String market, PartialBar partial) { + Duration d1m = Duration.ofMinutes(1); + ZonedDateTime endTime = partial.barStart.plus(d1m); + + // Ta4j Bar 생성 + Bar bar = buildTa4jBar(partial, d1m, endTime); + + // 1분봉 Ta4jStorage 저장 + ta4jStorage.addBar(market, "1m", bar); + log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime); + + String evalCandleType = timeframeService.resolveCandleTypeForMarket(market); + BarSeries series1m = ta4jStorage.getOrCreate(market, "1m"); + if ("1m".equals(evalCandleType)) { + evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), partial, bar, true); + } + + // 상위 타임프레임 전파 + for (int i = 0; i < UPPER_MINUTES.length; i++) { + int minutes = UPPER_MINUTES[i]; + String type = UPPER_TYPES[i]; + // 해당 상위 봉의 시작 시간 계산 + ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes); + ZonedDateTime upperEnd = upperStart.plusMinutes(minutes); + + BarSeries upperSeries = ta4jStorage.getOrCreate(market, type); + if (upperSeries.isEmpty()) { + // 새 상위봉 열기 + Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd); + ta4jStorage.addBar(market, type, upperBar); + } else { + Bar lastUpper = upperSeries.getLastBar(); + // Ta4j 0.22: getEndTime() → Instant + Instant lastUpperEndInstant = lastUpper.getEndTime(); + Instant upperEndInstant = upperEnd.toInstant(); + if (lastUpperEndInstant.equals(upperEndInstant)) { + // 동일 구간 → 가격 갱신 + ta4jStorage.updateLastBar(market, type, partial.close, partial.volume); + } else if (lastUpperEndInstant.isBefore(upperEndInstant)) { + // 다음 구간 → 새 Bar 추가 (이전 상위봉 마감) + Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd); + ta4jStorage.addBar(market, type, upperBar); + BarSeries upperAfter = ta4jStorage.getOrCreate(market, type); + if (type.equals(evalCandleType)) { + int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1); + evaluateStrategyOnBarClose(market, type, maturedUpper, partial, bar, false); + } + } + } + } + } + + /** + * 봉 마감 시점 전략 평가 + 시그널 저장 + 주문 큐 적재. + * + * @param publishStompSignal 1m 만 STOMP 시그널 필드에 실어 보냄 + */ + private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex, + PartialBar partial, Bar bar, boolean publishStompSignal) { + String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex); + String signalExecType = "CANDLE_CLOSE"; + + if ("NONE".equals(closeSignal)) { + closeSignal = liveStrategyEvaluator.evaluateRealtimeAtClose(market, candleType, maturedIndex); + signalExecType = "REALTIME_TICK"; + } + + if (publishStompSignal) { + signalOnClose.put(market, closeSignal); + signalBarTimeEpoch.put(market, partial.barStart.toEpochSecond()); + signalBarClose.put(market, partial.close); + } + + if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return; + + try { + final String execType = signalExecType; + GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market) + .stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) + && execType.equals(s.getExecutionType())).findFirst().orElse(null); + long candleTimeEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + String devId = activeSetting != null ? activeSetting.getDeviceId() : null; + Long uid = activeSetting != null ? activeSetting.getUserId() : null; + Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null; + tradeSignalService.save( + devId, uid, market, stratId, null, + closeSignal, partial.close, candleTimeEpoch, candleType, execType); + if (devId != null) { + orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, partial.close); + } + } catch (Exception e) { + log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage()); + } + } + + /** + * 현재 진행 중인 1분봉을 STOMP 로 발행. + * + *

핵심 버그 수정 (백테스팅 데이터 오염 방지)
+ * 분 전환 시점에 {@code commitBar} 가 {@code signalOnClose = "NONE"} 을 항상 설정하므로, + * 이전 코드에서는 {@code signal != null} 조건만으로 {@code closedBarTime} 을 결정했다. + * 그 결과 분 전환 첫 틱에 발행되는 STOMP 메시지가: + * - time = 직전 확정봉 barStart (잘못된 타임스탬프) + * - close = 새 provisional 봉 첫 틱 가격 (잘못된 close) + * 가 되어 프론트엔드 차트에서 직전 확정봉의 close 를 새 봉의 첫 틱으로 덮어쓰게 된다. + * 이 오염된 close 값은 백테스팅 전송 데이터로도 활용되어 CCI 계산이 왜곡된다. + * + *

수정 원칙: + *

    + *
  • 실제 BUY/SELL 시그널 → 마감봉 시각(closedBarTime)과 마감봉 close 를 사용
  • + *
  • "NONE" 또는 null → 현재 진행 중인 봉 시각(p.barStart)과 실시간 close 를 사용
  • + *
+ */ + private void publishCurrentBar(String market) { + PartialBar p = partialBars.get(market); + if (p == null) return; + + String signal = signalOnClose.remove(market); + + // ★ "NONE" 은 실제 시그널이 아니므로 closedBarTime 을 사용하지 않는다. + // 이전에는 signal != null 로만 판단해서 "NONE" 도 시각 보정 대상이 되었고, + // 결과적으로 새 봉의 첫 틱 데이터가 직전 확정봉 시각으로 발행되어 차트 데이터가 오염됐다. + boolean isRealSignal = "BUY".equals(signal) || "SELL".equals(signal); + + Long closedBarTime = isRealSignal ? signalBarTimeEpoch.remove(market) : null; + Double closedBarClose = isRealSignal ? signalBarClose.remove(market) : null; + + // 잔여 맵 엔트리 정리 (실제 시그널 없는 경우) + if (!isRealSignal) { + signalBarTimeEpoch.remove(market); + signalBarClose.remove(market); + } + + long barTime = (closedBarTime != null) ? closedBarTime : p.barStart.toEpochSecond(); + double barClose = (closedBarClose != null) ? closedBarClose : p.close; + + CandleBarDto dto = CandleBarDto.builder() + .time(barTime) + .open(p.open) + .high(p.high) + .low(p.low) + .close(barClose) + .volume(p.volume) + .signal(isRealSignal ? signal : null) // null 이면 @JsonInclude(NON_NULL) 로 직렬화 제외 + .build(); + broker.publish(market, "1m", dto); + publishUpperTimeframes(market); + } + + /** 상위 분봉 구독자용 — Ta4jStorage 마지막 봉 스냅샷 (틱마다 1m 과 함께 발행) */ + private void publishUpperTimeframes(String market) { + for (String type : UPPER_TYPES) { + try { + BarSeries series = ta4jStorage.getOrCreate(market, type); + if (series.isEmpty()) continue; + Bar last = series.getLastBar(); + long barStart = last.getEndTime().getEpochSecond() - last.getTimePeriod().getSeconds(); + broker.publish(market, type, CandleBarDto.builder() + .time(barStart) + .open(last.getOpenPrice().doubleValue()) + .high(last.getHighPrice().doubleValue()) + .low(last.getLowPrice().doubleValue()) + .close(last.getClosePrice().doubleValue()) + .volume(last.getVolume().doubleValue()) + .build()); + } catch (Exception e) { + log.trace("[BarBuilder] upper publish skip {}/{}: {}", market, type, e.getMessage()); + } + } + } + + /** PartialBar → Ta4j Bar 변환 */ + private Bar buildTa4jBar(PartialBar partial, Duration duration, ZonedDateTime endZdt) { + BarSeries tmp = new org.ta4j.core.BaseBarSeriesBuilder() + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22 + .build(); + // Ta4j 0.22: barBuilder().endTime() 은 Instant 파라미터 + Instant endInstant = endZdt.toInstant(); + return tmp.barBuilder() + .timePeriod(duration) + .endTime(endInstant) + .openPrice(partial.open) + .highPrice(partial.high) + .lowPrice(partial.low) + .closePrice(partial.close) + .volume(partial.volume) + .build(); + } + + /** 업비트 일봉 기준 시각: KST (UTC+9) */ + private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul"); + + /** + * 분 단위 시간을 상위 interval 의 시작으로 정렬. + * + * 일봉(minutes == 1440)은 업비트와 동일하게 KST 00:00 기준으로 정렬한다. + * 그 외 모든 타임프레임은 UTC epoch 기준 정렬(기존 방식 유지). + */ + private ZonedDateTime alignToInterval(ZonedDateTime time, int minutes) { + if (minutes == 1440) { + // 일봉: KST 자정(00:00) 기준 — 업비트 일봉 경계와 일치 + ZonedDateTime kst = time.withZoneSameInstant(ZONE_KST); + return kst.toLocalDate().atStartOfDay(ZONE_KST); + } + long epochMin = time.toEpochSecond() / 60; + long aligned = (epochMin / minutes) * minutes; + return Instant.ofEpochSecond(aligned * 60).atZone(ZoneOffset.UTC); + } + + // ── 내부 상태 ────────────────────────────────────────────────────────────── + + /** 진행 중인 단일 캔들 상태 */ + static class PartialBar { + final ZonedDateTime barStart; + final double open; + double high; + double low; + double close; + double volume; + + PartialBar(ZonedDateTime barStart, double price, double volume) { + this.barStart = barStart; + this.open = price; + this.high = price; + this.low = price; + this.close = price; + this.volume = volume; + } + + void update(double price, double vol) { + if (price > this.high) this.high = price; + if (price < this.low) this.low = price; + this.close = price; + this.volume += vol; + } + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java b/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java new file mode 100644 index 0000000..2a8b029 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java @@ -0,0 +1,261 @@ +package com.goldenchart.websocket; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilderFactory; +import org.ta4j.core.num.DoubleNumFactory; + +import java.time.Instant; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 동적 STOMP 구독 관리자 (명세서 3.3). + * + * 프론트엔드가 /sub/charts/{market}/{type} 채널을 구독하면: + * 1. Ta4jStorage 에 해당 종목 BarSeries 가 없으면 업비트 REST API Warm-up + * 2. UpbitWebSocketClient 구독 목록에 market 추가 + * + * 구독자 수가 0이 되면: + * 1. UpbitWebSocketClient 에서 market 제거 + * 2. Ta4jStorage 에서 BarSeries 삭제 (메모리 방어) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DynamicSubscriptionManager { + + // /sub/charts/{market}/{type} 패턴 파서 + private static final Pattern TOPIC_PATTERN = + Pattern.compile("^/sub/charts/([^/]+)/([^/]+)$"); + + private final Ta4jStorage ta4jStorage; + private final UpbitWebSocketClient upbitWsClient; + private final WebClient.Builder webClientBuilder; + private final ObjectMapper objectMapper; + + @Value("${upbit.api.base-url:https://api.upbit.com}") + private String upbitBaseUrl; + + @Value("${upbit.api.candle-limit:200}") + private int candlePageSize; + + /** EMA 수렴을 위한 warm-up 목표 봉 수 (페이지네이션으로 달성) */ + @Value("${upbit.api.warmup-count:600}") + private int warmupTargetCount; + + @Value("${upbit.api.request-delay-ms:110}") + private long requestDelayMs; + + /** 구독자 수 추적: "KRW-BTC:1m" → count */ + private final ConcurrentHashMap subscriberCount = + new ConcurrentHashMap<>(); + + /** 워밍업 중인 키 추적 (중복 요청 방지) */ + private final ConcurrentHashMap warmingUp = + new ConcurrentHashMap<>(); + + /** 워밍업 전용 단일 스레드 — 업비트 API 레이트리밋 보호용 직렬 실행 */ + private final ExecutorService warmUpExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "warmup-thread"); + t.setDaemon(true); + return t; + }); + + // ── STOMP 구독/구독해제 이벤트 리스너 ──────────────────────────────────── + + @EventListener + public void handleSubscribe(SessionSubscribeEvent event) { + String dest = getDestination(event.getMessage().getHeaders()); + if (dest == null) return; + Matcher m = TOPIC_PATTERN.matcher(dest); + if (!m.matches()) return; + + String market = m.group(1); + String candleType = m.group(2); + String key = market + ":" + candleType; + + int count = subscriberCount.computeIfAbsent(key, k -> new AtomicInteger(0)) + .incrementAndGet(); + log.info("[DynamicSubscriptionManager] 구독: {} (총 {}명)", key, count); + + // Warm-up: 메모리에 없고 현재 워밍업 중이 아니면 비동기 초기화 + if (!ta4jStorage.exists(market, candleType) + && warmingUp.putIfAbsent(key, Boolean.TRUE) == null) { + warmUpExecutor.submit(() -> { + try { + warmUp(market, candleType); + } finally { + warmingUp.remove(key); + } + }); + } + // UpbitWebSocket 구독 추가 + upbitWsClient.addMarket(market); + if ("1m".equals(candleType)) { + upbitWsClient.resubscribeAll(); + } + } + + @EventListener + public void handleUnsubscribe(SessionUnsubscribeEvent event) { + // unsubscribe 이벤트는 구독 ID 기반이므로 별도 매핑 필요 (간소화: 0 도달 시 GC) + // 실제 구현에서는 sessionId + subscriptionId → topic 역매핑 테이블 필요 + // 여기서는 구독 수 감소만 처리 + String dest = getDestination(event.getMessage().getHeaders()); + if (dest == null) return; + Matcher m = TOPIC_PATTERN.matcher(dest); + if (!m.matches()) return; + + String market = m.group(1); + String candleType = m.group(2); + String key = market + ":" + candleType; + + AtomicInteger counter = subscriberCount.get(key); + if (counter == null) return; + + int remaining = counter.decrementAndGet(); + log.info("[DynamicSubscriptionManager] 구독 해제: {} (남은 {}명)", key, remaining); + + if (remaining <= 0) { + subscriberCount.remove(key); + upbitWsClient.removeMarket(market); + ta4jStorage.remove(market, candleType); + log.info("[DynamicSubscriptionManager] GC: {} 메모리 해제", key); + } + } + + /** + * 실시간 전략 체크 등 STOMP 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다. + * (관심종목 모니터링 — 프론트가 해당 토픽을 구독하면 평가·시그널 발행이 연결됨) + */ + public void ensureMarketPinned(String market, String candleType) { + String key = market + ":" + candleType; + if (!ta4jStorage.exists(market, candleType) + && warmingUp.putIfAbsent(key, Boolean.TRUE) == null) { + warmUpExecutor.submit(() -> { + try { + warmUp(market, candleType); + } finally { + warmingUp.remove(key); + } + }); + } + upbitWsClient.addMarket(market); + log.info("[DynamicSubscriptionManager] 실시간 전략용 마켓 고정: {}:{}", market, candleType); + } + + // ── Warm-up ──────────────────────────────────────────────────────────────── + + /** + * 업비트 REST API 로 최근 N개 캔들을 페이지네이션으로 가져와 Ta4jStorage 를 초기화한다. + * EMA(200) 수렴에는 약 1000봉이 필요하므로 warmupTargetCount(기본 600)봉을 목표로 한다. + */ + private void warmUp(String market, String candleType) { + String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1"); + Duration duration = Ta4jStorage.parseDuration(candleType); + List> allRaw = new ArrayList<>(); + String toParam = null; // null = 최신부터 + int fetched = 0; + + try { + while (fetched < warmupTargetCount) { + int pageSize = Math.min(candlePageSize, warmupTargetCount - fetched); + String url = upbitBaseUrl + "/v1/candles/" + path + + "?market=" + market + "&count=" + pageSize + + (toParam != null ? "&to=" + toParam : ""); + + Thread.sleep(requestDelayMs); + String json = webClientBuilder.build() + .get().uri(url) + .retrieve().bodyToMono(String.class).block(); + if (json == null || json.isBlank()) break; + + List> page = + objectMapper.readValue(json, new TypeReference<>() {}); + if (page.isEmpty()) break; + + allRaw.addAll(page); + fetched += page.size(); + + // 다음 페이지: 가장 오래된 봉 시각보다 1초 이전을 to 로 설정. + // 업비트 to 파라미터는 exclusive 이지만, 동일 시각 중복 방지를 위해 1초 뺀다. + String oldest = (String) page.get(page.size() - 1).get("candle_date_time_utc"); + if (oldest == null) break; + long oldestEpoch = parseEpoch(oldest); + toParam = Instant.ofEpochSecond(oldestEpoch - 1) + .atZone(ZoneOffset.UTC) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) + + "Z"; + + if (page.size() < pageSize) break; // 더 이상 데이터 없음 + } + + // 업비트 응답은 최신→과거 순 → 오름차순 정렬 후 Ta4jStorage 에 적재 + allRaw.sort(Comparator.comparingLong(r -> parseEpoch((String) r.get("candle_date_time_utc")))); + + for (Map r : allRaw) { + long epochSec = parseEpoch((String) r.get("candle_date_time_utc")); + Instant endInstant = Instant.ofEpochSecond(epochSec).plus(duration); + try { + BarSeries tmp = new BaseBarSeriesBuilder() + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()).build(); + Bar bar = tmp.barBuilder() + .timePeriod(duration).endTime(endInstant) + .openPrice(toDouble(r.get("opening_price"))) + .highPrice(toDouble(r.get("high_price"))) + .lowPrice(toDouble(r.get("low_price"))) + .closePrice(toDouble(r.get("trade_price"))) + .volume(toDouble(r.get("candle_acc_trade_volume"))) + .build(); + ta4jStorage.addBar(market, candleType, bar); + } catch (Exception e) { + log.trace("[DynamicSubscriptionManager] warmUp bar 스킵: {}", e.getMessage()); + } + } + log.info("[DynamicSubscriptionManager] Warm-up 완료: {} / {} ({} bars)", market, candleType, allRaw.size()); + } catch (Exception e) { + log.error("[DynamicSubscriptionManager] Warm-up 실패 {}/{}: {}", market, candleType, e.getMessage()); + } + } + + // ── 유틸 ────────────────────────────────────────────────────────────────── + + private String getDestination(org.springframework.messaging.MessageHeaders headers) { + Object dest = headers.get("simpDestination"); + return dest instanceof String s ? s : null; + } + + private static long parseEpoch(String utcStr) { + if (utcStr == null) return 0L; + return ZonedDateTime.parse(utcStr + "Z", + java.time.format.DateTimeFormatter.ISO_DATE_TIME).toEpochSecond(); + } + + private static double toDouble(Object v) { + return v == null ? 0.0 : ((Number) v).doubleValue(); + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java new file mode 100644 index 0000000..7799acd --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -0,0 +1,111 @@ +package com.goldenchart.websocket; + +import com.goldenchart.dto.CandleBarDto; +import com.goldenchart.entity.GcLiveStrategySettings; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.service.LiveStrategyEvaluator; +import com.goldenchart.service.LiveStrategyTimeframeService; +import com.goldenchart.service.TradeSignalService; +import com.goldenchart.trading.pipeline.OrderExecutionQueue; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ta4j.core.BarSeries; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 3초 주기 라운드 로빈 스케줄러. + * + *

설정 방식이 REALTIME_TICK 이고 isLiveCheck = true 인 종목에 대해 + * 현재 진행 중인 캔들 인덱스(series.getEndIndex())로 전략을 평가하여 + * BUY/SELL 시그널이 발생하면 STOMP 토픽으로 발행한다. + * + *

동시성 안전: + *

    + *
  • BarBuilder 의 partialBars 와 Ta4jStorage 는 ConcurrentHashMap 기반
  • + *
  • 이 스케줄러는 단일 스레드로 돌기 때문에 중복 평가 없음
  • + *
+ */ +@Component +@RequiredArgsConstructor +@Slf4j +public class LiveStrategyScheduler { + + private final GcLiveStrategySettingsRepository settingsRepo; + private final LiveStrategyEvaluator evaluator; + private final Ta4jStorage ta4jStorage; + private final TradingWebSocketBroker broker; + private final BarBuilder barBuilder; + private final TradeSignalService tradeSignalService; + private final OrderExecutionQueue orderExecutionQueue; + private final LiveStrategyTimeframeService timeframeService; + + /** + * 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행. + * fixedDelay=3000: 이전 실행이 끝난 뒤 3초 후 재실행 (오버랩 방지). + */ + @Scheduled(fixedDelay = 3000) + public void tick() { + List activeSettings = settingsRepo.findAllByIsLiveCheckTrue(); + if (activeSettings.isEmpty()) return; + + for (GcLiveStrategySettings s : activeSettings) { + if (!"REALTIME_TICK".equals(s.getExecutionType())) continue; + if (s.getStrategyId() == null) continue; + + String market = s.getMarket(); + String candleType = timeframeService.resolveCandleType( + market, s.getUserId(), s.getDeviceId()); + + if (!ta4jStorage.exists(market, candleType)) continue; + + BarSeries series = ta4jStorage.getOrCreate(market, candleType); + if (series.isEmpty()) continue; + + String signal = evaluator.evaluateRealtimeTick(market, candleType); + if ("BUY".equals(signal) || "SELL".equals(signal)) { + // 현재 진행 중인 캔들 정보 조회 + org.ta4j.core.Bar lastBar = series.getLastBar(); + // Ta4j 0.22: getEndTime() → Instant; 시작 시간 = endTime - duration + long barStartEpoch = lastBar.getEndTime().getEpochSecond() + - lastBar.getTimePeriod().getSeconds(); + + CandleBarDto dto = CandleBarDto.builder() + .time(barStartEpoch) + .open(lastBar.getOpenPrice().doubleValue()) + .high(lastBar.getHighPrice().doubleValue()) + .low(lastBar.getLowPrice().doubleValue()) + .close(lastBar.getClosePrice().doubleValue()) + .volume(lastBar.getVolume().doubleValue()) + .signal(signal) + .build(); + + broker.publish(market, candleType, dto); + log.info("[LiveStrategyScheduler] REALTIME_TICK signal={} market={}", signal, market); + + // DB 저장 + try { + tradeSignalService.save( + s.getDeviceId(), s.getUserId(), + market, s.getStrategyId(), null, + signal, + lastBar.getClosePrice().doubleValue(), + barStartEpoch, candleType, "REALTIME_TICK" + ); + if (s.getDeviceId() != null) { + orderExecutionQueue.submitSignal( + s.getDeviceId(), s.getUserId(), market, + s.getStrategyId(), signal, + lastBar.getClosePrice().doubleValue()); + } + } catch (Exception ex) { + log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage()); + } + } + } + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/TradingWebSocketBroker.java b/backend/src/main/java/com/goldenchart/websocket/TradingWebSocketBroker.java new file mode 100644 index 0000000..86520c2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/TradingWebSocketBroker.java @@ -0,0 +1,43 @@ +package com.goldenchart.websocket; + +import com.goldenchart.dto.CandleBarDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +/** + * STOMP 실시간 캔들 분배기 (명세서 3.5 / 5.2). + * + * BarBuilder 또는 LiveStrategyScheduler 에서 확정된 캔들 데이터를 + * 구독자에게 브로드캐스트한다. + * + * 토픽: /sub/charts/{market}/{candleType} + * 페이로드: CandleBarDto (JSON 직렬화) + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TradingWebSocketBroker { + + private final SimpMessagingTemplate messagingTemplate; + + /** + * 특정 마켓·캔들타입 구독자에게 실시간 캔들 업데이트를 발행한다. + * + * @param market 업비트 마켓 코드 (e.g. "KRW-BTC") + * @param candleType 캔들 타입 (e.g. "1m") + * @param bar 전송할 캔들 데이터 + */ + public void publish(String market, String candleType, CandleBarDto bar) { + String destination = "/sub/charts/" + market + "/" + candleType; + try { + messagingTemplate.convertAndSend(destination, bar); + if (log.isDebugEnabled()) { + log.debug("[TradingWebSocketBroker] publish → {} close={}", destination, bar.getClose()); + } + } catch (Exception e) { + log.warn("[TradingWebSocketBroker] 발행 실패 {}: {}", destination, e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java b/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java new file mode 100644 index 0000000..4bc23e2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java @@ -0,0 +1,265 @@ +package com.goldenchart.websocket; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.trading.pipeline.TickActivityTracker; +import com.goldenchart.trading.pipeline.TradeEventQueue; +import com.goldenchart.trading.pipeline.TradeTickEvent; +import com.goldenchart.trading.pipeline.TradingPipelineProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.util.*; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 백엔드 업비트 WebSocket 클라이언트 (명세서 3.1). + * + * - 고정 관심종목(최대 20개) + 동적 차트 오픈 종목(최대 8개) 구독 + * - 수신한 trade 틱을 BarBuilder 로 전달하여 캔들 조립 + * - 연결 끊김 시 지수 백오프 재연결 (최대 60초 간격) + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class UpbitWebSocketClient { + + private final BarBuilder barBuilder; + private final TradeEventQueue tradeEventQueue; + private final TradingPipelineProperties pipelineProperties; + private final TickActivityTracker activityTracker; + private final ObjectMapper objectMapper; + + private final AtomicLong messagesReceived = new AtomicLong(); + private final AtomicLong parseErrors = new AtomicLong(); + private volatile long lastMessageAtMs; + private volatile long connectedAtMs; + private volatile int lastCloseCode; + private volatile String lastCloseReason = ""; + + @Value("${upbit.websocket.url:wss://api.upbit.com/websocket/v1}") + private String wsUrl; + + /** 현재 구독 중인 마켓 코드 집합 (thread-safe) */ + private final CopyOnWriteArraySet subscribedMarkets = new CopyOnWriteArraySet<>(); + + private volatile InternalWsClient wsClient; + private final AtomicBoolean running = new AtomicBoolean(false); + + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "upbit-ws-reconnect"); + t.setDaemon(true); + return t; + }); + + private int reconnectDelaySec = 2; + private static final int MAX_RECONNECT_DELAY = 60; + + // ── 생명주기 ────────────────────────────────────────────────────────────── + + @PostConstruct + public void init() { + running.set(true); + connect(); + } + + @PreDestroy + public void destroy() { + running.set(false); + scheduler.shutdownNow(); + if (wsClient != null) wsClient.close(); + log.info("[UpbitWebSocketClient] 종료"); + } + + // ── 구독 관리 (DynamicSubscriptionManager 에서 호출) ────────────────────── + + /** + * 마켓을 구독 목록에 추가하고 웹소켓 재구독 패킷 전송. + */ + public synchronized void addMarket(String market) { + if (subscribedMarkets.add(market)) { + log.info("[UpbitWebSocketClient] 마켓 추가: {} (총 {})", market, subscribedMarkets.size()); + sendSubscribePacket(); + } else if (wsClient != null && wsClient.isOpen()) { + // 이미 목록에 있어도 재연결 직후 구독 패킷이 누락될 수 있음 + sendSubscribePacket(); + } + } + + /** + * 마켓을 구독 목록에서 제거하고 웹소켓 재구독 패킷 전송. + */ + public synchronized void removeMarket(String market) { + if (subscribedMarkets.remove(market)) { + log.info("[UpbitWebSocketClient] 마켓 제거: {}", market); + sendSubscribePacket(); + } + } + + public Set getSubscribedMarkets() { + return Collections.unmodifiableSet(subscribedMarkets); + } + + public boolean isConnected() { + return wsClient != null && wsClient.isOpen(); + } + + public Map monitorSnapshot() { + Map m = new LinkedHashMap<>(); + m.put("connected", isConnected()); + m.put("subscribedMarkets", subscribedMarkets.size()); + m.put("messagesReceived", messagesReceived.get()); + m.put("parseErrors", parseErrors.get()); + m.put("lastMessageAtMs", lastMessageAtMs); + m.put("connectedAtMs", connectedAtMs); + m.put("lastCloseCode", lastCloseCode); + m.put("lastCloseReason", lastCloseReason); + m.put("reconnectDelaySec", reconnectDelaySec); + return m; + } + + /** 기동·STOMP 구독 직후 — 업비트에 구독 패킷 재전송 */ + public void resubscribeAll() { + sendSubscribePacket(); + } + + /** 틱 정체 등 — 외부 모니터가 재연결을 요청 */ + public void requestReconnect(String reason) { + log.warn("[UpbitWebSocketClient] 재연결 요청: {}", reason); + if (wsClient != null && wsClient.isOpen()) { + wsClient.close(); + } else if (running.get()) { + scheduleReconnect(); + } + } + + // ── 내부 연결 / 재연결 ──────────────────────────────────────────────────── + + private void connect() { + if (!running.get()) return; + try { + wsClient = new InternalWsClient(new URI(wsUrl)); + wsClient.connect(); + } catch (Exception e) { + log.error("[UpbitWebSocketClient] 연결 실패: {}", e.getMessage()); + scheduleReconnect(); + } + } + + private void scheduleReconnect() { + if (!running.get()) return; + log.info("[UpbitWebSocketClient] {}초 후 재연결 시도...", reconnectDelaySec); + scheduler.schedule(() -> { + connect(); + reconnectDelaySec = Math.min(reconnectDelaySec * 2, MAX_RECONNECT_DELAY); + }, reconnectDelaySec, TimeUnit.SECONDS); + } + + /** 현재 subscribedMarkets 기반으로 업비트 구독 JSON 패킷 전송 */ + private void sendSubscribePacket() { + if (wsClient == null || !wsClient.isOpen() || subscribedMarkets.isEmpty()) return; + try { + List codes = new ArrayList<>(subscribedMarkets); + // 업비트 WebSocket 구독 포맷 + String packet = objectMapper.writeValueAsString(List.of( + Map.of("ticket", UUID.randomUUID().toString()), + Map.of("type", "trade", "codes", codes, "isOnlyRealtime", true), + Map.of("format", "SIMPLE") + )); + wsClient.send(packet); + log.debug("[UpbitWebSocketClient] 구독 패킷 전송: {}", codes); + } catch (Exception e) { + log.error("[UpbitWebSocketClient] 구독 패킷 전송 실패: {}", e.getMessage()); + } + } + + // ── 내부 WebSocket 구현 ─────────────────────────────────────────────────── + + private class InternalWsClient extends WebSocketClient { + + InternalWsClient(URI uri) { + super(uri); + } + + @Override + public void onOpen(ServerHandshake handshake) { + log.info("[UpbitWebSocketClient] 연결 성공"); + reconnectDelaySec = 2; // 재연결 딜레이 리셋 + connectedAtMs = System.currentTimeMillis(); + sendSubscribePacket(); + } + + @Override + public void onMessage(String message) { + // 업비트는 binary frame 으로 전송 — text fallback 무시 + } + + @Override + public void onMessage(java.nio.ByteBuffer bytes) { + try { + byte[] payload = new byte[bytes.remaining()]; + bytes.get(payload); + JsonNode node = objectMapper.readTree(payload); + + String ty = node.path("ty").asText(""); + if (ty.isEmpty()) ty = node.path("type").asText(""); + if (!"trade".equals(ty)) return; + + messagesReceived.incrementAndGet(); + lastMessageAtMs = System.currentTimeMillis(); + + String market = node.path("cd").asText(""); + if (market.isEmpty()) market = node.path("code").asText(""); + if (market.isEmpty()) return; + + double tradePrice = node.path("tp").asDouble(); + if (tradePrice == 0) tradePrice = node.path("trade_price").asDouble(); + double tradeVolume = node.path("tv").asDouble(); + if (tradeVolume == 0) tradeVolume = node.path("trade_volume").asDouble(); + + long tradeTimeMs = node.path("tms").asLong(); + if (tradeTimeMs == 0) tradeTimeMs = node.path("ttms").asLong(); + if (tradeTimeMs == 0) tradeTimeMs = node.path("trade_timestamp").asLong(); + if (tradeTimeMs == 0) tradeTimeMs = node.path("timestamp").asLong(); + if (tradeTimeMs == 0) tradeTimeMs = System.currentTimeMillis(); + + activityTracker.record(market); + if (pipelineProperties.isEnabled()) { + tradeEventQueue.offer(TradeTickEvent.of( + market, tradePrice, tradeVolume, tradeTimeMs)); + } else { + barBuilder.onTick(market, tradePrice, tradeVolume, tradeTimeMs); + } + } catch (Exception e) { + parseErrors.incrementAndGet(); + log.warn("[UpbitWebSocketClient] 메시지 파싱 오류: {}", e.getMessage()); + } + } + + @Override + public void onClose(int code, String reason, boolean remote) { + lastCloseCode = code; + lastCloseReason = reason != null ? reason : ""; + log.warn("[UpbitWebSocketClient] 연결 종료 code={}, reason={}", code, reason); + if (running.get()) scheduleReconnect(); + } + + @Override + public void onError(Exception ex) { + log.error("[UpbitWebSocketClient] 에러: {}", ex.getMessage()); + } + } +} diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml new file mode 100644 index 0000000..f93fcc0 --- /dev/null +++ b/backend/src/main/resources/application-docker.yml @@ -0,0 +1,29 @@ +# Docker 컨테이너 환경 설정 (SPRING_PROFILES_ACTIVE=docker 일 때 적용) +spring: + datasource: + url: jdbc:mysql://mysql:3306/stockAnalyzer?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true + username: ${DB_USERNAME:stock} + password: ${DB_PASSWORD:analyzer} + hikari: + connection-timeout: 60000 + initialization-fail-timeout: 60000 + + jpa: + hibernate: + ddl-auto: validate + + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 0 + validate-on-migrate: false + +goldenchart: + cors: + allowed-origins: + - "*" + +logging: + level: + com.goldenchart: INFO + org.flywaydb: INFO diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..cb9c8f2 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,103 @@ +spring: + application: + name: goldenchart-backend + + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3306/stockAnalyzer?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true} + username: ${DB_USERNAME:stock} + password: ${DB_PASSWORD:analyzer} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + default_batch_fetch_size: 100 + open-in-view: false + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + + jackson: + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + default-property-inclusion: non_null + time-zone: Asia/Seoul + +server: + port: 8080 + servlet: + context-path: /api + compression: + enabled: true + mime-types: application/json,application/javascript,text/css + +# Upbit API +upbit: + api: + base-url: https://api.upbit.com + candle-limit: 1000 + request-delay-ms: 110 + # EMA 수렴을 위한 warm-up 목표치 (페이지네이션으로 달성) + # EMA(200) 완전 수렴: ~1150봉 필요. 여유분 포함 1500봉으로 설정. + # 1500봉 = 200봉×8페이지 / 110ms 지연 = 구독당 약 880ms 추가 소요 + warmup-count: 1500 + websocket: + url: wss://api.upbit.com/websocket/v1 + +# Logging +logging: + level: + com.goldenchart: INFO + org.springframework.security: WARN + org.hibernate.SQL: WARN + pattern: + console: "%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + +# GoldenChart settings +goldenchart: + secrets: + # 업비트 API 키 DB 암호화 (운영: GC_SECRETS_ENCRYPTION_KEY 환경변수 필수, 32자 이상 권장) + encryption-key: ${GC_SECRETS_ENCRYPTION_KEY:} + indicator: + warmup-bars: 200 # EMA/RMA 수렴을 위한 워밍업 기간 + max-bars: 2000 # 최대 로드 캔들 수 + trading: + pipeline: + enabled: true + queue-capacity: 65536 + worker-threads: 6 + order-rate-per-second: 5.0 + max-bars-per-series: 500 + stale-tick-seconds: 30 + gap-backfill-cron: "0 */5 * * * *" + gap-backfill-candle-count: 20 + cors: + allowed-origins: + - http://localhost:5173 + - http://localhost:3000 + - http://localhost:80 + - http://localhost + +# Firebase FCM (서비스 계정 JSON: classpath:firebase-service-account.json) +firebase: + enabled: ${FIREBASE_ENABLED:false} + service-account-file: ${FIREBASE_SERVICE_ACCOUNT:firebase-service-account.json} diff --git a/backend/src/main/resources/db/migration/V10__gc_live_strategy_settings.sql b/backend/src/main/resources/db/migration/V10__gc_live_strategy_settings.sql new file mode 100644 index 0000000..619526a --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__gc_live_strategy_settings.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- V10: 실시간 전략 체크 설정 테이블 +-- 디바이스(또는 유저)별, 마켓별로 어느 전략을 어떤 방식으로 +-- 실시간 체크할지 저장한다. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS gc_live_strategy_settings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + user_id BIGINT NULL COMMENT '회원 ID', + market VARCHAR(30) NOT NULL DEFAULT 'KRW-BTC' COMMENT '업비트 마켓 코드 (e.g. KRW-BTC)', + strategy_id BIGINT NULL COMMENT '연결된 전략 ID (gc_strategy.id)', + is_live_check TINYINT(1) NOT NULL DEFAULT 0 COMMENT '실시간 체크 ON/OFF (1=ON)', + execution_type VARCHAR(30) NOT NULL DEFAULT 'CANDLE_CLOSE' + COMMENT '체크 방식: CANDLE_CLOSE(봉 마감) | REALTIME_TICK(3초 틱)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_live_device_market (device_id, market), + UNIQUE KEY uk_live_user_market (user_id, market), + INDEX idx_live_device (device_id), + INDEX idx_live_user (user_id), + INDEX idx_live_market (market) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='디바이스/유저별 종목 실시간 전략 체크 설정'; diff --git a/backend/src/main/resources/db/migration/V11__gc_trade_signal.sql b/backend/src/main/resources/db/migration/V11__gc_trade_signal.sql new file mode 100644 index 0000000..a88c91d --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__gc_trade_signal.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- V11: 매매 시그널 이력 테이블 + 앱설정 매매알림 팝업 컬럼 +-- ============================================================ + +-- ① 실시간 전략 체크에서 발생한 매매 시그널 이력 +CREATE TABLE IF NOT EXISTS gc_trade_signal ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100) NULL COMMENT '기기 식별자', + user_id BIGINT NULL COMMENT '회원 ID', + market VARCHAR(30) NOT NULL COMMENT '마켓 코드 (e.g. KRW-BTC)', + strategy_id BIGINT NULL COMMENT '전략 ID', + strategy_name VARCHAR(200) NULL COMMENT '전략명 (스냅샷)', + signal_type VARCHAR(10) NOT NULL COMMENT 'BUY | SELL', + price DOUBLE NOT NULL COMMENT '시그널 발생 시 종가', + candle_time BIGINT NOT NULL COMMENT '캔들 시작 Unix 초', + candle_type VARCHAR(10) NOT NULL DEFAULT '1m' COMMENT '캔들 타입', + execution_type VARCHAR(30) NOT NULL COMMENT 'CANDLE_CLOSE | REALTIME_TICK', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_ts_device (device_id), + INDEX idx_ts_market (market), + INDEX idx_ts_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='실시간 전략 체크 매매 시그널 이력'; + +-- ② gc_app_settings 에 매매알림 팝업 ON/OFF 컬럼 추가 +ALTER TABLE gc_app_settings + ADD COLUMN trade_alert_popup TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '매매 시그널 발생 시 알림 팝업 표시 여부 (1=ON, 0=OFF)'; diff --git a/backend/src/main/resources/db/migration/V12__position_mode.sql b/backend/src/main/resources/db/migration/V12__position_mode.sql new file mode 100644 index 0000000..273c3dd --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__position_mode.sql @@ -0,0 +1,13 @@ +-- ============================================================ +-- V12: 포지션 종속성 모드 컬럼 추가 +-- gc_live_strategy_settings : position_mode (LONG_ONLY | SIGNAL_ONLY) +-- gc_backtest_settings : position_mode (LONG_ONLY | SIGNAL_ONLY) +-- ============================================================ + +ALTER TABLE gc_live_strategy_settings + ADD COLUMN position_mode VARCHAR(20) NOT NULL DEFAULT 'LONG_ONLY' + COMMENT '매도 시그널 포지션 종속성 모드 (LONG_ONLY | SIGNAL_ONLY)'; + +ALTER TABLE gc_backtest_settings + ADD COLUMN position_mode VARCHAR(20) NOT NULL DEFAULT 'LONG_ONLY' + COMMENT '백테스팅 매도 시그널 포지션 종속성 모드 (LONG_ONLY | SIGNAL_ONLY)'; diff --git a/backend/src/main/resources/db/migration/V13__live_strategy_defaults.sql b/backend/src/main/resources/db/migration/V13__live_strategy_defaults.sql new file mode 100644 index 0000000..cddbd99 --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__live_strategy_defaults.sql @@ -0,0 +1,10 @@ +-- 전역 실시간 전략 설정 (관심종목 = 전략 체크 대상) +ALTER TABLE gc_app_settings + ADD COLUMN live_strategy_check TINYINT(1) NOT NULL DEFAULT 0 + COMMENT '실시간 전략 체크 마스터 ON/OFF', + ADD COLUMN live_strategy_id BIGINT NULL + COMMENT '관심종목 공통 적용 전략 ID', + ADD COLUMN live_execution_type VARCHAR(30) NOT NULL DEFAULT 'CANDLE_CLOSE' + COMMENT 'CANDLE_CLOSE | REALTIME_TICK', + ADD COLUMN live_position_mode VARCHAR(20) NOT NULL DEFAULT 'LONG_ONLY' + COMMENT 'LONG_ONLY | SIGNAL_ONLY'; diff --git a/backend/src/main/resources/db/migration/V14__chart_series_price_labels.sql b/backend/src/main/resources/db/migration/V14__chart_series_price_labels.sql new file mode 100644 index 0000000..5f4ca03 --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__chart_series_price_labels.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- V14: gc_app_settings 에 보조지표 가격축 라벨·설명 표시 옵션 추가 +-- ON(1, 기본값): 우측 가격축 금액 하이라이트 + 선 설명(전환선 등) 표시 +-- OFF(0) : 지표 선만 표시 +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN chart_series_price_labels TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '보조지표 우측 가격축 라벨·설명·금액 하이라이트 (1=ON, 0=OFF)'; diff --git a/backend/src/main/resources/db/migration/V15__chart_volume_visible.sql b/backend/src/main/resources/db/migration/V15__chart_volume_visible.sql new file mode 100644 index 0000000..e71b4eb --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__chart_volume_visible.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- V15: gc_app_settings 에 거래량 표시 옵션 추가 +-- ON(1, 기본값): 차트 하단 거래량 바 표시 +-- OFF(0) : 거래량 pane 숨김 +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN chart_volume_visible TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '차트 하단 거래량 바 표시 (1=ON, 0=OFF)'; diff --git a/backend/src/main/resources/db/migration/V16__trade_alert_sound.sql b/backend/src/main/resources/db/migration/V16__trade_alert_sound.sql new file mode 100644 index 0000000..fc57632 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__trade_alert_sound.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- V16: gc_app_settings 에 매매 알림 사운드 옵션 추가 +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN trade_alert_sound_enabled TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '매매 시그널 알림 사운드 재생 여부 (1=ON, 0=OFF)'; + +ALTER TABLE gc_app_settings + ADD COLUMN trade_alert_sound VARCHAR(32) NOT NULL DEFAULT 'bell' + COMMENT '매매 시그널 알림음 ID (bell, chime 등)'; diff --git a/backend/src/main/resources/db/migration/V17__chart_legend_options.sql b/backend/src/main/resources/db/migration/V17__chart_legend_options.sql new file mode 100644 index 0000000..819e188 --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__chart_legend_options.sql @@ -0,0 +1,7 @@ +-- ============================================================ +-- V17: gc_app_settings 에 차트 상단 범례 표시 옵션 JSON 추가 +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN chart_legend_options_json JSON NULL + COMMENT '차트 상단 범례(tv-legend) 항목별 표시 옵션'; diff --git a/backend/src/main/resources/db/migration/V18__gc_paper_trading.sql b/backend/src/main/resources/db/migration/V18__gc_paper_trading.sql new file mode 100644 index 0000000..34af252 --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__gc_paper_trading.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- V18: 모의투자 (페이퍼 트레이딩) +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN paper_trading_enabled TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '모의투자 기능 사용 (1=ON)', + ADD COLUMN paper_initial_capital DECIMAL(20, 2) NOT NULL DEFAULT 10000000 + COMMENT '모의투자 초기 자본 (KRW)', + ADD COLUMN paper_fee_rate_pct DECIMAL(8, 4) NOT NULL DEFAULT 0.0500 + COMMENT '모의투자 수수료율 (%)', + ADD COLUMN paper_slippage_pct DECIMAL(8, 4) NOT NULL DEFAULT 0.0000 + COMMENT '모의투자 슬리피지 (%)', + ADD COLUMN paper_min_order_krw DECIMAL(20, 2) NOT NULL DEFAULT 5000 + COMMENT '모의투자 최소 주문 금액', + ADD COLUMN paper_auto_trade_enabled TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '실시간 전략 시그널 시 자동 모의매매 (1=ON)', + ADD COLUMN paper_auto_trade_budget_pct DECIMAL(8, 4) NOT NULL DEFAULT 95.0000 + COMMENT '자동 매수 시 가용 현금 사용 비율 (%)'; + +CREATE TABLE gc_paper_account ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL, + device_id VARCHAR(100) NOT NULL, + cash_balance DECIMAL(20, 2) NOT NULL DEFAULT 10000000, + realized_pnl DECIMAL(20, 2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_paper_account_device (device_id), + UNIQUE KEY uk_paper_account_user (user_id), + INDEX idx_paper_account_device (device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 계좌 (현금 잔고)'; + +CREATE TABLE gc_paper_position ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + symbol VARCHAR(50) NOT NULL, + korean_name VARCHAR(100) NULL, + quantity DECIMAL(30, 12) NOT NULL DEFAULT 0, + avg_price DECIMAL(20, 2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_paper_position_account_symbol (account_id, symbol), + CONSTRAINT fk_paper_position_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 보유 종목'; + +CREATE TABLE gc_paper_trade ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + symbol VARCHAR(50) NOT NULL, + side VARCHAR(10) NOT NULL COMMENT 'BUY | SELL', + order_kind VARCHAR(20) NOT NULL DEFAULT 'limit' COMMENT 'limit | market', + source VARCHAR(20) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL | STRATEGY', + strategy_id BIGINT NULL, + price DECIMAL(20, 2) NOT NULL, + quantity DECIMAL(30, 12) NOT NULL, + gross_amount DECIMAL(20, 2) NOT NULL, + fee_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + net_amount DECIMAL(20, 2) NOT NULL, + cash_after DECIMAL(20, 2) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_paper_trade_account (account_id), + INDEX idx_paper_trade_created (created_at), + CONSTRAINT fk_paper_trade_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 체결 이력'; diff --git a/backend/src/main/resources/db/migration/V19__live_trading_and_candle_type.sql b/backend/src/main/resources/db/migration/V19__live_trading_and_candle_type.sql new file mode 100644 index 0000000..7eb1125 --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__live_trading_and_candle_type.sql @@ -0,0 +1,38 @@ +-- 실거래(Upbit) + 운영 모드 + 종목별 분봉 + +ALTER TABLE gc_app_settings + ADD COLUMN trading_mode VARCHAR(10) NOT NULL DEFAULT 'PAPER' + COMMENT 'PAPER | LIVE | BOTH' AFTER paper_auto_trade_budget_pct, + ADD COLUMN live_auto_trade_enabled TINYINT(1) NOT NULL DEFAULT 0 + COMMENT '실거래 자동매매 ON' AFTER trading_mode, + ADD COLUMN upbit_access_key VARCHAR(128) NULL + COMMENT '업비트 Open API Access Key' AFTER live_auto_trade_enabled, + ADD COLUMN upbit_secret_key VARCHAR(256) NULL + COMMENT '업비트 Open API Secret Key' AFTER upbit_access_key; + +ALTER TABLE gc_live_strategy_settings + ADD COLUMN candle_type VARCHAR(10) NOT NULL DEFAULT '1m' + COMMENT '전략 평가·WS warm-up 분봉: 1m,3m,5m,15m,30m,1h,4h,1d' + AFTER execution_type; + +CREATE TABLE IF NOT EXISTS gc_live_trade ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100) NOT NULL, + user_id BIGINT NULL, + symbol VARCHAR(30) NOT NULL COMMENT 'KRW-BTC', + side VARCHAR(8) NOT NULL COMMENT 'BUY | SELL', + order_kind VARCHAR(16) NOT NULL DEFAULT 'market', + source VARCHAR(32) NOT NULL DEFAULT 'STRATEGY' COMMENT 'STRATEGY | MANUAL | STOP_LOSS | TAKE_PROFIT', + strategy_id BIGINT NULL, + upbit_order_uuid VARCHAR(64) NULL, + price DECIMAL(20, 8) NOT NULL, + quantity DECIMAL(24, 12) NOT NULL, + gross_amount DECIMAL(20, 2) NOT NULL, + fee_amount DECIMAL(20, 2) NULL, + state VARCHAR(24) NOT NULL DEFAULT 'done', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_live_trade_device (device_id), + INDEX idx_live_trade_symbol (symbol), + INDEX idx_live_trade_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='업비트 실거래 체결 이력'; diff --git a/backend/src/main/resources/db/migration/V20__chart_stomp_fcm_live_budget.sql b/backend/src/main/resources/db/migration/V20__chart_stomp_fcm_live_budget.sql new file mode 100644 index 0000000..c3b093a --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__chart_stomp_fcm_live_budget.sql @@ -0,0 +1,20 @@ +-- 차트 실시간 소스, 실거래 예산, FCM 푸시 설정 +ALTER TABLE gc_app_settings + ADD COLUMN chart_realtime_source VARCHAR(20) NOT NULL DEFAULT 'BACKEND_STOMP' + COMMENT 'BACKEND_STOMP | UPBIT_DIRECT', + ADD COLUMN live_auto_trade_budget_pct DECIMAL(8, 4) NOT NULL DEFAULT 95.0000, + ADD COLUMN fcm_push_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS gc_fcm_token ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NULL, + device_id VARCHAR(64) NOT NULL, + token VARCHAR(512) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + last_used_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_gc_fcm_token (token), + KEY idx_gc_fcm_device (device_id), + KEY idx_gc_fcm_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/src/main/resources/db/migration/V21__gc_user.sql b/backend/src/main/resources/db/migration/V21__gc_user.sql new file mode 100644 index 0000000..7c2039f --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__gc_user.sql @@ -0,0 +1,13 @@ +-- GoldenChart 로그인 사용자 +CREATE TABLE IF NOT EXISTS gc_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL COMMENT '로그인 ID', + password_hash VARCHAR(100) NOT NULL, + display_name VARCHAR(100) NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_gc_user_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 로그인 사용자'; diff --git a/backend/src/main/resources/db/migration/V22__display_timezone.sql b/backend/src/main/resources/db/migration/V22__display_timezone.sql new file mode 100644 index 0000000..9883f93 --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__display_timezone.sql @@ -0,0 +1,3 @@ +ALTER TABLE gc_app_settings + ADD COLUMN display_timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Seoul' + COMMENT '차트·UI 시간 표시 IANA 시간대 (예: Asia/Seoul, UTC)' AFTER default_layout_id; diff --git a/backend/src/main/resources/db/migration/V23__user_role.sql b/backend/src/main/resources/db/migration/V23__user_role.sql new file mode 100644 index 0000000..9f323b2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V23__user_role.sql @@ -0,0 +1,5 @@ +ALTER TABLE gc_user + ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'USER' + COMMENT 'ADMIN | USER | GUEST' AFTER enabled; + +UPDATE gc_user SET role = 'ADMIN' WHERE username = 'admin'; diff --git a/backend/src/main/resources/db/migration/V24__role_menu_permission.sql b/backend/src/main/resources/db/migration/V24__role_menu_permission.sql new file mode 100644 index 0000000..7f418ae --- /dev/null +++ b/backend/src/main/resources/db/migration/V24__role_menu_permission.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS gc_role_menu_permission ( + id BIGINT NOT NULL AUTO_INCREMENT, + role VARCHAR(20) NOT NULL COMMENT 'ADMIN | USER | GUEST', + menu_id VARCHAR(50) NOT NULL, + allowed TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uk_role_menu (role, menu_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='역할별 메뉴 접근 권한'; + +-- ADMIN: 전체 허용 +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('ADMIN', 'dashboard', 1), ('ADMIN', 'chart', 1), ('ADMIN', 'paper', 1), +('ADMIN', 'strategy', 1), ('ADMIN', 'backtest', 1), ('ADMIN', 'notifications', 1), +('ADMIN', 'settings', 1), +('ADMIN', 'settings_general', 1), ('ADMIN', 'settings_chart', 1), +('ADMIN', 'settings_indicators', 1), ('ADMIN', 'settings_backtest', 1), +('ADMIN', 'settings_strategy', 1), ('ADMIN', 'settings_paper', 1), +('ADMIN', 'settings_alert', 1), ('ADMIN', 'settings_network', 1), +('ADMIN', 'settings_admin', 1); + +-- USER: 관리자 설정 제외 +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('USER', 'dashboard', 1), ('USER', 'chart', 1), ('USER', 'paper', 1), +('USER', 'strategy', 1), ('USER', 'backtest', 1), ('USER', 'notifications', 1), +('USER', 'settings', 1), +('USER', 'settings_general', 1), ('USER', 'settings_chart', 1), +('USER', 'settings_indicators', 1), ('USER', 'settings_backtest', 1), +('USER', 'settings_strategy', 1), ('USER', 'settings_paper', 1), +('USER', 'settings_alert', 1), ('USER', 'settings_network', 1), +('USER', 'settings_admin', 0); + +-- GUEST: 차트·설정(일부)만 +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES +('GUEST', 'dashboard', 0), ('GUEST', 'chart', 1), ('GUEST', 'paper', 0), +('GUEST', 'strategy', 0), ('GUEST', 'backtest', 0), ('GUEST', 'notifications', 0), +('GUEST', 'settings', 1), +('GUEST', 'settings_general', 1), ('GUEST', 'settings_chart', 1), +('GUEST', 'settings_indicators', 0), ('GUEST', 'settings_backtest', 0), +('GUEST', 'settings_strategy', 0), ('GUEST', 'settings_paper', 0), +('GUEST', 'settings_alert', 0), ('GUEST', 'settings_network', 1), +('GUEST', 'settings_admin', 0); diff --git a/backend/src/main/resources/db/migration/V25__encrypt_upbit_key_columns.sql b/backend/src/main/resources/db/migration/V25__encrypt_upbit_key_columns.sql new file mode 100644 index 0000000..c218882 --- /dev/null +++ b/backend/src/main/resources/db/migration/V25__encrypt_upbit_key_columns.sql @@ -0,0 +1,6 @@ +-- 업비트 API 키 AES 암호문 저장용 컬럼 확장 +ALTER TABLE gc_app_settings + MODIFY COLUMN upbit_access_key VARCHAR(512) NULL + COMMENT '업비트 Access Key (AES-GCM 암호문)', + MODIFY COLUMN upbit_secret_key VARCHAR(512) NULL + COMMENT '업비트 Secret Key (AES-GCM 암호문)'; diff --git a/backend/src/main/resources/db/migration/V26__trade_alert_popup_layout.sql b/backend/src/main/resources/db/migration/V26__trade_alert_popup_layout.sql new file mode 100644 index 0000000..c070205 --- /dev/null +++ b/backend/src/main/resources/db/migration/V26__trade_alert_popup_layout.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- V26: 매매 알림 팝업 표시 위치·배치 방식 +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN trade_alert_popup_position VARCHAR(10) NOT NULL DEFAULT 'right' + COMMENT '알림 팝업 위치: right | left | bottom', + ADD COLUMN trade_alert_popup_layout VARCHAR(10) NOT NULL DEFAULT 'stack' + COMMENT '알림 팝업 배치: stack | grid | strip | single', + ADD COLUMN trade_alert_popup_grid_cols INT NOT NULL DEFAULT 2 + COMMENT '그리드 배치 시 열 개수 (2~4)'; diff --git a/backend/src/main/resources/db/migration/V27__trade_alert_popup_grid_cols_int.sql b/backend/src/main/resources/db/migration/V27__trade_alert_popup_grid_cols_int.sql new file mode 100644 index 0000000..6452086 --- /dev/null +++ b/backend/src/main/resources/db/migration/V27__trade_alert_popup_grid_cols_int.sql @@ -0,0 +1,4 @@ +-- V26에서 TINYINT로 생성된 컬럼을 Hibernate Integer 매핑과 맞추기 위해 INT로 변경 +ALTER TABLE gc_app_settings + MODIFY COLUMN trade_alert_popup_grid_cols INT NOT NULL DEFAULT 2 + COMMENT '그리드 배치 시 열 개수 (2~4)'; diff --git a/backend/src/main/resources/db/migration/V2__create_gc_tables.sql b/backend/src/main/resources/db/migration/V2__create_gc_tables.sql new file mode 100644 index 0000000..5be4f71 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__create_gc_tables.sql @@ -0,0 +1,115 @@ +-- ============================================================ +-- GoldenChart 전용 테이블 (V2) +-- 기존 stockAnalyzer DB 에 추가 +-- ============================================================ + +-- ──────────────────────────────────────────────────────────── +-- 1. gc_chart_workspace — 사용자 차트 워크스페이스 +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS gc_chart_workspace ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL COMMENT 'users.id 논리 참조 (로그인 사용자)', + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + layout_id VARCHAR(20) NOT NULL DEFAULT '1x1' COMMENT '레이아웃 (1x1/2x1/1x2/2x2/3x1 등)', + sync_options_json JSON NULL COMMENT '차트 간 동기화 옵션 {symbol, timeframe, crosshair}', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_gc_ws_user (user_id), + INDEX idx_gc_ws_device (device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 멀티차트 워크스페이스 설정'; + +-- ──────────────────────────────────────────────────────────── +-- 2. gc_chart_slot — 슬롯별 차트 설정 +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS gc_chart_slot ( + id BIGINT NOT NULL AUTO_INCREMENT, + workspace_id BIGINT NOT NULL COMMENT 'gc_chart_workspace.id', + slot_index INT NOT NULL DEFAULT 0 COMMENT '슬롯 인덱스 (0-based)', + symbol VARCHAR(50) NULL COMMENT '종목 코드 (KRW-BTC, AAPL 등)', + timeframe VARCHAR(10) NULL COMMENT '타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M)', + chart_type VARCHAR(20) NOT NULL DEFAULT 'candlestick', + theme VARCHAR(20) NOT NULL DEFAULT 'dark', + mode VARCHAR(20) NOT NULL DEFAULT 'chart' COMMENT 'chart / trading', + log_scale TINYINT(1) NOT NULL DEFAULT 0, + indicators_json JSON NULL COMMENT 'IndicatorConfig[] — 보조지표 설정 배열', + drawings_json JSON NULL COMMENT 'Drawing[] — 드로잉 객체 배열', + drawings_locked TINYINT(1) NOT NULL DEFAULT 0, + drawings_visible TINYINT(1) NOT NULL DEFAULT 1, + pane_layout_json JSON NULL COMMENT '보조지표 창 높이 배열 [{paneIndex, height}]', + main_chart_style_json JSON NULL COMMENT '캔들 색상 등 메인 차트 스타일', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_slot_workspace_index (workspace_id, slot_index), + INDEX idx_gc_slot_ws (workspace_id), + CONSTRAINT fk_gc_slot_workspace FOREIGN KEY (workspace_id) + REFERENCES gc_chart_workspace(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 슬롯별 차트 설정 (지표·드로잉 포함)'; + +-- ──────────────────────────────────────────────────────────── +-- 3. gc_watchlist — 관심종목 +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS gc_watchlist ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL COMMENT 'users.id 논리 참조', + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + symbol VARCHAR(50) NOT NULL COMMENT 'KRW-BTC, AAPL 등', + korean_name VARCHAR(100) NULL, + english_name VARCHAR(100) NULL, + display_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_watchlist_user_symbol (user_id, symbol), + UNIQUE KEY uk_watchlist_device_symbol (device_id, symbol), + INDEX idx_gc_wl_user (user_id), + INDEX idx_gc_wl_device (device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 관심종목'; + +-- ──────────────────────────────────────────────────────────── +-- 4. gc_holdings — 보유종목 +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS gc_holdings ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL COMMENT 'users.id 논리 참조', + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + symbol VARCHAR(50) NOT NULL, + korean_name VARCHAR(100) NULL, + english_name VARCHAR(100) NULL, + avg_price DECIMAL(30, 8) NULL COMMENT '평균 매입가', + quantity DECIMAL(30, 8) NULL COMMENT '보유 수량', + display_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_holdings_user_symbol (user_id, symbol), + UNIQUE KEY uk_holdings_device_symbol (device_id, symbol), + INDEX idx_gc_hld_user (user_id), + INDEX idx_gc_hld_device (device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 보유종목'; + +-- ──────────────────────────────────────────────────────────── +-- 5. gc_indicator_cache — 지표 계산 캐시 (선택적 사용) +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS gc_indicator_cache ( + id BIGINT NOT NULL AUTO_INCREMENT, + cache_key VARCHAR(200) NOT NULL COMMENT 'symbol:timeframe:type:params_hash', + symbol VARCHAR(50) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + indicator_type VARCHAR(50) NOT NULL, + params_json JSON NULL COMMENT '계산 파라미터', + result_json MEDIUMTEXT NULL COMMENT 'IndicatorResponse JSON', + bar_count INT NULL COMMENT '계산에 사용된 캔들 수', + last_bar_time BIGINT NULL COMMENT '마지막 캔들 Unix 타임스탬프(초)', + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expired_at DATETIME NULL COMMENT 'NULL=영구, 설정 시 만료', + PRIMARY KEY (id), + UNIQUE KEY uk_gc_ind_cache_key (cache_key), + INDEX idx_gc_cache_symbol_tf (symbol, timeframe), + INDEX idx_gc_cache_expired (expired_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 지표 계산 결과 캐시'; diff --git a/backend/src/main/resources/db/migration/V3__gc_indicator_settings.sql b/backend/src/main/resources/db/migration/V3__gc_indicator_settings.sql new file mode 100644 index 0000000..f903b60 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__gc_indicator_settings.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- V3: 전역 지표 파라미터 설정 테이블 +-- 장치(device_id) 또는 사용자(user_id)별로 지표 계산에 사용할 +-- 모든 파라미터를 JSON으로 저장한다. +-- 프론트엔드 indicatorRegistry.ts의 defaultParams를 오버라이드. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS gc_indicator_settings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + user_id BIGINT NULL COMMENT '회원 ID (향후 확장용)', + + -- 지표 타입별 파라미터를 최상위 키로 저장 + -- 예) {"RSI":{"length":9},"MACD":{"fastLength":12,"slowLength":26,"signalLength":9},...} + -- MySQL 8.0: JSON 컬럼에 DEFAULT 값 불가 → 애플리케이션 레이어에서 '{}' 기본값 처리 + params_json JSON NOT NULL, + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_indi_device (device_id), + UNIQUE KEY uk_indi_user (user_id), + INDEX idx_indi_device (device_id), + INDEX idx_indi_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='장치/사용자별 전역 지표 계산 파라미터'; diff --git a/backend/src/main/resources/db/migration/V4__gc_app_settings.sql b/backend/src/main/resources/db/migration/V4__gc_app_settings.sql new file mode 100644 index 0000000..00c5bc6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__gc_app_settings.sql @@ -0,0 +1,38 @@ +-- ============================================================ +-- V4: 앱 전역 차트 기본 설정 테이블 +-- 장치(device_id) 또는 사용자(user_id)별로 하드코딩을 대체하는 +-- 기본 차트 설정값을 저장한다. +-- +-- 이 테이블의 값이 우선 적용되며, 없으면 프론트엔드 코드의 fallback 값 사용. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS gc_app_settings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + user_id BIGINT NULL COMMENT '회원 ID (향후 확장)', + + -- 기본 차트 설정 (per-slot 기본값, gc_chart_slot 없을 때 사용) + default_symbol VARCHAR(50) NOT NULL DEFAULT 'KRW-BTC' COMMENT '기본 종목', + default_timeframe VARCHAR(10) NOT NULL DEFAULT '1D' COMMENT '기본 타임프레임', + default_chart_type VARCHAR(20) NOT NULL DEFAULT 'candlestick' COMMENT '기본 차트 타입', + default_theme VARCHAR(20) NOT NULL DEFAULT 'dark' COMMENT '기본 테마', + default_log_scale TINYINT(1) NOT NULL DEFAULT 0 COMMENT '기본 로그 스케일', + default_layout_id VARCHAR(20) NOT NULL DEFAULT '1' COMMENT '기본 레이아웃 ID', + + -- 캔들 색상 기본값 (frontend DEFAULT_MAIN_CHART_STYLE 대체) + -- {"upColor":"#ff6b6b","downColor":"#4dabf7","borderUpColor":"#ff6b6b",...} + main_chart_style_json JSON NULL COMMENT '캔들 색상 설정', + + -- 기본 차트 동기화 옵션 (frontend DEFAULT_SYNC 대체) + -- {"symbol":false,"interval":false,"crosshair":true,"time":false,"dateRange":false} + sync_options_json JSON NULL COMMENT '멀티차트 동기화 옵션', + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_app_device (device_id), + UNIQUE KEY uk_app_user (user_id), + INDEX idx_app_device (device_id), + INDEX idx_app_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='장치/사용자별 앱 전역 차트 기본 설정 (하드코딩 대체)'; diff --git a/backend/src/main/resources/db/migration/V5__gc_indicator_visual.sql b/backend/src/main/resources/db/migration/V5__gc_indicator_visual.sql new file mode 100644 index 0000000..5e0f0aa --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__gc_indicator_visual.sql @@ -0,0 +1,20 @@ +-- V5: gc_indicator_settings 에 시각 설정(색상, 선 굵기, 수평선) 컬럼 추가 +-- visual_config_json 구조 예시: +-- { +-- "RSI": { +-- "plots": [{"id":"plot0","title":"RSI","color":"#7E57C2","type":"line","lineWidth":2}], +-- "hlines": [{"price":70,"color":"#EF5350","visible":true},{"price":30,"color":"#26A69A","visible":true}] +-- }, +-- "MACD": { +-- "plots": [{"id":"macd","title":"MACD","color":"#2962FF","type":"line","lineWidth":2}, ...], +-- "hlines": [] +-- }, +-- "IchimokuCloud": { +-- "plots": [...], +-- "hlines": [], +-- "cloudColors": {"bullishColor":"#EF535033","bearishColor":"#26A69A33"} +-- } +-- } +ALTER TABLE gc_indicator_settings + ADD COLUMN visual_config_json JSON NULL + COMMENT '지표 시각 설정 JSON (색상·선굵기·수평선): Map'; diff --git a/backend/src/main/resources/db/migration/V6__gc_strategy.sql b/backend/src/main/resources/db/migration/V6__gc_strategy.sql new file mode 100644 index 0000000..6630622 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__gc_strategy.sql @@ -0,0 +1,21 @@ +-- V6: GoldenChart 투자전략 테이블 +-- frontend StrategyPage 에서 생성한 DSL 전략을 DB에 저장 +-- DSL 구조: { id, name, description, buyCondition: LogicNode, sellCondition: LogicNode } + +CREATE TABLE IF NOT EXISTS gc_strategy ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL COMMENT 'users.id 논리 참조 (로그인 사용자)', + device_id VARCHAR(100) NULL COMMENT '비회원 기기 식별자', + name VARCHAR(200) NOT NULL COMMENT '전략 이름', + description TEXT NULL COMMENT '전략 설명', + buy_condition_json JSON NULL COMMENT '매수 조건 DSL (LogicNode 트리)', + sell_condition_json JSON NULL COMMENT '매도 조건 DSL (LogicNode 트리)', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성 여부', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_gc_strategy_user (user_id), + INDEX idx_gc_strategy_device (device_id), + INDEX idx_gc_strategy_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='GoldenChart 투자전략 DSL 저장'; diff --git a/backend/src/main/resources/db/migration/V7__gc_backtest_settings.sql b/backend/src/main/resources/db/migration/V7__gc_backtest_settings.sql new file mode 100644 index 0000000..2af686e --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__gc_backtest_settings.sql @@ -0,0 +1,48 @@ +-- 백테스팅 설정 테이블 +-- Ta4j 에서 제공하는 주요 백테스팅 옵션을 저장 +CREATE TABLE IF NOT EXISTS gc_backtest_settings ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100), + + -- ── 자본 설정 ────────────────────────────────────────────────────────── + initial_capital DECIMAL(20,2) NOT NULL DEFAULT 10000000.00, -- 초기 자본 (원) + + -- ── 비용 모델 ────────────────────────────────────────────────────────── + commission_type VARCHAR(20) NOT NULL DEFAULT 'LINEAR', -- LINEAR | ZERO + commission_rate DECIMAL(8,5) NOT NULL DEFAULT 0.00150, -- 수수료율 (0.15%) + slippage_rate DECIMAL(8,5) NOT NULL DEFAULT 0.00050, -- 슬리피지 (0.05%) + + -- ── 진입/청산 가격 ─────────────────────────────────────────────────── + entry_price_type VARCHAR(20) NOT NULL DEFAULT 'CLOSE', -- CLOSE | NEXT_OPEN + exit_price_type VARCHAR(20) NOT NULL DEFAULT 'CLOSE', -- CLOSE | NEXT_OPEN + + -- ── 포지션 방향 ────────────────────────────────────────────────────── + position_direction VARCHAR(10) NOT NULL DEFAULT 'LONG', -- LONG | SHORT | BOTH + + -- ── 거래 규모 ────────────────────────────────────────────────────────── + trade_size_type VARCHAR(20) NOT NULL DEFAULT 'CAPITAL_PCT',-- CAPITAL_PCT | FIXED_AMOUNT + trade_size_value DECIMAL(10,4) NOT NULL DEFAULT 100.0000, -- 비율(%) or 고정금액(원) + + -- ── 손절 (StopLossRule) ─────────────────────────────────────────────── + stop_loss_enabled TINYINT(1) NOT NULL DEFAULT 0, + stop_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 2.000, -- 손절 비율 (%) + + -- ── 익절 (StopGainRule) ─────────────────────────────────────────────── + take_profit_enabled TINYINT(1) NOT NULL DEFAULT 0, + take_profit_pct DECIMAL(6,3) NOT NULL DEFAULT 5.000, -- 익절 비율 (%) + + -- ── 트레일링 스탑 (TrailingStopLossRule) ───────────────────────────── + trailing_stop_enabled TINYINT(1) NOT NULL DEFAULT 0, + trailing_stop_pct DECIMAL(6,3) NOT NULL DEFAULT 2.000, -- 트레일링 스탑 비율 (%) + + -- ── 재진입 제어 ─────────────────────────────────────────────────────── + reentry_wait_bars INT NOT NULL DEFAULT 0, -- 매도 후 재진입 대기 봉 수 + max_open_trades INT NOT NULL DEFAULT 1, -- 최대 동시 보유 포지션 수 + + -- ── 기타 ────────────────────────────────────────────────────────────── + partial_exit_enabled TINYINT(1) NOT NULL DEFAULT 0, -- 분할 청산 여부 + partial_exit_pct DECIMAL(6,3) NOT NULL DEFAULT 50.000, -- 분할 청산 비율 (%) + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V8__gc_backtest_result.sql b/backend/src/main/resources/db/migration/V8__gc_backtest_result.sql new file mode 100644 index 0000000..63f4cd8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__gc_backtest_result.sql @@ -0,0 +1,33 @@ +-- 백테스팅 실행 이력 및 결과 테이블 +CREATE TABLE IF NOT EXISTS gc_backtest_result ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(100), + strategy_id BIGINT, + strategy_name VARCHAR(200), + symbol VARCHAR(50), + timeframe VARCHAR(10), + bar_count INT NOT NULL DEFAULT 0, + from_time BIGINT COMMENT '시작 봉 Unix timestamp (초)', + to_time BIGINT COMMENT '종료 봉 Unix timestamp (초)', + + -- 설정 스냅샷 + settings_json JSON, + + -- 매수/매도 시그널 배열 + signals_json JSON, + + -- Ta4j AnalysisCriterion 계산 결과 전체 + analysis_json JSON, + + -- 빠른 목록 조회용 인덱스 컬럼 + total_return DECIMAL(12,4) COMMENT '총 수익률 (소수, e.g. 0.15 = +15%)', + win_rate DECIMAL(6,4) COMMENT '승률 0~1', + total_trades INT, + max_drawdown DECIMAL(12,4) COMMENT '최대 낙폭 (음수)', + sharpe_ratio DECIMAL(10,4), + final_equity DECIMAL(20,2), + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_device_created (device_id, created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V9__gc_app_settings_bt_popup.sql b/backend/src/main/resources/db/migration/V9__gc_app_settings_bt_popup.sql new file mode 100644 index 0000000..64a5a2c --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__gc_app_settings_bt_popup.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- V9: gc_app_settings 에 백테스팅 결과 자동 팝업 옵션 추가 +-- ON(1, 기본값): 백테스팅 완료 시 결과 팝업 자동 표시 +-- OFF(0) : 팝업 자동 표시 안 함 (수동으로 결과보기 버튼 클릭 필요) +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN bt_auto_popup TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '백테스팅 완료 시 결과 팝업 자동 표시 여부 (1=ON, 0=OFF)', + ADD COLUMN bt_show_price TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '백테스팅 매수/매도 마커에 금액 표시 여부 (1=ON, 0=OFF)'; diff --git a/data_process.md b/data_process.md new file mode 100644 index 0000000..af67b2b --- /dev/null +++ b/data_process.md @@ -0,0 +1,817 @@ +# GoldenChart 데이터 처리 구조 상세 문서 + +> 최종 업데이트: 2026-05-20 +> 기준 코드 커밋: `stock_data_process.md` 명세 기반 백엔드 파이프라인 + 프론트엔드 무한스크롤 구현 완료 + +--- + +## 1. 전체 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 업비트 서버 (Upbit) │ +│ REST API: https://api.upbit.com/v1/candles/... │ +│ WebSocket: wss://api.upbit.com/websocket/v1 │ +└──────────────┬─────────────────────────┬───────────────────────────┘ + │ ① WS(trade 틱 수신) │ ② REST(과거 캔들 요청) + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Spring Boot 백엔드 (port 8080) │ +│ context-path: /api │ +│ │ +│ ┌─────────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │UpbitWebSocket │ │ BarBuilder │ │ DynamicSubscription │ │ +│ │Client │─▶│ (틱→캔들 │ │ Manager │ │ +│ │(Java-WebSocket) │ │ 조립) │ │ (STOMP 구독 감지·GC) │ │ +│ └─────────────────┘ └──────┬───────┘ └────────────┬───────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ Ta4jStorage (싱글톤) │ │ +│ │ ConcurrentHashMap>│ │ +│ │ setMaximumBarCount(300) — 슬라이딩 윈도우 │ │ +│ └───────────────────┬────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┐ │ ┌────────────────────────┐ │ +│ │ HistoricalDataService │◀─────────┘ │ TradingWebSocketBroker │ │ +│ │ (과거 캔들 중계기) │ │ (STOMP /sub/charts/...) │ │ +│ └──────────┬─────────────┘ └────────────┬───────────┘ │ +│ │ │ │ +│ ┌──────────▼─────────────┐ │ │ +│ │ CandleController │ │ │ +│ │ GET /api/candles/ │ │ │ +│ │ history │ │ │ +│ └──────────┬─────────────┘ │ │ +└─────────────┼──────────────────────────────────────┬─┘ + │ REST 응답 │ STOMP Pub + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ React 프론트엔드 (port 5173) │ +│ │ +│ ┌───────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ +│ │ useUpbitData │ │ useHistoryLoader │ │ ChartManager │ │ +│ │ (초기 300봉 REST │ │ (무한스크롤 Prepend│ │ (lightweight- │ │ +│ │ + WS 틱 조립) │ │ + 락 + 디바운스) │ │ charts 관리) │ │ +│ └───────────────────┘ └──────────────────┘ └───────────────┘ │ +│ │ +│ ※ 실시간: 프론트가 업비트 WS 직접 수신 (현재 구현) │ +│ ※ 과거 스크롤: 백엔드 /api/candles/history 우선 → 폴백: 업비트 직접 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 백엔드 컴포넌트 상세 + +### 2.1 Ta4jStorage — 인메모리 시세 저장소 + +**파일**: `backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java` +**어노테이션**: `@Service` (Spring 싱글톤) + +#### 자료구조 +```java +ConcurrentHashMap> storage +// Key 1: market (e.g. "KRW-BTC") +// Key 2: candleType (e.g. "1m", "5m", "1h", "1d") +// Value: Ta4j BarSeries +``` + +#### 슬라이딩 윈도우 제한 +```java +public static final int MAX_BAR_COUNT = 300; + +// 모든 BarSeries 생성 시 적용 +new BaseBarSeriesBuilder() + .withMaxBarCount(MAX_BAR_COUNT) // 301번째 캔들 진입 시 가장 오래된 캔들 자동 소멸 + .withNumFactory(DoubleNumFactory.getInstance()) + .build(); +``` + +#### 지원 캔들 타입 ↔ 업비트 API 경로 매핑 +| candleType | 업비트 API 경로 | +|-----------|----------------| +| `1m` | `minutes/1` | +| `3m` | `minutes/3` | +| `5m` | `minutes/5` | +| `15m` | `minutes/15` | +| `30m` | `minutes/30` | +| `1h` | `minutes/60` | +| `4h` | `minutes/240` | +| `1d` | `days` | + +#### 주요 메서드 +| 메서드 | 설명 | +|--------|------| +| `getOrCreate(market, type)` | BarSeries 반환. 없으면 신규 생성 | +| `addBar(market, type, bar)` | 완성된 Bar 추가 (replace=true: 동일 시간 교체) | +| `updateLastBar(market, type, price, vol)` | 진행 중인 Bar 종가 갱신 (틱 수신용) | +| `getBars(market, type)` | 전체 Bar 리스트 시간 오름차순 반환 | +| `getOldestTime(market, type)` | 인메모리 가장 오래된 캔들 시간 | +| `getLatestTime(market, type)` | 인메모리 가장 최신 캔들 시간 | +| `remove(market, type)` | BarSeries 삭제 (GC 시 사용) | +| `parseDuration(candleType)` | 캔들 타입 문자열 → `java.time.Duration` | +| `epochToZdt(epochSec)` | Unix 초 → `ZonedDateTime` (UTC) | +| `epochToInstant(epochSec)` | Unix 초 → `java.time.Instant` | + +> **Ta4j 0.22.7 호환 주의사항** +> - `Bar.getEndTime()` → `Instant` 반환 (ZonedDateTime 아님) +> - `barBuilder().endTime()` → `Instant` 파라미터 +> - `BaseBarSeriesBuilder.withNumFactory()` (withNumTypeOf 아님) + +--- + +### 2.2 BarBuilder — 틱→캔들 조립기 + +**파일**: `backend/src/main/java/com/goldenchart/websocket/BarBuilder.java` +**어노테이션**: `@Component` + +#### 역할 +업비트 WebSocket 으로부터 수신한 실시간 `trade` 틱 데이터를 1분봉 단위로 조립하고, 상위 타임프레임으로 집계·전파. + +#### 분봉 조립 규칙 +``` +틱 수신 + │ + ├─ 현재 진행 중인 PartialBar 와 같은 분(minute)? + │ YES → current.update(price, volume) + │ ta4jStorage.updateLastBar("1m", price, volume) + │ + └─ 분이 바뀜? + YES → commitBar(이전 PartialBar) ← Ta4jStorage 1분봉 저장 + 상위 타임프레임 전파 + PartialBar 신규 생성 + + 항상 → broker.publish(market, "1m", currentBar) ← STOMP 즉시 발행 +``` + +#### 상위 타임프레임 전파 (1분봉 확정 시) +| 상위 봉 | 집계 기준 (분 단위) | 정렬 방법 | +|---------|------------------|---------| +| 3m | 3분 배수 정렬 | `epochMin / 3 * 3` | +| 5m | 5분 배수 정렬 | `epochMin / 5 * 5` | +| 15m | 15분 배수 정렬 | `epochMin / 15 * 15` | +| 30m | 30분 배수 정렬 | `epochMin / 30 * 30` | +| 1h | 60분 배수 정렬 | `epochMin / 60 * 60` | +| 4h | 240분 배수 정렬 | `epochMin / 240 * 240` | +| 1d | 1440분 배수 정렬 | `epochMin / 1440 * 1440` | + +전파 시 동작: +- 인메모리 상위봉이 없음 → `addBar()` 로 신규 봉 열기 +- 동일 구간 → `updateLastBar()` 로 가격 갱신 +- 다음 구간 → `addBar()` 로 새 봉 추가 + +#### PartialBar 내부 상태 클래스 +```java +class PartialBar { + final ZonedDateTime barStart; // 분 시작 시간 (초/나노 제거) + final double open; + double high, low, close, volume; + + void update(double price, double vol) { + // OHLCV 실시간 갱신 + } +} +``` + +--- + +### 2.3 UpbitWebSocketClient — 백엔드 업비트 WS 클라이언트 + +**파일**: `backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java` +**어노테이션**: `@Component` +**의존성**: `org.java-websocket:Java-WebSocket:1.5.7` + +#### 생명주기 +- `@PostConstruct init()` → 애플리케이션 시작 시 자동 연결 +- `@PreDestroy destroy()` → 정상 종료 시 연결 해제 + +#### 지수 백오프 재연결 +``` +연결 실패 → 2초 후 재시도 +재시도 실패 → 4초 후 재시도 +→ 8초 → 16초 → 32초 → 60초 (상한) +연결 성공 시 딜레이 2초로 초기화 +``` + +#### 업비트 WS 구독 패킷 형식 +```json +[ + { "ticket": "UUID" }, + { "type": "trade", "codes": ["KRW-BTC", "KRW-ETH", ...], "isOnlyRealtime": true } +] +``` + +#### 수신 처리 흐름 +``` +onMessage(ByteBuffer) + → JSON 파싱 (node.path("ty") == "trade" 확인) + → cd: market 코드 + → tp: 체결가 (tradePrice) + → tv: 체결량 (tradeVolume) + → ttms: 체결 Unix 밀리초 (tradeTimeMs) + → barBuilder.onTick(market, price, volume, timeMs) +``` + +#### 구독 관리 API +| 메서드 | 설명 | +|--------|------| +| `addMarket(market)` | 종목 추가 → 구독 패킷 재전송 | +| `removeMarket(market)` | 종목 제거 → 구독 패킷 재전송 | +| `getSubscribedMarkets()` | 현재 구독 목록 반환 (불변 Set) | + +--- + +### 2.4 DynamicSubscriptionManager — 동적 구독 관리자 + +**파일**: `backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java` +**어노테이션**: `@Component` + +#### 역할 +프론트엔드가 STOMP 채널을 구독/해제하는 이벤트를 감지하여 자동으로 자원을 할당하거나 수거한다. + +#### STOMP 구독 이벤트 처리 흐름 + +**구독 시 (`SessionSubscribeEvent`)** +``` +/sub/charts/{market}/{type} 구독 감지 + │ + ├─ subscriberCount[market:type]++ + │ + ├─ Ta4jStorage 에 BarSeries 없음? + │ YES → warmUp(market, type) ← 업비트 REST 200개 캔들 로드 + │ + └─ upbitWsClient.addMarket(market) ← 업비트 WS 구독 추가 +``` + +**구독 해제 시 (`SessionUnsubscribeEvent`)** +``` +구독 해제 감지 + │ + └─ subscriberCount[market:type]-- + 구독자 수 == 0? + YES → upbitWsClient.removeMarket(market) ← 업비트 WS 구독 제거 + ta4jStorage.remove(market, type) ← 메모리 GC +``` + +#### Warm-up 로직 +``` +GET https://api.upbit.com/v1/candles/{type}?market={market}&count=200 + → 업비트 응답(최신→과거 순) + → 시간 오름차순 정렬 + → Ta4j Bar 생성 (Instant endTime 사용) + → ta4jStorage.addBar() × 200회 +``` + +**요청 지연**: `upbit.api.request-delay-ms=110ms` (업비트 API Rate Limit 준수) + +--- + +### 2.5 HistoricalDataService — 과거 데이터 페이징 중계기 + +**파일**: `backend/src/main/java/com/goldenchart/service/HistoricalDataService.java` +**어노테이션**: `@Service` + +#### 핵심 로직 흐름 +``` +getHistory(market, candleType, to, count) + │ + ├─ 1단계: to 시간 파싱 (ISO-8601, Z 유무 모두 허용) + │ + ├─ 2단계: 인메모리 범위 판단 + │ ta4jStorage.getOldestTime(market, type) + │ to >= oldestMemoryTime? → 인메모리 슬라이싱 + │ to < oldestMemoryTime? → 업비트 REST 프록시 + │ + └─ 3단계: RSI 계산 및 첨부 + 워밍업 캔들(최대 50개) + 대상 캔들 → 임시 BarSeries 구성 + RSIIndicator(period=14) 계산 + 워밍업 구간 제외, 대상 구간만 반환 +``` + +#### 인메모리 슬라이싱 +```java +ta4jStorage.getBars(market, candleType) + .stream() + .filter(b -> !b.getEndTime().isAfter(toInstant)) // to 이전 Bar만 + .sorted(Comparator.comparing(Bar::getEndTime).reversed()) // 최신순 + .limit(count) + .sorted(...) // 다시 시간 오름차순 +``` + +#### 업비트 REST 프록시 +``` +GET https://api.upbit.com/v1/candles/{path} + ?market={market}&count={count}&to={to}Z + +업비트 응답(최신→과거 순) → 시간 오름차순 변환 → UpbitCandleRaw 리스트 +``` + +#### RSI 계산 방식 (Ta4j 임시 BarSeries) +``` +워밍업 캔들 (인메모리에서 최대 50개, 반환 제외) + + +대상 캔들 200개 + = +임시 BarSeries (250개) + +RSIIndicator(ClosePriceIndicator(tmpSeries), 14) + → seriesIdx >= 14 이상인 구간만 유효값 추출 + → NaN/Infinity 는 null 처리 +``` + +#### 응답 포맷 (CandleBarDto) +```json +{ + "time": 1779229200, + "open": 92100000.0, + "high": 92150000.0, + "low": 92050000.0, + "close": 92080000.0, + "volume": 4.215, + "rsi": 42.15 +} +``` + +--- + +### 2.6 TradingWebSocketBroker — STOMP 실시간 분배기 + +**파일**: `backend/src/main/java/com/goldenchart/websocket/TradingWebSocketBroker.java` +**어노테이션**: `@Service` + +#### STOMP 토픽 구조 +``` +/sub/charts/{market}/{candleType} + 예: /sub/charts/KRW-BTC/1m +``` + +#### 발행 페이로드 (CandleBarDto) +```json +{ + "time": 1779229260, + "open": 92080000.0, + "high": 92120000.0, + "low": 92010000.0, + "close": 92050000.0, + "volume": 1.845, + "rsi": null +} +``` +> BarBuilder 에서 발행할 때는 `rsi=null` (실시간 틱은 RSI 계산 생략) + +--- + +### 2.7 WebSocketConfig — STOMP 브로커 설정 + +**파일**: `backend/src/main/java/com/goldenchart/config/WebSocketConfig.java` + +``` +연결 엔드포인트: ws(s)://host/api/ws/trading (SockJS 지원) +구독 접두사: /sub (인메모리 심플 브로커) +발행 접두사: /pub (서버 @MessageMapping 핸들러) +``` + +--- + +### 2.8 CandleController — REST API 엔드포인트 + +**파일**: `backend/src/main/java/com/goldenchart/controller/CandleController.java` + +#### 엔드포인트 +``` +GET /api/candles/history + +Query Parameters: + market (필수) - 업비트 마켓 코드 (e.g. "KRW-BTC") + type (선택) - 캔들 타입, 기본값: "1d" + to (선택) - 기준 시간 이전 ISO-8601 (e.g. "2026-05-20T10:00:00") + count (선택) - 요청 수, 기본값: 200, 최대: 200 + +Response: CandleBarDto[] (시간 오름차순) +``` + +--- + +## 3. 프론트엔드 컴포넌트 상세 + +### 3.1 실시간 데이터 파이프라인 (현재 구현) + +``` +업비트 WebSocket (wss://api.upbit.com/websocket/v1) + │ (Vite 개발: /upbit-ws 프록시 → 업비트 WS) + │ (Nginx 운영: /upbit-ws → upstream 업비트 WS) + │ + ▼ +UpbitWebSocketClient (frontend/src/utils/upbitApi.ts) + - trade 구독 + - PING/PONG 자동 관리 + - 지수 백오프 재연결 + │ + ▼ +useUpbitData (frontend/src/hooks/useUpbitData.ts) + - 초기화: fetchUpbitCandlesCached() → 최근 300개 REST 로드 + - 틱 수신: getCandleStart() 로 캔들 시작시간 계산 + ├─ 동일 캔들 내 → onTickUpdate(bar) → ChartManager.updateBar() + └─ 새 캔들 시작 → onNewCandle(bar) → ChartManager.appendBar() +``` + +> **참고**: 실시간 데이터는 현재 프론트가 업비트 WS에 직접 연결한다. +> 향후 백엔드 STOMP (`/sub/charts/{market}/{type}`)로 전환 예정이며, 백엔드 인프라는 이미 구현 완료. + +--- + +### 3.2 과거 데이터 무한스크롤 (useHistoryLoader) + +**파일**: `frontend/src/hooks/useHistoryLoader.ts` + +#### 트리거 조건 +``` +subscribeVisibleLogicalRange 콜백 + → logicalRange.from < LOAD_MORE_TRIGGER (30) + → loadMoreRef.current() 호출 +``` + +#### 중복 방지 락(Lock) 메커니즘 +```typescript +const loadingRef = useRef(false); // ★ 핵심: re-render 없이 동기 체크 + +async function doLoadMore() { + if (loadingRef.current) return; // ← 이미 로딩 중: 즉시 리턴 + loadingRef.current = true; // ← 락 설정 + try { + // ... API 호출 및 데이터 병합 + } finally { + loadingRef.current = false; // ← 락 해제 (항상 실행) + } +} +``` + +#### 데이터 소스 우선순위 +``` +1순위: GET /api/candles/history (백엔드) + → Ta4j RSI가 포함된 응답 + → 인메모리(최근 300개) 또는 업비트 REST 프록시 + +실패 시 2순위: fetchUpbitCandlesBeforeCached() (업비트 직접) +``` + +#### 디바운스 래퍼 +```typescript +const DEBOUNCE_MS = 300; + +// subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출 +// → 300ms 동안 추가 호출 없으면 실제 API 요청 +loadMoreRef.current = () => { + clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => doLoadMore(), DEBOUNCE_MS); +}; +``` + +#### 완전 종료 조건 +```typescript +const hasMoreRef = useRef(true); + +if (olderBars.length === 0) { + hasMoreRef.current = false; // 이후 스크롤에서 API 호출 중단 +} +``` + +#### 데이터 병합 (Prepend) +```typescript +await mgr.prependBars(olderBars); +// ChartManager.prependBars(): +// 1. 기존 rawBars 앞에 신규 bars 병합 (중복 타임스탬프 필터링) +// 2. setData() 로 메인/볼륨 시리즈 갱신 +// 3. logicalRange.from += newBars.length (스크롤 위치 보정) +// 4. _refreshAllIndicatorsData() 로 전체 지표 재계산 +``` + +#### 심볼/타임프레임 변경 시 상태 초기화 +```typescript +useEffect(() => { + hasMoreRef.current = true; + loadingRef.current = false; + clearTimeout(debounceTimer.current); +}, [symbol, timeframe]); +``` + +--- + +### 3.3 ChartManager — 차트 데이터 관리 + +**파일**: `frontend/src/utils/ChartManager.ts` + +#### 주요 메서드 +| 메서드 | 설명 | +|--------|------| +| `updateBar(bar)` | 현재 진행 중인 캔들 실시간 갱신 | +| `appendBar(bar)` | 새 캔들 추가 (오른쪽 확장) | +| `prependBars(bars)` | 과거 캔들 앞에 병합 (왼쪽 확장) | +| `getOldestBarTime()` | 가장 오래된 캔들 Unix 초 반환 | +| `getRawBarsLength()` | 현재 보유 캔들 수 | +| `getVisibleLogicalRange()` | 현재 화면 논리 범위 | +| `applyVisibleLogicalRange(from, to)` | 논리 범위 적용 (스크롤 위치 복원) | +| `setCrosshairMagnet(enabled)` | 크로스헤어 자석모드 설정 | +| `reloadIndicatorsOnly(indicators)` | 메인 캔들 유지하며 지표만 재계산 | + +--- + +## 4. 데이터 인터페이스 포맷 + +### 4.1 REST API — 과거 캔들 조회 + +**Request** +``` +GET /api/candles/history?market=KRW-BTC&type=1m&to=2026-05-20T10:17:00&count=200 +``` + +**Response** (JSON 배열, 시간 오름차순) +```json +[ + { + "time": 1779229200, + "open": 92100000.0, + "high": 92150000.0, + "low": 92050000.0, + "close": 92080000.0, + "volume": 4.215, + "rsi": 42.15 + }, + ... +] +``` + +### 4.2 STOMP WebSocket — 실시간 캔들 분배 + +**연결** +``` +ws://host/api/ws/trading (또는 SockJS 폴백) +``` + +**구독 토픽** +``` +/sub/charts/KRW-BTC/1m +``` + +**수신 페이로드** +```json +{ + "time": 1779229260, + "open": 92080000.0, + "high": 92120000.0, + "low": 92010000.0, + "close": 92050000.0, + "volume": 1.845, + "rsi": null +} +``` + +--- + +## 5. 설정 파일 주요 값 + +### application.yml +```yaml +server: + port: 8080 + servlet: + context-path: /api # 모든 REST 엔드포인트 앞에 /api 붙음 + +upbit: + api: + base-url: https://api.upbit.com + candle-limit: 200 # 1회 요청 최대 캔들 수 + request-delay-ms: 110 # 업비트 Rate Limit 준수 딜레이 (ms) + websocket: + url: wss://api.upbit.com/websocket/v1 + +goldenchart: + indicator: + warmup-bars: 200 # EMA/RMA 수렴을 위한 워밍업 기간 + max-bars: 2000 # 최대 로드 캔들 수 +``` + +### pom.xml 핵심 의존성 +```xml + + + org.ta4j + ta4j-core + 0.22.7-SNAPSHOT + + + + + org.java-websocket + Java-WebSocket + 1.5.7 + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-starter-websocket + +``` + +--- + +## 6. 전체 데이터 흐름 시나리오 + +### 시나리오 A: 초기 차트 로드 + +``` +1. 프론트엔드 차트 마운트 +2. useUpbitData → fetchUpbitCandlesCached(market, timeframe, 300) + → 업비트 REST: GET /v1/candles/{type}?market=...&count=300 + → 응답(최신→과거) → 시간 오름차순 변환 → ChartManager.setData() +3. UpbitWebSocketClient(프론트) 연결 → trade 구독 +4. 실시간 틱 수신 → ChartManager.updateBar() / appendBar() +``` + +### 시나리오 B: 차트 왼쪽 스크롤 (무한스크롤) + +``` +1. subscribeVisibleLogicalRange → logicalRange.from < 30 감지 +2. loadMoreRef.current() → debounce(300ms) +3. doLoadMore() 실행 + ├─ loadingRef.current = true (락 설정) + ├─ oldestTime = mgr.getOldestBarTime() → beforeIso 계산 + │ + ├─ [우선] GET /api/candles/history?market=...&type=...&to=beforeIso&count=200 + │ └─ 백엔드 HistoricalDataService + │ ├─ to >= Ta4jStorage 인메모리 최오래된 시간? + │ │ YES → Ta4jStorage 슬라이싱 + │ │ NO → 업비트 REST 프록시 (GET /v1/candles/...) + │ └─ RSI 계산(Ta4j 임시 BarSeries) → CandleBarDto[] 반환 + │ + ├─ [폴백] 백엔드 실패 시 → fetchUpbitCandlesBeforeCached() 직접 호출 + │ + └─ mgr.prependBars(olderBars) (차트 왼쪽 확장) + └─ loadingRef.current = false (락 해제) +``` + +### 시나리오 C: 새 종목 STOMP 구독 (향후 전환 시) + +``` +1. 프론트엔드 → STOMP 구독: /sub/charts/KRW-SAND/1m +2. DynamicSubscriptionManager.handleSubscribe() + ├─ subscriberCount[KRW-SAND:1m]++ + ├─ Ta4jStorage에 KRW-SAND:1m 없음? + │ YES → warmUp(KRW-SAND, 1m) + │ GET /v1/candles/minutes/1?market=KRW-SAND&count=200 + │ → Ta4jStorage 초기화 (200개) + └─ upbitWsClient.addMarket(KRW-SAND) + → 업비트 WS 재구독 패킷 전송 + +3. 실시간 틱 수신 → BarBuilder → Ta4jStorage 갱신 + → TradingWebSocketBroker.publish(KRW-SAND, 1m, dto) + → 프론트엔드 구독자에게 STOMP 메시지 전달 +``` + +### 시나리오 D: 종목 조회 중단 (GC) + +``` +1. 프론트엔드 → STOMP 구독 해제: /sub/charts/KRW-SAND/1m +2. DynamicSubscriptionManager.handleUnsubscribe() + ├─ subscriberCount[KRW-SAND:1m]-- + └─ 구독자 수 == 0? + YES → upbitWsClient.removeMarket(KRW-SAND) + ta4jStorage.remove(KRW-SAND, 1m) + (메모리 방어: BarSeries 삭제) +``` + +--- + +## 7. 패키지 구조 전체 + +``` +backend/src/main/java/com/goldenchart/ +├── GoldenChartApplication.java # @EnableAsync, @EnableScheduling +│ +├── config/ +│ ├── CorsConfig.java # CORS 설정 +│ ├── SecurityConfig.java # Security (개발용 전체 허용) +│ └── WebSocketConfig.java # ★ STOMP 브로커 설정 +│ +├── controller/ +│ ├── CandleController.java # ★ GET /candles/history +│ ├── AppSettingsController.java +│ ├── BacktestResultController.java +│ ├── BacktestSettingsController.java +│ ├── BacktestingController.java # POST /backtesting/run +│ ├── ChartSettingsController.java +│ ├── IndicatorController.java # POST /indicators/calculate +│ ├── IndicatorSettingsController.java +│ ├── StrategyController.java +│ └── WatchlistController.java +│ +├── dto/ +│ ├── CandleBarDto.java # ★ REST/STOMP 공용 캔들 DTO +│ ├── BacktestAnalysisDto.java +│ ├── BacktestRequest.java / BacktestResponse.java +│ ├── BacktestSettingsDto.java +│ ├── IndicatorRequest.java / IndicatorResponse.java +│ ├── OhlcvBar.java +│ └── StrategyDto.java +│ +├── entity/ # JPA 엔티티 × 8 +│ ├── GcAppSettings.java +│ ├── GcBacktestResult.java / GcBacktestSettings.java +│ ├── GcChartSlot.java / GcChartWorkspace.java +│ ├── GcHoldings.java +│ ├── GcIndicatorSettings.java +│ ├── GcStrategy.java +│ └── GcWatchlist.java +│ +├── repository/ # Spring Data JPA × 8 +│ +├── service/ +│ ├── HistoricalDataService.java # ★ 과거 데이터 페이징 중계기 +│ ├── AppSettingsService.java +│ ├── BacktestSettingsService.java +│ ├── BacktestingService.java # Ta4j 백테스트 +│ ├── ChartSettingsService.java +│ ├── IndicatorService.java # Ta4j 지표 계산 (80+ 지표) +│ ├── IndicatorSettingsService.java +│ ├── StrategyDslToTa4jAdapter.java # JSON DSL → Ta4j Rule +│ └── StrategyService.java +│ +├── storage/ +│ └── Ta4jStorage.java # ★ 인메모리 싱글톤 시세 저장소 +│ +└── websocket/ + ├── BarBuilder.java # ★ 틱→캔들 조립기 + ├── DynamicSubscriptionManager.java # ★ 동적 구독 관리자 + ├── TradingWebSocketBroker.java # ★ STOMP 실시간 분배기 + └── UpbitWebSocketClient.java # ★ 업비트 WS 클라이언트 +``` + +``` +frontend/src/ +├── hooks/ +│ ├── useHistoryLoader.ts # ★ 무한스크롤 (백엔드 API 우선) +│ ├── useUpbitData.ts # 실시간 데이터 (업비트 WS 직접) +│ ├── useUpbitOrderbook.ts # 호가 +│ ├── useMarketTicker.ts # 마켓 목록 + 티커 +│ ├── useAppSettings.ts +│ ├── useBacktest.ts +│ ├── useIndicatorSettings.ts +│ └── useWorkspacePersist.ts +│ +└── utils/ + ├── ChartManager.ts # ★ lightweight-charts 관리 (prependBars 등) + ├── upbitApi.ts # UpbitWebSocketClient + REST + ├── requestCache.ts # 캔들 중복 요청 캐시/디바운스 + ├── backendApi.ts # Spring Boot REST 클라이언트 + ├── indicatorRegistry.ts # 프론트 로컬 지표 계산 + └── ... +``` + +--- + +## 8. 현재 구현 상태 vs 향후 계획 + +| 항목 | 현재 상태 | 비고 | +|------|-----------|------| +| 실시간 캔들 수신 | ✅ **설정 선택** (`BACKEND_STOMP` 기본 / `UPBIT_DIRECT`) | `useChartRealtimeData`, `gc_app_settings.chart_realtime_source` | +| 과거 스크롤 | ✅ 백엔드 `/api/candles/history` 우선 + 업비트 폴백 | — | +| Ta4jStorage | ✅ 파이프라인 ON 시 max 500봉 (기본 1800) | — | +| BarBuilder | ✅ 1m + 상위 TF + 종목별 `candle_type` | — | +| LiveStrategyScheduler | ✅ 3초 주기 + REALTIME_TICK | — | +| FCM 푸시 | ✅ `FcmPushService` + `/api/fcm/*` (Firebase JSON 필요) | `firebase.enabled=true` | +| 프론트 STOMP | ✅ 차트·시그널·마커 | SockJS + stompjs | +| 멀티스레드 파이프라인 | ✅ `trading.pipeline.*` | — | +| 대시보드 | ✅ `/api/dashboard/summary` + UI | — | +| 실거래 수동 주문 | ✅ `/api/live/orders` + TradeAlertModal | — | + +--- + +## 9. 주요 설계 결정 및 트레이드오프 + +### 9.1 Ta4j 0.22.7-SNAPSHOT API 호환 +- `Bar.getEndTime()` 반환 타입이 `ZonedDateTime` → `Instant` 로 변경됨 +- `BaseBarSeriesBuilder.withNumTypeOf()` → `withNumFactory()` 로 변경됨 +- `barBuilder().endTime()` 파라미터가 `ZonedDateTime` → `Instant` 로 변경됨 +- 내부적으로 UTC 기준 `Instant` 를 사용하고 필요 시 `.atZone(ZoneOffset.UTC)` 변환 + +### 9.2 인메모리 300개 슬라이딩 윈도우 +- RAM 사용량 방어: 종목 수 × 타임프레임 수 × 300봉 × Bar 크기(~200B) = 관리 가능한 수준 +- 전략 감시(LiveStrategyScheduler)에 필요한 최신 데이터 충분 확보 +- 300봉 외 과거 데이터는 온디맨드 업비트 REST 프록시로 처리 + +### 9.3 무한스크롤 락(Lock) 설계 +- `useRef(false)` 사용: React state(re-render) 없이 동기적 체크 가능 +- `loadingRef.current = true` 설정 직후 이후 모든 스크롤 이벤트 무시 +- `finally` 블록에서 반드시 해제 → 예외 상황에도 락이 풀림 + +### 9.4 백엔드 API 우선, 업비트 직접 폴백 +- 백엔드가 RSI 계산된 데이터를 제공하므로 프론트 계산 부하 감소 +- 백엔드 장애 시에도 서비스 중단 없이 업비트 직접 호출로 폴백 +- 개발/테스트 환경에서 백엔드 없이도 프론트 단독 동작 가능 diff --git a/db_struct.md b/db_struct.md new file mode 100644 index 0000000..a5fa501 --- /dev/null +++ b/db_struct.md @@ -0,0 +1,878 @@ +# GoldenAnalysis - MySQL DB 구조 문서 + +## 기본 연결 정보 + +| 항목 | 값 | +|------|-----| +| DB명 | `stockAnalyzer` | +| 사용자 | `stock` / 비밀번호 `analyzer` | +| Root 비밀번호 | `rootpassword` | +| 포트 | `3306` (호스트 ↔ 컨테이너) | +| 문자셋 | `utf8mb4 / utf8mb4_unicode_ci` | +| 엔진 | MySQL 8.0 | +| JDBC URL (백엔드) | `jdbc:mysql://mysql:3306/stockAnalyzer` | + +--- + +## 전체 테이블 목록 (44개) + +> **v2 신규 (GoldenChart 전용)**: `gc_chart_workspace`, `gc_chart_slot`, `gc_watchlist`, `gc_holdings`, `gc_indicator_cache` — 5개 추가 + +| # | 테이블명 | 분류 | 주요 용도 | +|---|----------|------|-----------| +| 1 | `users` | 인증 | 사용자 계정 | +| 2 | `upbit_candles` | 시세 | 업비트 캔들 마스터 | +| 3 | `upbit_trades` | 시세 | 업비트 체결 아카이브 | +| 4 | `upbit_ingestion_state` | 시세 | 아카이브 인제스트 상태 | +| 5 | `upbit_ws_subscription` | 시세 | WebSocket 수집 대상 | +| 6 | `analysis_candle_data` | 분석 | 분석 파이프라인용 캔들 | +| 7 | `data_update_history` | 분석 | 데이터 수집 이력 | +| 8 | `analysis_settings` | 설정 | 분석 전체 설정 | +| 9 | `indicator_metadata` | 설정 | 지표 메타 (표시명·범위 등) | +| 10 | `indicator_period_setting` | 설정 | 지표별 사용 가능 기간 | +| 11 | `indicator_calculation_setting` | 설정 | 지표 계산 시 가격 타입 | +| 12 | `user_indicator_settings` | 설정 | 사용자별 지표·색상 설정 | +| 13 | `guest_indicator_settings` | 설정 | 비회원 기기별 지표 설정 | +| 14 | `user_ui_theme_settings` | 설정 | 사용자 UI 테마 (PC/탭/모바일) | +| 15 | `system_settings` | 설정 | 시스템 전역 키-값 설정 | +| 16 | `trading_strategy` | 전략 | 매매 전략 파라미터 | +| 17 | `strategy_rule` | 전략 | DSL 기반 전략 규칙 | +| 18 | `condition_group` | 전략 | 전략 조건 그룹 (중첩 AND/OR) | +| 19 | `strategy_condition` | 전략 | 개별 지표 조건 | +| 20 | `ma_condition` | 전략 | MA 크로스 조건 | +| 21 | `strategy_performance` | 전략 | 백테스트 성과 기록 | +| 22 | `crypto_alert` | 알림 | 종목별 알림 설정 | +| 23 | `alert_condition` | 알림 | 알림 발동 조건 | +| 24 | `alert_notification` | 알림 | 알림 발송 이력 | +| 25 | `alert_strategy` | 알림 | 알림에 연결된 전략 규칙 | +| 26 | `alert_schedule_config` | 알림 | 알림 실행 스케줄 설정 | +| 27 | `fcm_tokens` | 알림 | FCM 푸시 토큰 | +| 28 | `crypto_favorites` | 종목 | 즐겨찾기 종목 | +| 29 | `crypto_holdings` | 종목 | 보유/관리 종목 목록 | +| 30 | `dashboard_snapshot` | 대시보드 | 대시보드 캐시 스냅샷 | +| 31 | `stock_price` | 주식 | 주식 OHLCV | +| 32 | `stock_indicator` | 주식 | 주식 기술 지표 | +| 33 | `stock_flow` | 주식 | 외인/기관 수급 | +| 34 | `stock_trend_score` | 주식 | 상승 추세 점수·랭킹 | +| 35 | `market_status` | 주식 | 시장 전체 상태 | +| 36 | `rising_stock_schedule_config` | 주식 | 상승종목 스캔 스케줄 | +| 37 | `chatbot_config` | 챗봇 | 챗봇 설정 | +| 38 | `chatbot_message` | 챗봇 | 챗봇 메시지 이력 | +| 39 | `chatbot_schedule_log` | 챗봇 | 챗봇 실행 로그 | +| 40 | `gc_chart_workspace` | **GoldenChart** | 멀티차트 워크스페이스 (레이아웃·동기화 옵션) | +| 41 | `gc_chart_slot` | **GoldenChart** | 슬롯별 차트 설정 (종목·지표·드로잉·스타일 등) | +| 42 | `gc_watchlist` | **GoldenChart** | 관심종목 목록 | +| 43 | `gc_holdings` | **GoldenChart** | 보유종목 목록 (매입가·수량 포함) | +| 44 | `gc_indicator_cache` | **GoldenChart** | Ta4j 지표 계산 결과 캐시 | + +--- + +## 테이블 상세 + +### 1. `users` — 사용자 계정 + +**용도**: 로그인·인증·역할 관리 + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| `id` | BIGINT | PK, AUTO | 사용자 ID | +| `username` | VARCHAR(50) | NOT NULL, UNIQUE | 로그인 ID | +| `password` | VARCHAR(255) | NOT NULL | BCrypt 해시 | +| `email` | VARCHAR(100) | NOT NULL, UNIQUE | 이메일 | +| `name` | VARCHAR(50) | | 표시 이름 | +| `enabled` | BOOLEAN | NOT NULL | 계정 활성 여부 | +| `role` | VARCHAR(20) | NOT NULL | ROLE_USER / ROLE_ADMIN | +| `created_at` | DATETIME | | 생성일 | +| `updated_at` | DATETIME | | 수정일 | +| `last_login_at` | DATETIME | | 최근 로그인 | + +**인덱스**: `idx_user_username`(unique), `idx_user_email`(unique) + +--- + +### 2. `upbit_candles` — 업비트 캔들 데이터 + +**용도**: 업비트 시세 마스터. REST API / WebSocket / Python 서비스가 UPSERT. 지표 계산·차트·알림 모두 이 테이블을 기반으로 조회. + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| `id` | BIGINT | PK, AUTO | | +| `market` | VARCHAR(50) | NOT NULL | KRW-BTC 형식 | +| `candle_type` | VARCHAR(20) | NOT NULL | minutes / days / weeks / months | +| `unit` | INT | | 분봉 단위 (1,3,5,10,15,30,60,240) | +| `candle_date_time_utc` | DATETIME | NOT NULL | UTC 기준 봉 시각 | +| `candle_date_time_kst` | DATETIME | NOT NULL | KST 기준 봉 시각 | +| `opening_price` | DECIMAL(30,8) | NOT NULL | 시가 | +| `high_price` | DECIMAL(30,8) | NOT NULL | 고가 | +| `low_price` | DECIMAL(30,8) | NOT NULL | 저가 | +| `trade_price` | DECIMAL(30,8) | NOT NULL | 종가 | +| `timestamp` | BIGINT | NOT NULL | 밀리초 타임스탬프 | +| `candle_acc_trade_price` | DECIMAL(30,8) | | 봉 누적 거래대금 | +| `candle_acc_trade_volume` | DECIMAL(30,8) | | 봉 누적 거래량 | +| `prev_closing_price` | DECIMAL(30,8) | | 전일 종가 | +| `change_price` | DECIMAL(30,8) | | 변동 금액 | +| `change_rate` | DECIMAL(20,8) | | 변동률 | +| `created_at` | DATETIME | | | +| `updated_at` | DATETIME | | | + +**유니크**: `uk_market_candle` `(market, candle_type, unit, candle_date_time_utc)` +**인덱스**: `idx_upbit_candles_lookup_kst` `(market, candle_type, unit, candle_date_time_kst)` + +> **데이터 보존 정책**: 최근 3일만 유지. 매일 03:00~03:30 자동 삭제 (Java 스케줄러 + Python cleanup 서비스). + +--- + +### 3. `upbit_trades` — 업비트 체결 아카이브 + +**용도**: 대용량 체결 내역 적재 (Python `upbit_data_service`) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| `id` | BIGINT | PK, AUTO | | +| `market` | VARCHAR(20) | NOT NULL | | +| `sequential_id` | BIGINT | NOT NULL | 업비트 순번 | +| `trade_timestamp_ms` | BIGINT | | 체결 시각(ms) | +| `trade_price` | DECIMAL(30,8) | | 체결가 | +| `trade_volume` | DECIMAL(30,8) | | 체결량 | +| `ask_bid` | VARCHAR(4) | | ASK / BID | +| `trade_date_utc` | VARCHAR(20) | | YYYY-MM-DD | +| `source` | VARCHAR(30) | 기본 'archive' | 수집 출처 | +| `created_at` | DATETIME | | | + +**유니크**: `uk_trade` `(market, sequential_id)` + +--- + +### 4. `upbit_ingestion_state` — 아카이브 인제스트 상태 + +**용도**: ZIP/날짜별 아카이브 적재 진행 상태 추적 (중복 스킵) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `source_key` | VARCHAR | 파일/배치 키 | +| `market` | VARCHAR | | +| `unit_path` | VARCHAR | 시간봉 경로 | +| `target_date` | DATE | | +| `file_hash` | VARCHAR | MD5 등 | +| `status` | VARCHAR | PENDING/DONE/ERROR | +| `last_error` | LONGTEXT | 오류 메시지 | +| `created_at` / `updated_at` | DATETIME | | + +**유니크**: `uk_ingest` `(source_key, market, unit_path, target_date)` + +--- + +### 5. `upbit_ws_subscription` — WebSocket 수집 대상 + +**용도**: Python WebSocket 서비스가 `is_active=1` 행을 읽어 수집할 종목·시간봉을 결정. 백엔드 API로 `acquire/release` 관리. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `market` | VARCHAR | KRW-BTC 등 | +| `interval_code` | VARCHAR | 1m / 5m / 1h 등 | +| `is_active` | BOOLEAN | 수집 활성 여부 | +| `ref_count` | INT | 참조 카운트 | +| `created_at` / `updated_at` | DATETIME | | + +**유니크**: `uk_ws` `(market, interval_code)` + +--- + +### 6. `analysis_candle_data` — 분석용 캔들 + +**용도**: 분석 파이프라인·대시보드용 캔들 저장소 (백엔드 native upsert 지원) + +`upbit_candles`와 구조 유사하나 `time_unit`(1m/5m/1d 등 문자열) 컬럼 사용. + +**유니크**: `uk_market_time_unit_date` `(market, time_unit, candle_date_time_kst)` +**인덱스**: `idx_market`, `idx_market_time_unit_date` + +--- + +### 7. `data_update_history` — 데이터 수집 이력 + +**용도**: 파일별 수집 진행 상태 이력 + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `file_path` | VARCHAR | UNIQUE | +| `file_name` | VARCHAR | | +| `market` | VARCHAR | | +| `time_unit` | VARCHAR | | +| `data_date` | DATE | | +| `year` | INT | | +| `record_count` | INT | | +| `file_size` | BIGINT | | +| `status` | ENUM | PENDING/PROCESSING/DONE/ERROR | +| `error_message` | TEXT | | +| `processed_at` | DATETIME | | +| `processing_duration_ms` | BIGINT | | +| `created_at` | DATETIME | | + +--- + +### 8. `analysis_settings` — 분석 설정 + +**용도**: 분석 관련 전체 설정을 key-value로 저장 + +| 컬럼 | 설명 | +|------|------| +| `setting_key` | UNIQUE, 설정 키 | +| `setting_value` | TEXT, 설정 값 | +| `setting_type` | ENUM (STRING/NUMBER/BOOLEAN/JSON) | +| `category` | ENUM (분류) | +| `is_active` | 활성 여부 | + +--- + +### 9. `indicator_metadata` — 지표 메타정보 + +**용도**: 전략 조건 UI에서 지표 표시명·허용 범위·허용 조건타입 정의 + +| 컬럼 | 설명 | +|------|------| +| `indicator_type` | UNIQUE, 지표 코드 (RSI/MACD/CCI…) | +| `display_name` | 화면 표시명 | +| `category` | 분류 | +| `description` | 설명 | +| `min_value` / `max_value` / `default_value` / `step` | 범위 | +| `target_value_required` | 목표값 필요 여부 | +| `helper_text` | 입력 도움말 | +| `allowed_conditions` | 사용 가능한 조건 타입 목록 | +| `display_order` | 정렬 순서 | +| `enabled` | 활성 여부 | + +--- + +### 10. `indicator_period_setting` — 지표 기간 설정 + +**용도**: 각 지표별 선택 가능한 기간 목록 (기본값 포함) + +**유니크**: `(indicator_type, period)` + +--- + +### 11. `indicator_calculation_setting` — 지표 계산 가격 타입 + +**용도**: MACD·RSI·볼린저 등의 계산 시 종가/시가/고가/중간값 중 어느 가격을 쓸지 설정 + +**유니크**: `indicator_type` +주요 컬럼: `price_type`(enum `MAPriceType`), `enabled` + +--- + +### 12. `user_indicator_settings` — 사용자 지표 설정 + +**용도**: 로그인 사용자의 지표 표시 여부·기간·차트 색상 설정 저장 + +**유니크**: `user_id` + +| 컬럼 | 설명 | +|------|------| +| `user_id` | UNIQUE | +| `settings` | JSON 문자열 (지표 on/off) | +| `indicator_periods` | JSON 문자열 (기간 설정) | +| `chart_color_settings` | JSON 문자열 (색상 설정) | +| `created_at` / `updated_at` | | + +--- + +### 13. `guest_indicator_settings` — 비회원 지표 설정 + +**용도**: 비로그인 사용자를 기기ID로 식별하여 설정 저장 + +**유니크**: `device_id` +`user_indicator_settings`와 동일 구조 (user_id 대신 device_id 사용) + +--- + +### 14. `user_ui_theme_settings` — UI 테마 설정 + +**용도**: 사용자별 PC/태블릿/모바일 테마 ID 저장 + +**유니크**: `user_id` + +| 컬럼 | 설명 | +|------|------| +| `user_id` | UNIQUE | +| `pc_theme` | PC 테마 ID | +| `tablet_theme` | 태블릿 테마 ID | +| `mobile_theme` | 모바일 테마 ID | + +--- + +### 15. `system_settings` — 시스템 설정 + +**용도**: 시스템 전역 키-값 설정 + +**유니크**: `setting_key` +주요 컬럼: `setting_key`, `setting_value`(500), `description` + +--- + +### 16. `trading_strategy` — 매매 전략 파라미터 + +**용도**: 백테스트·신호 분석에 사용하는 지표 임계값·가중치 등 파라미터 집합. 전략 유형(`strategy_type`)에 따라 컬럼 활용 방식이 달라짐. + +**주요 컬럼** + +| 컬럼 그룹 | 설명 | +|-----------|------| +| `name`, `strategy_type`, `description`, `enabled` | 기본 정보 | +| `stochastic_*`, `cci_*`, `macd_*` | 지표별 임계값·가중치 | +| `grenvil_*`, `obv_*`, `volume_multiplier` | 신호 필터 | +| `int_buy_condition`, `int_sell_condition` | `INTEGRATED` 타입 조건 문자열 | +| `min_signal_score`, `composite_*` | `COMPOSITE` 타입 가중치 | +| `int_ma_*`, `int_macd_*`, `int_stoch_*` 등 | 통합 지표 on/off·기간 | + +--- + +### 17. `strategy_rule` — DSL 전략 규칙 + +**용도**: 조건 그룹 트리 + DSL JSON으로 매수/매도 규칙을 정의 + +**관계**: `@OneToMany` → `condition_group`, `ma_condition` (cascade ALL, orphanRemoval) + +| 컬럼 그룹 | 설명 | +|-----------|------| +| `name`, `signal_type`(BUY/SELL), `trend_type`, `enabled` | 기본 정보 | +| `dsl_json` | JSON 타입 — UI에서 직접 편집된 DSL | +| `strategy_type`, `created_by_ui`, `use_fsm` | DSL 타입 메타 | +| `inter_group_operator_buy/sell` | 그룹 간 AND/OR | +| MA·일목·거래량 필터 다수 | 추가 필터 조건 | + +--- + +### 18. `condition_group` — 전략 조건 그룹 + +**용도**: 중첩 AND/OR 트리 구조로 전략 조건을 그룹핑 + +**관계**: `@ManyToOne` → `strategy_rule`(NOT NULL), self-join `parent_group_id`(nullable) +`@OneToMany` → `strategy_condition` + +| 컬럼 | 설명 | +|------|------| +| `strategy_rule_id` | FK → strategy_rule | +| `parent_group_id` | 자기참조 (nullable) | +| `logical_operator` | AND / OR | +| `group_type` | BUY / SELL | +| `order_index` | 정렬 | + +--- + +### 19. `strategy_condition` — 개별 지표 조건 + +**용도**: 각 그룹 내 단일 지표 조건 (RSI > 70, MACD 크로스 등) + +**관계**: `@ManyToOne` → `condition_group`(NOT NULL) + +| 컬럼 | 설명 | +|------|------| +| `condition_group_id` | FK | +| `indicator_type` | RSI / MACD / CCI … | +| `condition_type` | ABOVE / BELOW / CROSS_UP … | +| `target_value` | 기준값 | +| `secondary_value` | 보조값 | +| `period` | 기간 | +| `compare_period` | 비교 기간 | +| `candle_range` | 캔들 범위 | +| `logical_operator` | 이전 조건과의 AND/OR | + +--- + +### 20. `ma_condition` — MA 크로스 조건 + +**용도**: 전략 규칙의 이동평균 크로스 매수/매도 조건 + +**관계**: `@ManyToOne` → `strategy_rule`(NOT NULL) + +| 컬럼 | 설명 | +|------|------| +| `strategy_rule_id` | FK | +| `signal_type` | BUY / SELL | +| `ma_short_period` | 단기 MA 기간 | +| `ma_long_period` | 장기 MA 기간 | +| `ma_cross_type` | GOLDEN_CROSS / DEAD_CROSS 등 | +| `logical_operator` | AND / OR | + +--- + +### 21. `strategy_performance` — 백테스트 성과 + +**용도**: 전략별 백테스트 결과 누적 기록 + +| 컬럼 그룹 | 설명 | +|-----------|------| +| `strategy_id`, `strategy_name`, `strategy_type` | 전략 참조 (논리적 FK) | +| `market`, `time_unit` | 테스트 대상 | +| `analysis_period_start/end` | 테스트 기간 | +| `initial_capital`, `final_capital`, `total_return`, `return_rate` | 수익 지표 | +| `total_trades`, `winning_trades`, `win_rate` | 거래 통계 | +| `max_drawdown`, `sharpe_ratio` | 위험 지표 | +| `analysis_date` | 분석 날짜 | + +--- + +### 22. `crypto_alert` — 종목 알림 설정 + +**용도**: 사용자가 특정 종목에 대해 설정한 알림 (연결 조건 포함) + +**관계**: `@OneToMany` → `alert_condition` (cascade ALL, orphanRemoval) + +| 컬럼 | 설명 | +|------|------| +| `symbol` | KRW-BTC 등 | +| `korean_name` / `english_name` | 종목명 | +| `enabled` | 활성 여부 | +| `user_id` | 사용자 참조 (논리적 FK) | +| `strategy_rule_id` | 연결된 전략 규칙 (nullable) | + +--- + +### 23. `alert_condition` — 알림 조건 + +**용도**: 알림 발동 조건 (지표값·임계값 등). 전역 조건(`is_global=true`) 지원. + +**관계**: `@ManyToOne` → `crypto_alert` (nullable) + +| 컬럼 | 설명 | +|------|------| +| `crypto_alert_id` | FK (nullable) | +| `is_global` | 전역 조건 여부 | +| `indicator_type` | 지표 종류 | +| `condition_type` | 조건 타입 | +| `target_value` | 기준값 | +| `period` | 기간 | + +--- + +### 24. `alert_notification` — 알림 발송 이력 + +**용도**: 알림 발동 기록·읽음 상태 + +**관계**: `@ManyToOne` → `crypto_alert`(NOT NULL) + +| 컬럼 | 설명 | +|------|------| +| `crypto_alert_id` | FK | +| `message` | TEXT 알림 메시지 | +| `condition_description` | 발동 조건 설명 | +| `is_read` | 읽음 여부 | +| `triggered_at` | 발동 시각 | +| `notified_at` | 발송 시각 | +| `candle_time` | 발동 캔들 시각 | +| `time_interval` | 시간봉 | + +--- + +### 25. `alert_strategy` — 알림 연결 전략 + +**용도**: 알림 시스템과 전략 규칙 연결 + +| 컬럼 | 설명 | +|------|------| +| `strategy_rule_id` | 논리적 FK → strategy_rule | +| `enabled` | 활성 여부 | + +--- + +### 26. `alert_schedule_config` — 알림 스케줄 설정 + +**용도**: 알림 체크 주기·cron 설정 + +| 컬럼 | 설명 | +|------|------| +| `user_id` | 사용자별 설정 (nullable = 글로벌) | +| `cron_expression` | Cron 표현식 | +| `time_interval` | 시간봉 단위 | +| `strategy_check_interval_seconds` | 전략 체크 주기(초) | +| `enabled` | 활성 여부 | +| `last_run_time` / `next_run_time` | 실행 시각 | + +--- + +### 27. `fcm_tokens` — FCM 푸시 토큰 + +**용도**: Firebase Cloud Messaging 토큰 관리 + +**유니크**: `token` + +| 컬럼 | 설명 | +|------|------| +| `token` | FCM 토큰 (UNIQUE) | +| `user_id` | 사용자 ID (논리적 FK) | +| `device_id` | 기기 식별자 | +| `active` | 활성 여부 | +| `last_used_at` | 최근 사용 시각 | + +--- + +### 28. `crypto_favorites` — 즐겨찾기 종목 + +**용도**: 드롭다운 목록에서 즐겨찾기로 표시할 종목 + +**유니크**: `symbol` + +| 컬럼 | 설명 | +|------|------| +| `symbol` | KRW-BTC 등 (UNIQUE) | +| `korean_name` / `english_name` | 종목명 | +| `display_order` | 정렬 순서 | +| `created_at` | | + +--- + +### 29. `crypto_holdings` — 보유/관리 종목 + +**용도**: 모니터링 대상으로 등록한 종목 + +**유니크**: `symbol` +구조는 `crypto_favorites`와 동일 + +--- + +### 30. `dashboard_snapshot` — 대시보드 스냅샷 + +**용도**: 대시보드 전체 데이터를 JSON으로 캐시 (단일 행, PK=1) + +| 컬럼 | 설명 | +|------|------| +| `id` | 고정 1 | +| `summary_json` | MEDIUMTEXT, 요약 JSON | +| `market_data_status_json` | MEDIUMTEXT, 시장 상태 JSON | +| `computed_at` | 생성 시각 | + +--- + +### 31~36. 주식 관련 테이블 + +> 업비트 암호화폐와 별도로 국내 주식 데이터 분석용 + +| 테이블 | 용도 | 주요 컬럼 | +|--------|------|-----------| +| `stock_price` | 주식 OHLCV | `code`, `date`, OHLCV, `high_120d` | +| `stock_indicator` | 기술지표 | `code`(UNIQUE), MA/RSI/MACD/ADX/BB | +| `stock_flow` | 수급 | `code`(UNIQUE), 외인·기관·프로그램 순매수, 체결강도 | +| `stock_trend_score` | 추세 점수 | `code`(UNIQUE), 점수·랭킹·매수신호·강도 | +| `market_status` | 시장 전체 | `market_type`(UNIQUE), 지수·VIX·매수가능 여부 | +| `rising_stock_schedule_config` | 스캔 스케줄 | `scan_interval`, `max_results`, `enabled` | + +--- + +### 37. `chatbot_config` — 챗봇 설정 + +**용도**: 사용자별 챗봇 동작 설정 (대상 종목·분석 조건·알림 설정) + +| 컬럼 | 설명 | +|------|------| +| `user_id` | 사용자 | +| `enabled` | 활성 여부 | +| `schedule_interval` | 실행 주기 | +| `target_symbols` | TEXT, 대상 종목 JSON | +| `candle_interval` | 분석 시간봉 | +| `strategy_conditions` | TEXT, 전략 조건 JSON | +| `notification_settings` | TEXT, 알림 설정 JSON | + +--- + +### 38. `chatbot_message` — 챗봇 메시지 이력 + +**용도**: 챗봇이 생성한 분석 메시지 저장 + +| 컬럼 | 설명 | +|------|------| +| `config_id` | 챗봇 설정 ID (논리적 FK) | +| `symbol` / `symbol_name` | 종목 | +| `message_type` | 메시지 유형 | +| `title` / `content` | TEXT 내용 | +| `analysis_data` | 분석 원본 데이터 | +| `price` | 분석 시점 가격 | +| `indicators` | 지표값 JSON | +| `read_status` | 읽음 여부 | + +--- + +### 39. `chatbot_schedule_log` — 챗봇 실행 로그 + +**용도**: 챗봇 스케줄 실행 이력·성능 추적 + +| 컬럼 | 설명 | +|------|------| +| `config_id` | 챗봇 설정 ID (논리적 FK) | +| `execution_time` | 실행 시각 | +| `symbols_processed` | 처리 종목 수 | +| `messages_generated` | 생성 메시지 수 | +| `errors` | TEXT, 오류 내용 | +| `duration_ms` | 소요 시간 | +| `status` | SUCCESS / ERROR | + +--- + +## 테이블 간 관계 (ERD 요약) + +``` +users (1) ───────────────────────────────────────────────────────────── + │ ├── (논리) user_indicator_settings.user_id (1:1) + │ ├── (논리) guest_indicator_settings.device_id (기기 구분) + │ ├── (논리) user_ui_theme_settings.user_id (1:1) + │ ├── (논리) crypto_alert.user_id + │ ├── (논리) alert_schedule_config.user_id + │ ├── (논리) fcm_tokens.user_id + │ └── (논리) chatbot_config.user_id + +strategy_rule (1) ─┬── (JPA) condition_group (*) ─── strategy_condition (*) + ├── (JPA) ma_condition (*) + └── (논리) alert_strategy.strategy_rule_id + +condition_group ── 자기참조 (parent_group_id, 중첩 AND/OR 트리) + +crypto_alert (1) ─┬── (JPA) alert_condition (*) + └── (JPA) alert_notification (*) + +trading_strategy ─── (논리) strategy_performance.strategy_id + +upbit_candles ─── 지표 계산의 입력 원본 + ├── IndicatorCalculationService + ├── BacktestService + ├── AlertSchedulerService + └── Python upbit_data_service / upbit_websock_data_service (WRITE) +``` + +> **(논리)**: JPA `@ManyToOne` 없이 ID 값으로만 연결 (FK 제약 없음) +> **(JPA)**: `@ManyToOne` / `@OneToMany` 실제 ORM 연관 존재 + +--- + +## Frontend ↔ Backend ↔ DB 연결 매핑 + +| 프론트 서비스 파일 | API 경로 | 관련 테이블 | +|------------------|----------|------------| +| `authApi.ts` | `/api/auth/*` | `users` | +| `cryptoFavoriteApi.ts` | `/api/favorites/*` | `crypto_favorites` | +| `cryptoHoldingApi.ts` | `/api/holdings/*` | `crypto_holdings` | +| `alertApi.ts` | `/api/alerts/*` | `crypto_alert`, `alert_condition`, `alert_notification` | +| `alertStrategyApi.ts` | `/api/alert-strategies` | `alert_strategy` | +| `strategyRuleApi.ts` | `/api/strategy-rules/*` | `strategy_rule`, `condition_group`, `strategy_condition`, `ma_condition` | +| `tradingStrategyApi.ts` | `/api/strategies/*` | `trading_strategy` | +| `backtestApi.ts` | `/api/backtest/*` | `trading_strategy`, `upbit_candles` | +| `dashboardApi.ts` | `/api/dashboard/*` | `dashboard_snapshot`, `strategy_performance` | +| `settingsApi.ts` | `/api/settings/*` | `analysis_settings` | +| `indicatorSettingsApi.ts` | `/api/user/settings/*` | `user_indicator_settings`, `guest_indicator_settings` | +| `upbitApi.ts` `getCurrentPrice` | `/api/upbit/ticker` | (업비트 외부 API) | +| `upbitApi.ts` `analyzeMacd` | `/api/upbit/analyze` | `upbit_candles` → 지표 계산 | +| `upbitWsSubscriptionApi.ts` | `/api/upbit/ws-subscriptions/*` | `upbit_ws_subscription` | +| `upbitDbStatusApi.ts` | `/api/upbit/db-status` | `upbit_candles` 통계 | +| `dataCollectionApi.ts` | `/api/data-collection/*` | `data_update_history`, `upbit_ingestion_state`, `upbit_trades` | +| `chatbotApi.ts` | `/api/chatbot/*` | `chatbot_config`, `chatbot_message`, `chatbot_schedule_log` | +| `risingStockApi.ts` | `/api/rising-stocks/*` | `stock_trend_score`, `market_status`, `rising_stock_schedule_config` | +| `CryptoNameComboBox` | `/api/upbit/ticker`, `/api/crypto/list` | `crypto_holdings` (목록), 외부 API (시세) | + +--- + +## Python 서비스 → DB 쓰기 매핑 + +| 서비스 | 파일 | 테이블 | 작업 | +|--------|------|--------|------| +| `upbit_data_service` | `db_upsert.py` | `upbit_candles` | INSERT ON DUPLICATE KEY UPDATE | +| `upbit_data_service` | `db_upsert.py` | `upbit_trades` | INSERT ON DUPLICATE KEY UPDATE | +| `upbit_data_service` | `db_upsert.py` | `upbit_ingestion_state` | SELECT + INSERT UPSERT | +| `upbit_data_service` | `data_cleanup.py` | `upbit_candles` | DELETE (3일 이전), OPTIMIZE TABLE | +| `upbit_websock_data_service` | `db_upsert.py` | `upbit_candles` | WebSocket 수신 시 upsert (고저가 GREATEST/LEAST) | +| `upbit_websock_data_service` | `db_upsert.py` | `upbit_ws_subscription` | SELECT (is_active=1 구독 목록 읽기) | + +--- + +## 데이터 보존 정책 + +| 테이블 | 보존 기간 | 삭제 방식 | +|--------|-----------|-----------| +| `upbit_candles` | **3일** | Java `CandleDataSyncScheduler` 매일 03:30 배치 삭제
Python `upbit-data-cleanup` 서비스 매일 03:00 배치 삭제 | +| `alert_notification` | 무제한 | 수동 관리 | +| `chatbot_message` | 설정가능 | `ChatbotMessageRepository.deleteOldMessages()` | +| `chatbot_schedule_log` | 설정가능 | `ChatbotScheduleLogRepository.deleteOldLogs()` | +| `stock_trend_score` | 최신 유지 | `StockTrendScoreRepository` bulk UPDATE | +| binlog | **1일** | `binlog_expire_logs_seconds=86400` (MySQL 설정) | + +--- + +--- + +## GoldenChart 전용 테이블 상세 (v2 신규) + +### 40. `gc_chart_workspace` — 멀티차트 워크스페이스 + +**용도**: 사용자(또는 기기)별 차트 워크스페이스 상태 저장. 레이아웃 ID 및 동기화 옵션을 관리. +**관계**: `@OneToMany` → `gc_chart_slot` (cascade ALL, orphanRemoval) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `user_id` | BIGINT | `users.id` 논리 참조 (로그인 사용자) | +| `device_id` | VARCHAR(100) | 비회원 기기 식별자 | +| `layout_id` | VARCHAR(20) NOT NULL | 레이아웃 코드 (1x1 / 2x1 / 1x2 / 2x2 / 3x1 등) | +| `sync_options_json` | JSON | 차트 간 동기화 옵션 `{symbol, timeframe, crosshair}` | +| `created_at` / `updated_at` | DATETIME | | + +**인덱스**: `idx_gc_ws_user(user_id)`, `idx_gc_ws_device(device_id)` + +--- + +### 41. `gc_chart_slot` — 슬롯별 차트 설정 + +**용도**: 멀티차트 레이아웃의 각 슬롯에 대한 모든 설정값 저장. +**관계**: `@ManyToOne` → `gc_chart_workspace` (FK, CASCADE DELETE) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `workspace_id` | BIGINT NOT NULL | FK → `gc_chart_workspace` | +| `slot_index` | INT NOT NULL | 슬롯 인덱스 (0-based) | +| `symbol` | VARCHAR(50) | 종목 코드 (`KRW-BTC`, `AAPL` 등) | +| `timeframe` | VARCHAR(10) | 타임프레임 (1m/5m/15m/30m/1h/4h/1D/1W/1M) | +| `chart_type` | VARCHAR(20) | candlestick / bar / line / area / baseline | +| `theme` | VARCHAR(20) | dark / light / blue | +| `mode` | VARCHAR(20) | chart / trading | +| `log_scale` | TINYINT(1) | 로그 스케일 여부 | +| `indicators_json` | JSON | `IndicatorConfig[]` — 보조지표 설정 배열 | +| `drawings_json` | JSON | `Drawing[]` — 드로잉 객체 배열 (fibtzSettings 포함) | +| `drawings_locked` | TINYINT(1) | 드로잉 잠금 여부 | +| `drawings_visible` | TINYINT(1) | 드로잉 표시 여부 | +| `pane_layout_json` | JSON | 보조지표 창 높이 배열 `[{paneIndex, height}]` | +| `main_chart_style_json` | JSON | 캔들 색상 등 메인 차트 스타일 | +| `created_at` / `updated_at` | DATETIME | | + +**유니크**: `uk_slot_workspace_index(workspace_id, slot_index)` + +> **indicators_json 구조** (frontend `IndicatorConfig[]` 동일): +> ```json +> [ +> { "id": "EMA_1", "type": "EMA", "params": {"length":21,"src":"close"}, +> "hidden": false, "plotVisibility": {} } +> ] +> ``` + +> **drawings_json 구조** (frontend `Drawing[]` 동일): +> ```json +> [ +> { "id": "uuid", "type": "fibtz", "points": [{"time":1700000000,"price":50000},...], +> "color": "#2196F3", "lineWidth": 1, "style": "dashed", +> "fibtzSettings": {"baseline":{...}, "levels":[...]} } +> ] +> ``` + +--- + +### 42. `gc_watchlist` — 관심종목 + +**용도**: GoldenChart 관심종목 목록 (기존 `crypto_favorites` 와 별도 관리). + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `user_id` | BIGINT | 로그인 사용자 (nullable) | +| `device_id` | VARCHAR(100) | 비회원 기기 ID (nullable) | +| `symbol` | VARCHAR(50) NOT NULL | 종목 코드 | +| `korean_name` / `english_name` | VARCHAR(100) | 종목명 | +| `display_order` | INT | 표시 순서 | +| `created_at` | DATETIME | | + +**유니크**: `uk_watchlist_user_symbol(user_id, symbol)`, `uk_watchlist_device_symbol(device_id, symbol)` + +--- + +### 43. `gc_holdings` — 보유종목 + +**용도**: GoldenChart 보유종목 목록. 평균 매입가·수량 포함. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `user_id` | BIGINT | 로그인 사용자 (nullable) | +| `device_id` | VARCHAR(100) | 비회원 기기 ID (nullable) | +| `symbol` | VARCHAR(50) NOT NULL | 종목 코드 | +| `korean_name` / `english_name` | VARCHAR(100) | 종목명 | +| `avg_price` | DECIMAL(30,8) | 평균 매입가 | +| `quantity` | DECIMAL(30,8) | 보유 수량 | +| `display_order` | INT | 표시 순서 | +| `created_at` / `updated_at` | DATETIME | | + +**유니크**: `uk_holdings_user_symbol(user_id, symbol)`, `uk_holdings_device_symbol(device_id, symbol)` + +--- + +### 44. `gc_indicator_cache` — 지표 계산 결과 캐시 + +**용도**: Ta4j 지표 계산 결과를 캐시하여 동일 요청 반복 계산 방지. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `cache_key` | VARCHAR(200) UNIQUE | `symbol:timeframe:type:params_hash` | +| `symbol` | VARCHAR(50) | 종목 코드 | +| `timeframe` | VARCHAR(10) | 타임프레임 | +| `indicator_type` | VARCHAR(50) | 지표 타입 (RSI, MACD 등) | +| `params_json` | JSON | 계산 파라미터 | +| `result_json` | MEDIUMTEXT | `IndicatorResponse` JSON | +| `bar_count` | INT | 계산에 사용된 캔들 수 | +| `last_bar_time` | BIGINT | 마지막 캔들 Unix 타임스탬프(초) | +| `computed_at` | DATETIME | 계산 시각 | +| `expired_at` | DATETIME | 만료 시각 (NULL = 영구) | + +--- + +## GoldenChart Frontend ↔ Backend ↔ DB 연결 매핑 + +| 프론트 모듈 | API 경로 | 관련 테이블 | +|-------------|----------|------------| +| `backendApi.loadWorkspace()` | `GET /api/chart/workspace` | `gc_chart_workspace`, `gc_chart_slot` | +| `backendApi.saveWorkspace()` | `POST /api/chart/workspace` | `gc_chart_workspace`, `gc_chart_slot` | +| `backendApi.saveSlot()` | `PUT /api/chart/slot/{idx}` | `gc_chart_slot` | +| `backendApi.loadWatchlist()` | `GET /api/watchlist` | `gc_watchlist` | +| `backendApi.addWatchlistItem()` | `POST /api/watchlist` | `gc_watchlist` | +| `backendApi.loadHoldings()` | `GET /api/holdings` | `gc_holdings` | +| `backendApi.upsertHoldingsItem()` | `POST /api/holdings` | `gc_holdings` | +| `backendApi.calculateIndicator()` | `POST /api/indicators/calculate` | `gc_indicator_cache` (캐시) | + +--- + +## GoldenChart 프로젝트 구조 + +``` +goldenChart/ + frontend/ ← React + Vite 프론트엔드 + src/ + utils/backendApi.ts ← 백엔드 API 클라이언트 + hooks/useWorkspacePersist.ts ← DB 자동 저장·복원 훅 + vite.config.ts ← /api → localhost:8080 프록시 추가 + .env.example + backend/ ← Spring Boot 백엔드 + pom.xml ← Ta4j 0.16, Spring Boot 3.3 + src/main/java/com/goldenchart/ + GoldenChartApplication.java + config/ ← CorsConfig, SecurityConfig + entity/ ← GcChartWorkspace, GcChartSlot, GcWatchlist, GcHoldings + repository/ ← JPA Repository 인터페이스 + service/ ← ChartSettingsService, IndicatorService + controller/ ← ChartSettingsController, IndicatorController, WatchlistController + dto/ ← OhlcvBar, IndicatorRequest/Response + src/main/resources/ + application.yml + db/migration/V2__create_gc_tables.sql + ta4j-master/ ← Ta4j 소스 참조용 + db_struct.md ← 이 파일 +``` + +--- + +## 주의사항 + +1. **JPA FK vs 논리 참조**: `users`, `strategy_rule` 등을 참조하는 컬럼 다수가 JPA `@ManyToOne` 없이 Long ID만 보관하므로 DB 레벨 FK 제약이 없습니다. 참조 무결성은 애플리케이션 로직에 의존합니다. +2. **`trading_strategy` vs `strategy_rule`**: 두 테이블은 서로 다른 전략 관리 체계입니다. `trading_strategy`는 파라미터 기반, `strategy_rule`은 DSL/조건 트리 기반입니다. +3. **`upbit_candles` 인덱스**: `(market, candle_type, unit, candle_date_time_kst)` 복합 인덱스. `candle_date_time_kst` 단독 범위 쿼리는 인덱스 효율이 낮습니다. +4. **`autoTradingApi.ts`**: 프론트에서 `/api/auto-trading/*` 호출하나 현재 백엔드에 해당 컨트롤러 없음 (미구현). +5. **GoldenChart 인증**: `gc_*` 테이블은 `user_id` (로그인) 또는 `device_id` (비회원) 중 하나를 식별자로 사용. 헤더 `x-user-id` / `x-device-id` 로 전달. +6. **GoldenChart 지표 계산**: 프론트엔드는 `lightweight-charts-indicators` 로 차트 렌더링, 백엔드는 `Ta4j 0.16` 으로 동일 지표를 계산하여 전략 판단에 사용. EMA 초기화 방식 차이로 인해 초기 수십 봉은 값이 다를 수 있으므로 `warmup-bars: 200` 설정으로 충분한 워밍업 데이터를 확보해야 한다. +7. **`gc_backtest_result`** (V8 마이그레이션): 백테스팅 실행 이력. `analysis_json` 컬럼에 Ta4j `AnalysisCriterion` 전체 계산 결과(총수익률, 승률, MDD, 샤프비율 등)를 JSON으로 저장. `signals_json` 에는 BUY/SELL 시그널 배열, `settings_json` 에는 실행 당시 설정 스냅샷 보관. diff --git a/deploy-home.sh b/deploy-home.sh new file mode 100755 index 0000000..abe6f89 --- /dev/null +++ b/deploy-home.sh @@ -0,0 +1,319 @@ +#!/bin/bash +# ====================================================================== +# Golden Chart - 원격 서버 자동 배포 스크립트 +# 대상 서버: exdev.co.kr +# 동작: 로컬 소스 압축 전송 → 서버에서 docker compose up --build +# ====================================================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# 색상 +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +# ── 서버 설정 ────────────────────────────────────────────────────────── +REMOTE_HOST="${REMOTE_HOST:-exdev.co.kr}" +REMOTE_USER="${REMOTE_USER:-aidev}" +REMOTE_PORT="${REMOTE_PORT:-22}" +REMOTE_PROJECT_DIR="${REMOTE_PROJECT_DIR:-/Users/aidev/dev/goldenChart}" +FRONTEND_PORT="${FRONTEND_PORT:-80}" +BACKEND_PORT="${BACKEND_PORT:-8080}" + +# ── 옵션 파싱 ────────────────────────────────────────────────────────── +DOWN=false +LOGS=false +STATUS=false +SETUP_SSH=false +SYNC=true +NO_CACHE=true # 기본: --no-cache (항상 최신 코드) +FORGET_PASSWORD=false +SHELL_ONLY=false +SERVICE="" # 특정 서비스만 재빌드 (frontend / backend / all) + +print_usage() { + echo "사용법: $0 [옵션]" + echo "" + echo " (기본) 소스 동기화 + 전체 서비스 재빌드 + 재시작" + echo " --use-cache 캐시 사용 빌드 (빠름)" + echo " --no-sync 파일 동기화 생략" + echo " --frontend 프론트엔드만 재빌드" + echo " --backend 백엔드만 재빌드" + echo " --down, -d 모든 서비스 중지" + echo " --logs, -l 전체 로그 스트리밍" + echo " --status, -s 서비스 상태 확인" + echo " --shell 서버 SSH 세션 열기" + echo " --forget-password SSH 패스워드 캐시 삭제" + echo " --help, -h 도움말" + echo "" + echo "서버: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}" + echo "경로: ${REMOTE_PROJECT_DIR}" + echo "URL: http://${REMOTE_HOST}:${FRONTEND_PORT}" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --use-cache) NO_CACHE=false; shift ;; + --no-sync) SYNC=false; shift ;; + --frontend) SERVICE=frontend; shift ;; + --backend) SERVICE=backend; shift ;; + --down|-d) DOWN=true; shift ;; + --logs|-l) LOGS=true; shift ;; + --status|-s) STATUS=true; shift ;; + --shell) SHELL_ONLY=true; shift ;; + --setup-ssh) SETUP_SSH=true; shift ;; + --forget-password) FORGET_PASSWORD=true; shift ;; + --user) REMOTE_USER="$2"; shift 2 ;; + --port-ssh) REMOTE_PORT="$2"; shift 2 ;; + --help|-h) print_usage; exit 0 ;; + *) echo -e "${RED}알 수 없는 옵션: $1${NC}"; print_usage; exit 1 ;; + esac +done + +SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}" + +# ── 배너 ─────────────────────────────────────────────────────────────── +echo -e "${CYAN}${BOLD}" +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ GoldenChart 원격 배포 스크립트 ║" +echo "╚══════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e " 서버: ${YELLOW}${SSH_TARGET}:${REMOTE_PORT}${NC}" +echo -e " 경로: ${YELLOW}${REMOTE_PROJECT_DIR}${NC}" +echo -e " URL: ${CYAN}http://${REMOTE_HOST}:${FRONTEND_PORT}${NC}" +echo "" + +# ── SSH 패스워드 캐시 ───────────────────────────────────────────────── +PASS_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/golden-chart-deploy" +PASS_CACHE_FILE="${PASS_CACHE_DIR}/sshpass-${REMOTE_USER}@${REMOTE_HOST}-${REMOTE_PORT}.txt" + +if [ "$FORGET_PASSWORD" = true ]; then + rm -f "$PASS_CACHE_FILE" && echo -e "${GREEN}✓ 패스워드 캐시 삭제${NC}" || echo -e "${YELLOW}캐시 없음${NC}" +fi + +SERVER_PASSWORD="" +PASSWORD_FROM_CACHE=false + +apply_ssh_cmds() { + # IdentitiesOnly=yes: ssh-agent의 모든 키를 자동 시도하지 않도록 제한 + # → "Too many authentication failures" 방지 + if [ -n "$SERVER_PASSWORD" ]; then + export SSHPASS="$SERVER_PASSWORD" + SSH_CMD="sshpass -e ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o PubkeyAuthentication=no -p ${REMOTE_PORT}" + SCP_CMD="sshpass -e scp -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o PubkeyAuthentication=no -P ${REMOTE_PORT}" + else + unset SSHPASS 2>/dev/null || true + SSH_CMD="ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -p ${REMOTE_PORT}" + SCP_CMD="scp -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -P ${REMOTE_PORT}" + fi +} + +if command -v sshpass &>/dev/null; then + if [ -f "$PASS_CACHE_FILE" ]; then + IFS= read -r SERVER_PASSWORD < "$PASS_CACHE_FILE" || SERVER_PASSWORD="" + [ -n "$SERVER_PASSWORD" ] && PASSWORD_FROM_CACHE=true && echo -e "${GREEN}✓ 저장된 패스워드 사용 (--forget-password 로 초기화)${NC}" + fi + if [ -z "$SERVER_PASSWORD" ]; then + echo -e "${CYAN}SSH 패스워드 (키 인증 시 Enter):${NC}" + read -s -p "패스워드: " SERVER_PASSWORD; echo "" + PASSWORD_FROM_CACHE=false + fi +else + echo -e "${YELLOW}sshpass 없음 — SSH 키 인증 모드${NC}" +fi +apply_ssh_cmds + +# ── SSH 연결 테스트 ─────────────────────────────────────────────────── +echo -e "${CYAN}[연결] SSH 테스트 중...${NC}" +# set -e 영향 차단을 위해 || true 사용 +SSH_ERR=$( { $SSH_CMD $SSH_TARGET "exit"; } 2>&1 ) || true +SSH_EC=$( { $SSH_CMD $SSH_TARGET "exit" >/dev/null 2>&1; echo $?; } ) || SSH_EC=1 +SSH_EC="${SSH_EC:-1}" + +if [ "${SSH_EC}" != "0" ] && [ -n "$SERVER_PASSWORD" ] && [ "$PASSWORD_FROM_CACHE" = true ] && command -v sshpass &>/dev/null; then + echo -e "${YELLOW}캐시 패스워드 실패 — 재입력:${NC}" + rm -f "$PASS_CACHE_FILE" + read -s -p "패스워드: " SERVER_PASSWORD; echo "" + PASSWORD_FROM_CACHE=false + apply_ssh_cmds + SSH_ERR=$( { $SSH_CMD $SSH_TARGET "exit"; } 2>&1 ) || true + SSH_EC=$( { $SSH_CMD $SSH_TARGET "exit" >/dev/null 2>&1; echo $?; } ) || SSH_EC=1 + SSH_EC="${SSH_EC:-1}" +fi + +if [ "${SSH_EC}" != "0" ]; then + echo -e "${RED}✗ SSH 연결 실패 (exit code: ${SSH_EC})${NC}" + [ -n "$SSH_ERR" ] && echo -e "${RED} 오류: $(echo "$SSH_ERR" | head -3)${NC}" + echo "" + echo -e "${YELLOW}해결 방법:${NC}" + echo " 1) SSH 키 등록: ssh-copy-id -p ${REMOTE_PORT} ${SSH_TARGET}" + echo " 2) 직접 테스트: ssh -p ${REMOTE_PORT} ${SSH_TARGET}" + echo " 3) sshpass 설치 (패스워드 인증): brew install hudochenkov/sshpass/sshpass" + exit 1 +fi + +echo -e "${GREEN}✓ SSH 연결 성공${NC}" + +# 패스워드 캐시 저장 +if [ -n "$SERVER_PASSWORD" ] && [ "$PASSWORD_FROM_CACHE" = false ] && command -v sshpass &>/dev/null; then + mkdir -p "$PASS_CACHE_DIR"; chmod 700 "$PASS_CACHE_DIR" 2>/dev/null || true + ( umask 077 && printf '%s' "$SERVER_PASSWORD" > "$PASS_CACHE_FILE" ) + chmod 600 "$PASS_CACHE_FILE" 2>/dev/null || true + echo -e "${GREEN}✓ 패스워드 캐시 저장됨${NC}" +fi +echo "" + +# ── 원격 명령 헬퍼 ──────────────────────────────────────────────────── +remote_exec() { $SSH_CMD $SSH_TARGET "bash -l -c $(printf '%q' "$*")"; } + +# ── 특수 모드 처리 ──────────────────────────────────────────────────── +if [ "$SHELL_ONLY" = true ]; then + echo -e "${CYAN}서버 SSH 세션 (exit 로 종료):${NC}" + $SSH_CMD -t $SSH_TARGET "bash -l"; exit 0 +fi + +if [ "$STATUS" = true ]; then + echo -e "${CYAN}[상태] 서비스 컨테이너:${NC}" + remote_exec "cd ${REMOTE_PROJECT_DIR} && docker compose ps" + echo ""; echo -e "${CYAN}[리소스]:${NC}" + remote_exec "docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'" + exit 0 +fi + +if [ "$LOGS" = true ]; then + echo -e "${CYAN}[로그] 전체 서비스 로그 (Ctrl+C 종료):${NC}" + $SSH_CMD $SSH_TARGET "cd ${REMOTE_PROJECT_DIR} && docker compose logs -f --tail 100" + exit 0 +fi + +if [ "$DOWN" = true ]; then + echo -e "${YELLOW}[중지] 모든 서비스 중지 중...${NC}" + remote_exec "cd ${REMOTE_PROJECT_DIR} && docker compose down" + echo -e "${GREEN}✓ 서비스 중지 완료${NC}"; exit 0 +fi + +# ── [1/3] 파일 동기화 ───────────────────────────────────────────────── +if [ "$SYNC" = true ]; then + echo -e "${CYAN}[1/3] 소스 → 서버 동기화 중...${NC}" + TEMP_ARCHIVE="/tmp/gc-deploy-$$.tar.gz" + echo -e "${YELLOW} 압축 중 (node_modules / .git / dist / target 제외)...${NC}" + tar --exclude='*/node_modules' \ + --exclude='*/.git' \ + --exclude='*/dist' \ + --exclude='*/target' \ + --exclude='*/.idea' \ + --exclude='*/.vscode' \ + --exclude='*/.cursor' \ + --exclude='*/__MACOSX' \ + --exclude='*/.DS_Store' \ + --exclude='*.log' \ + --exclude='.env.local' \ + --exclude='.env.*.local' \ + -czf "$TEMP_ARCHIVE" . + echo -e "${YELLOW} 서버 전송 중...${NC}" + $SCP_CMD "$TEMP_ARCHIVE" "${SSH_TARGET}:/tmp/gc-deploy.tar.gz" + echo -e "${YELLOW} 서버 기존 소스 정리 후 압축 해제 중...${NC}" + remote_exec "rm -rf ${REMOTE_PROJECT_DIR} && \ + mkdir -p ${REMOTE_PROJECT_DIR} && \ + cd ${REMOTE_PROJECT_DIR} && \ + tar -xzf /tmp/gc-deploy.tar.gz && \ + rm -f /tmp/gc-deploy.tar.gz" + rm -f "$TEMP_ARCHIVE" + echo -e "${GREEN}✓ 파일 동기화 완료${NC}" +else + echo -e "${CYAN}[1/3] 파일 동기화 생략 — 서버 기존 소스 사용${NC}" +fi +echo "" + +# ── Docker 데몬 확인 ────────────────────────────────────────────────── +echo -e "${CYAN}[사전] Docker 데몬 확인 중...${NC}" +if ! remote_exec "docker info >/dev/null 2>&1"; then + echo -e "${YELLOW} Docker Desktop 기동 시도 중...${NC}" + remote_exec "[ -d /Applications/Docker.app ] && open -ga Docker || true" + for i in $(seq 1 40); do + sleep 3 + remote_exec "docker info >/dev/null 2>&1" && break + echo -ne "${YELLOW} 대기 중 ${i}...${NC}\r" + done +fi +remote_exec "docker info >/dev/null 2>&1" && echo -e "${GREEN}✓ Docker 데몬 준비됨${NC}" || { echo -e "${RED}✗ Docker 데몬 불가${NC}"; exit 1; } +echo "" + +# ── [2/3] 빌드 및 재시작 ────────────────────────────────────────────── +[ "$NO_CACHE" = true ] && CACHE_MSG="캐시 없이 (항상 최신 코드 반영)" || CACHE_MSG="캐시 사용 (빠름)" +[ "$NO_CACHE" = true ] && NO_CACHE_FLAG="--no-cache" || NO_CACHE_FLAG="" + +if [ -z "$SERVICE" ]; then + # ── 전체 서비스 재시작 ───────────────────────────────────────────── + echo -e "${CYAN}[2/3] 전체 서비스 빌드 + 재시작 중... (${CACHE_MSG})${NC}" + echo -e "${YELLOW} (mysql, backend, frontend 순서로 시작됨)${NC}" + remote_exec "cd ${REMOTE_PROJECT_DIR} && \ + docker compose down --remove-orphans && \ + docker compose build ${NO_CACHE_FLAG} && \ + docker compose up -d" +else + # ── 특정 서비스만 재빌드 ────────────────────────────────────────── + echo -e "${CYAN}[2/3] ${SERVICE} 서비스만 재빌드 중... (${CACHE_MSG})${NC}" + remote_exec "cd ${REMOTE_PROJECT_DIR} && \ + docker compose build ${NO_CACHE_FLAG} ${SERVICE} && \ + docker compose up -d ${SERVICE}" +fi + +echo -e "${GREEN}✓ 빌드 및 재시작 완료${NC}" +echo "" + +# ── [3/3] 서비스 안정화 대기 ────────────────────────────────────────── +echo -e "${CYAN}[3/3] 서비스 기동 대기 중 (최대 90초)...${NC}" +WAIT=0; MAX_WAIT=90 +while [ $WAIT -lt $MAX_WAIT ]; do + RUNNING=$(remote_exec "cd ${REMOTE_PROJECT_DIR} && docker compose ps --status running --format json 2>/dev/null | grep -c 'running' || echo 0" 2>/dev/null || echo "0") + # 3개 서비스가 모두 running 이면 완료 + TOTAL=$(remote_exec "cd ${REMOTE_PROJECT_DIR} && docker compose ps --format json 2>/dev/null | grep -c 'name' || echo 0" 2>/dev/null || echo "0") + HTTP_CODE=$(remote_exec "curl -s -o /dev/null -w '%{http_code}' http://localhost:${FRONTEND_PORT}/ 2>/dev/null || echo 000" 2>/dev/null || echo "000") + echo -ne " 대기 ${WAIT}s — 컨테이너: ${RUNNING}/${TOTAL}, HTTP: ${HTTP_CODE} \r" + if echo "$HTTP_CODE" | grep -qE "^2[0-9][0-9]$"; then + echo "" + break + fi + sleep 5; WAIT=$((WAIT + 5)) +done +echo "" + +# ── 최종 상태 ───────────────────────────────────────────────────────── +echo -e "${CYAN}[완료] 서비스 상태:${NC}" +remote_exec "cd ${REMOTE_PROJECT_DIR} && docker compose ps" +echo "" + +HTTP_CODE=$(remote_exec "curl -s -o /dev/null -w '%{http_code}' http://localhost:${FRONTEND_PORT}/ 2>/dev/null || echo 000" 2>/dev/null || echo "000") +if echo "$HTTP_CODE" | grep -qE "^2[0-9][0-9]$"; then + echo -e "${GREEN}✓ HTTP ${HTTP_CODE} — 프론트엔드 정상 응답${NC}" +else + echo -e "${YELLOW}⚠ HTTP ${HTTP_CODE} — 아직 초기화 중일 수 있습니다.${NC}" + echo -e "${YELLOW} 로그 확인: ./deploy-home.sh --logs${NC}" +fi + +BACKEND_HTTP=$(remote_exec "curl -s -o /dev/null -w '%{http_code}' http://localhost:${BACKEND_PORT}/api/indicators/list 2>/dev/null || echo 000" 2>/dev/null || echo "000") +if echo "$BACKEND_HTTP" | grep -qE "^2[0-9][0-9]$"; then + echo -e "${GREEN}✓ HTTP ${BACKEND_HTTP} — 백엔드 API 정상 응답${NC}" +else + echo -e "${YELLOW}⚠ 백엔드 HTTP ${BACKEND_HTTP} (Flyway 마이그레이션 실행 중일 수 있음)${NC}" +fi + +echo "" +echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}${BOLD}║ 배포 완료! 🎉 ║${NC}" +echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e " 접속 URL: ${CYAN}${BOLD}http://${REMOTE_HOST}:${FRONTEND_PORT}${NC}" +echo "" +echo -e "${YELLOW}자주 쓰는 명령어:${NC}" +echo " ./deploy-home.sh # 전체 재빌드 배포 (기본)" +echo " ./deploy-home.sh --use-cache # 캐시 사용 빌드 (빠름)" +echo " ./deploy-home.sh --frontend # 프론트엔드만 재빌드" +echo " ./deploy-home.sh --backend # 백엔드만 재빌드" +echo " ./deploy-home.sh --no-sync # 서버 기존 소스로 재빌드" +echo " ./deploy-home.sh --status # 서비스 상태 확인" +echo " ./deploy-home.sh --logs # 전체 로그 보기" +echo " ./deploy-home.sh --down # 서비스 중지" +echo " ./deploy-home.sh --shell # 서버 SSH 접속" diff --git a/deploy_server_guide.md b/deploy_server_guide.md new file mode 100644 index 0000000..0e949f1 --- /dev/null +++ b/deploy_server_guide.md @@ -0,0 +1,812 @@ +# GoldenChart 서버 배포 가이드 (exdev.co.kr + Jenkins) + +> **대상 프로젝트:** `goldenChart` (React Frontend + Spring Boot Backend + MySQL) +> **배포 서버:** `exdev.co.kr` +> **문서 버전:** 1.0 · 최종 갱신: 2026-05-23 + +Mac Studio HFT 배포 매뉴얼의 **Git Hook(Track A)** · **Jenkins(Track B)** 패턴을 GoldenChart에 맞게 단순화한 문서입니다. +이 프로젝트는 **Docker Compose 3서비스**(mysql / backend / frontend) 중심이며, 별도 PM2·AI Agent 스택은 포함하지 않습니다. + +--- + +## 목차 + +1. [시스템 개요](#1-시스템-개요) +2. [환경 정보 및 경로](#2-환경-정보-및-경로) +3. [사전 준비 (최초 1회)](#3-사전-준비-최초-1회) +4. [케이스 A: 로컬에서 Git push → 자동 배포](#4-케이스-a-로컬에서-git-push--자동-배포) +5. [케이스 B: deploy-home.sh 수동 배포 (개발용)](#5-케이스-b-deploy-homesh-수동-배포-개발용) +6. [케이스 C: Jenkins 스케줄 자동 배포](#6-케이스-c-jenkins-스케줄-자동-배포) +7. [케이스 D: Jenkins 수동·파라미터 배포](#7-케이스-d-jenkins-수동파라미터-배포) +8. [케이스 E: 서버에서 직접 배포](#8-케이스-e-서버에서-직접-배포) +9. [케이스 F: 서비스별 선택 배포](#9-케이스-f-서비스별-선택-배포) +10. [배포 후 검증](#10-배포-후-검증) +11. [일상 운영 명령어](#11-일상-운영-명령어) +12. [트러블슈팅](#12-트러블슈팅) +13. [부록: deploy.sh · post-receive · Jenkinsfile](#13-부록-deploysh--post-receive--jenkinsfile) + +--- + +## 1. 시스템 개요 + +GoldenChart 배포는 **공통 스크립트 `deploy.sh`** 를 중심으로 두 가지 트랙을 운영합니다. + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ deploy.sh (서버 공통 진입점) │ +│ 예: /Users/aidev/git/deploy.sh (서버에 1회 설치) │ +└───────────────┬────────────────────────────┬───────────────────────┘ + │ │ + Track A (Express) Track B (Scheduled) + git push main → 즉시 배포 Jenkins → 예약/승인 배포 + │ │ + docker compose build/up docker compose build/up + (frontend / backend 선택 가능) (backend·DB 마이그레 권장) +``` + +| 트랙 | 트리거 | 적합한 변경 | 다운타임 | 실행 도구 | +|------|--------|------------|---------|----------| +| **Track A** | `git push exdev main` | UI, 소규모 API, 설정 | 짧음 (컨테이너 재기동) | Git `post-receive` | +| **Track B** | Jenkins 크론/수동 | Flyway DB 마이그레, 대규모 백엔드 | 짧음 | Jenkins Freestyle/Pipeline | +| **개발용** | 로컬 `./deploy-home.sh` | 긴급·초기 구축 | `down` 후 재기동 | SSH + tar 동기화 | + +### deploy.sh 4단계 (권장) + +| Phase | 작업 | 설명 | +|-------|------|------| +| Phase 1 | Git checkout | bare repo → work-tree 동기화 | +| Phase 2 | 빌드 | Docker 이미지 빌드 (`docker compose build`) | +| Phase 3 | 교체 | `docker compose up -d` (서비스별 `--no-deps` 가능) | +| Phase 4 | 검증 | Frontend HTTP + Backend API 응답 확인 | + +> **참고:** 저장소에 포함된 [`deploy-home.sh`](./deploy-home.sh) 는 Git 없이 **tar 압축 + SCP** 로 동기화하는 별도 경로입니다. Jenkins·Git Hook 구축 전까지 또는 긴급 배포에 사용합니다. + +--- + +## 2. 환경 정보 및 경로 + +### 서버 (exdev.co.kr) + +| 항목 | 값 (기본) | +|------|-----------| +| 호스트 | `exdev.co.kr` | +| SSH 사용자 | `aidev` | +| SSH 포트 | `22` | +| 프로젝트 work-tree | `/Users/aidev/dev/goldenChart` | +| Git bare 저장소 (권장) | `/Users/aidev/git/goldenChart.git` | +| 배포 스크립트 (권장) | `/Users/aidev/git/deploy.sh` | +| 배포 로그 (권장) | `/Users/aidev/logs/goldenchart/deploy.log` | + +> 서버 OS·디렉터리는 환경에 맞게 조정하세요. [`deploy-home.sh`](./deploy-home.sh) 기본값은 work-tree를 `/Users/aidev/dev/goldenChart` 로 사용합니다. + +### 로컬 개발 PC + +| 항목 | 예시 경로 | +|------|-----------| +| 소스 | `~/dev/goldenChart` | +| Git remote 이름 | `exdev` (권장) | + +### 서비스 포트 (docker-compose.yml) + +| 서비스 | 컨테이너 | 포트 (기본) | URL | +|--------|---------|------------|-----| +| Frontend (Nginx) | `gc-frontend` | 80 | http://exdev.co.kr/ | +| Backend (Spring Boot) | `gc-backend` | 8080 | http://exdev.co.kr:8080 (직접) 또는 `/api` (Nginx 경유) | +| MySQL | `gc-mysql` | 3306 | localhost:3306 (서버 내부) | + +브라우저에서는 Nginx가 `/api` → `backend:8080` 으로 프록시합니다. + +### Docker Compose 서비스 + +| 서비스명 | 이미지/빌드 | 역할 | +|---------|------------|------| +| `mysql` | mysql:8.0 | DB + Flyway 마이그레이션 대상 | +| `backend` | `backend/Dockerfile` | Spring Boot API, WebSocket(STOMP) | +| `frontend` | `frontend/Dockerfile` | React 빌드 + Nginx | + +### 환경 변수 (.env) + +서버 work-tree 루트에 `.env` 파일을 두세요. 예시는 [`.env.example`](./.env.example) 참고. + +```bash +MYSQL_ROOT_PASSWORD=... # 운영 환경에서 반드시 변경 +MYSQL_DATABASE=stockAnalyzer +MYSQL_USER=stock +MYSQL_PASSWORD=... +BACKEND_PORT=8080 +FRONTEND_PORT=80 +# backend/docker-compose 환경 +GC_SECRETS_ENCRYPTION_KEY=... # 운영용 강한 키로 변경 +``` + +--- + +## 3. 사전 준비 (최초 1회) + +### 3-1. 서버(exdev.co.kr) 인프라 확인 + +서버에 SSH 접속 후 실행합니다. + +```bash +ssh aidev@exdev.co.kr + +# Docker +docker --version +docker compose version +docker ps + +# 디렉터리 생성 +mkdir -p /Users/aidev/git +mkdir -p /Users/aidev/logs/goldenchart +mkdir -p /Users/aidev/dev/goldenChart +``` + +### 3-2. Git Bare 저장소 생성 + +```bash +# 서버에서 실행 +git init --bare /Users/aidev/git/goldenChart.git + +# work-tree (배포 대상) — 이미 clone이 있으면 생략 가능 +# 최초 1회: bare에서 checkout +git --git-dir=/Users/aidev/git/goldenChart.git \ + --work-tree=/Users/aidev/dev/goldenChart \ + checkout -f main 2>/dev/null || true +``` + +### 3-3. deploy.sh 설치 + +서버에 공통 배포 스크립트를 둡니다. **부록 13-1** 의 `deploy.sh` 내용을 복사합니다. + +```bash +# 로컬에서 서버로 복사 (예시) +scp scripts/deploy.sh aidev@exdev.co.kr:/Users/aidev/git/deploy.sh + +# 서버에서 +chmod +x /Users/aidev/git/deploy.sh +``` + +> 저장소에 `scripts/deploy.sh` 가 없으면 [부록 13-1](#13-1-deploysh-서버용) 을 참고해 서버에 직접 생성하세요. + +### 3-4. post-receive 훅 등록 (Track A) + +```bash +cat > /Users/aidev/git/goldenChart.git/hooks/post-receive << 'HOOK' +#!/bin/bash +set -euo pipefail +while read oldrev newrev refname; do + branch="${refname#refs/heads/}" + if [ "$branch" = "main" ]; then + echo "[post-receive] main push → deploy.sh" + SERVICE_NAME="${SERVICE_NAME:-all}" /Users/aidev/git/deploy.sh + else + echo "[post-receive] $branch — 배포 생략 (main만 자동 배포)" + fi +done +HOOK + +chmod +x /Users/aidev/git/goldenChart.git/hooks/post-receive +``` + +### 3-5. 로컬 PC — Git remote 등록 + +```bash +cd ~/dev/goldenChart + +# SSH remote (권장) +git remote add exdev ssh://aidev@exdev.co.kr/Users/aidev/git/goldenChart.git + +# SSH 키 등록 (최초 1회) +ssh-copy-id aidev@exdev.co.kr + +# 연결 테스트 +git ls-remote exdev +``` + +### 3-6. 최초 push (저장소·work-tree 초기화) + +```bash +cd ~/dev/goldenChart +git checkout main +git push -u exdev main +``` + +push 완료 시 서버 `post-receive` → `deploy.sh` 가 자동 실행됩니다. + +### 3-7. Jenkins 설치 및 초기 설정 (Track B) + +서버에 Jenkins가 없다면 Homebrew + PM2 또는 Docker 중 하나로 설치합니다. + +**Homebrew 예시:** + +```bash +brew install jenkins-lts openjdk@21 +# Jenkins URL: http://127.0.0.1:8080 또는 8090 (설치 방식에 따름) +``` + +**Jenkins 전역 환경 변수 (필수):** + +Jenkins → **Manage Jenkins** → **System** → **Global properties** → **Environment variables** + +| 변수명 | 값 (macOS Apple Silicon 예시) | +|--------|------------------------------| +| `PATH` | `/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin` | +| `JAVA_HOME` | `/opt/homebrew/opt/openjdk@21` | + +**Execute shell** 블록 맨 위에 항상 추가: + +```bash +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" +``` + +### 3-8. 서버 .env 및 방화벽 + +```bash +cd /Users/aidev/dev/goldenChart +cp .env.example .env +# vi .env — 비밀번호·암호화 키 변경 + +# 외부에서 80/8080 접근 필요 시 (서버 방화벽·라우터 설정은 환경별로 확인) +``` + +--- + +## 4. 케이스 A: 로컬에서 Git push → 자동 배포 + +> **용도:** 개발 완료 후 exdev.co.kr에 반영 +> **트리거:** `git push exdev main` +> **특징:** Track A — `main` 브랜치만 자동 배포 + +### 4-1. 일반 워크플로 + +```bash +cd ~/dev/goldenChart + +git status +git add . +git commit -m "feat: 알림 팝업 레이아웃 개선" + +git push exdev main +``` + +### 4-2. 자동 배포 흐름 + +``` +[로컬] git push exdev main + ↓ +[exdev] post-receive (main만) + ↓ +/Users/aidev/git/deploy.sh + ↓ +Phase 1: git checkout → /Users/aidev/dev/goldenChart +Phase 2: docker compose build +Phase 3: docker compose up -d +Phase 4: HTTP/API 헬스 확인 +``` + +### 4-3. 배포 로그 확인 (서버) + +```bash +ssh aidev@exdev.co.kr +tail -f /Users/aidev/logs/goldenchart/deploy.log +``` + +### 4-4. feature 브랜치 (배포 없음) + +```bash +git push exdev feature/my-branch +# → post-receive가 main이 아니면 deploy.sh 실행 안 함 +``` + +### 4-5. Track A에 적합한 변경 + +| 변경 유형 | Track A | 비고 | +|----------|---------|------| +| `frontend/src/**` | ✅ | `SERVICE_NAME=frontend` 로 더 빠르게 가능 | +| `backend/src/**` (소규모) | ✅ | Flyway 변경 있으면 Track B 권장 | +| `db/migration/V*.sql` 신규 | ⚠️ | Jenkins·장 마감 후 배포 권장 | +| `docker-compose.yml` | ⚠️ | `docker-only` 또는 전체 재배포 | + +--- + +## 5. 케이스 B: deploy-home.sh 수동 배포 (개발용) + +> **용도:** Git/Jenkins 미구축 시, 또는 긴급 전체 동기화 +> **실행 위치:** 로컬 Mac (`goldenChart` 루트) + +```bash +cd ~/dev/goldenChart + +# 전체 동기화 + Docker 재빌드 (기본, --no-cache) +./deploy-home.sh + +# 프론트엔드만 +./deploy-home.sh --frontend + +# 백엔드만 +./deploy-home.sh --backend + +# 캐시 사용 (빠름) +./deploy-home.sh --use-cache + +# 상태 / 로그 / 중지 +./deploy-home.sh --status +./deploy-home.sh --logs +./deploy-home.sh --down +./deploy-home.sh --shell +``` + +| 옵션 | 설명 | +|------|------| +| (기본) | tar 동기화 → `docker compose down` → build → up | +| `--no-sync` | 서버 기존 소스로만 재빌드 | +| `--use-cache` | Docker 빌드 캐시 사용 | +| `--frontend` / `--backend` | 해당 서비스만 재빌드 | + +환경 변수로 서버 지정: + +```bash +REMOTE_HOST=exdev.co.kr REMOTE_USER=aidev ./deploy-home.sh +``` + +> **주의:** `deploy-home.sh` 는 서버 디렉터리를 **삭제 후 tar 전체 덮어쓰기** 합니다. 서버 전용 `.env`·로컬 수정 사항이 있다면 백업 후 사용하세요. +> 정식 운영은 **Git push + deploy.sh** 를 권장합니다. + +--- + +## 6. 케이스 C: Jenkins 스케줄 자동 배포 + +> **용도:** Spring Boot + Flyway(DB 스키마) 정기 배포 +> **트리거:** Jenkins 크론 또는 Poll SCM + +### 6-1. Jenkins Job 생성 (최초 1회) + +1. Jenkins UI 접속 (예: `http://127.0.0.1:8090` — 서버에서만 접근 가능할 수 있음) +2. **New Item** → `goldenChart-Pipeline` → **Freestyle project** + +**Source Code Management:** + +``` +[x] Git +Repository URL : file:///Users/aidev/git/goldenChart.git +Branch : */main +``` + +**Build Triggers (택 1):** + +| 시나리오 | 크론 표현식 | +|---------|-----------| +| 월~금 18:00 (장 마감 후) | `0 18 * * 1-5` | +| 매주 토 02:00 | `0 2 * * 6` | +| 1시간마다 변경 감지 | Poll SCM `H * * * *` | + +**Build → Execute shell:** + +```bash +#!/bin/bash +set -euo pipefail +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +echo "=== goldenChart Jenkins Build #${BUILD_NUMBER} ===" +echo "시각: $(date)" + +SERVICE_NAME=backend /Users/aidev/git/deploy.sh +``` + +### 6-2. Jenkins에서 Git·Docker 경로 확인 + +빌드 로그에 다음이 출력되어야 합니다. + +```bash +which git && which docker && docker compose version +``` + +--- + +## 7. 케이스 D: Jenkins 수동·파라미터 배포 + +> **용도:** 배포 시점·대상·승인을 UI에서 제어 + +### 7-1. 파라미터 설정 + +Job → **General** → **This project is parameterized** + +| 파라미터 | 타입 | 이름 | 선택값 | +|---------|------|------|--------| +| 배포 서비스 | Choice | `SERVICE_NAME` | `all` / `frontend` / `backend` / `docker-only` | +| 배포 유형 | Choice | `DEPLOY_TYPE` | `after_market` / `immediate` | +| 배포 승인 | Boolean | `DEPLOY_APPROVED` | default: false | + +**Execute shell:** + +```bash +#!/bin/bash +set -euo pipefail +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +if [ "${DEPLOY_APPROVED:-false}" != "true" ]; then + echo "[ERROR] DEPLOY_APPROVED=true 필요" + exit 1 +fi + +if [ "${DEPLOY_TYPE:-after_market}" = "after_market" ]; then + HOUR=$(date +%H) + if [ "$HOUR" -ge 9 ] && [ "$HOUR" -lt 18 ]; then + echo "[ERROR] 장중(09~18시) after_market 배포 불가" + exit 1 + fi +fi + +echo "SERVICE_NAME=${SERVICE_NAME:-all}" +SERVICE_NAME="${SERVICE_NAME:-all}" /Users/aidev/git/deploy.sh +``` + +### 7-2. UI에서 실행 + +1. `goldenChart-Pipeline` → **Build with Parameters** +2. `SERVICE_NAME`, `DEPLOY_TYPE`, `DEPLOY_APPROVED` 설정 +3. **Build** + +### 7-3. 파라미터 조합 예시 + +| 상황 | SERVICE_NAME | DEPLOY_TYPE | DEPLOY_APPROVED | +|------|-------------|-------------|-----------------| +| 장 마감 후 백엔드+DB | `backend` | `after_market` | ✅ | +| UI 긴급 수정 | `frontend` | `immediate` | ✅ | +| 컨테이너만 재시작 | `docker-only` | `immediate` | ✅ | +| 전체 배포 | `all` | `after_market` | ✅ | + +--- + +## 8. 케이스 E: 서버에서 직접 배포 + +> **용도:** Jenkins/Git 없이 서버 터미널에서 즉시 배포 + +```bash +ssh aidev@exdev.co.kr + +# 전체 배포 +/Users/aidev/git/deploy.sh + +# Git push (서버에서 개발한 경우) +cd /Users/aidev/dev/goldenChart +git add . && git commit -m "fix: ..." +git push exdev main # remote가 서버 bare를 가리킬 때 +``` + +### Docker만 수동 재시작 + +```bash +cd /Users/aidev/dev/goldenChart + +docker compose up -d +docker compose up -d --no-deps backend +docker compose up -d --no-deps frontend +docker compose logs -f gc-backend --tail 100 +``` + +### Backend 수동 빌드 (디버깅) + +```bash +cd /Users/aidev/dev/goldenChart +docker compose build backend +docker compose up -d --no-deps backend +``` + +--- + +## 9. 케이스 F: 서비스별 선택 배포 + +`SERVICE_NAME` 환경 변수로 `deploy.sh` 실행 범위를 제한합니다. + +| SERVICE_NAME | 빌드 대상 | 배포 | +|-------------|----------|------| +| `frontend` | `frontend` 이미지 | `docker compose up -d --no-deps frontend` | +| `backend` | `backend` 이미지 | `docker compose up -d --no-deps backend` | +| `docker-only` | 스킵 | `docker compose up -d` | +| `all` (기본) | 전체 | `docker compose up -d` | + +```bash +SERVICE_NAME=frontend /Users/aidev/git/deploy.sh +SERVICE_NAME=backend /Users/aidev/git/deploy.sh +SERVICE_NAME=docker-only /Users/aidev/git/deploy.sh +``` + +### 변경 파일 → SERVICE_NAME 가이드 + +``` +frontend/src/**, frontend/nginx.conf → frontend +backend/src/**, backend/pom.xml → backend +db/migration/V*.sql → backend (Track B 권장) +docker-compose.yml, .env → all 또는 docker-only +``` + +--- + +## 10. 배포 후 검증 + +### 10-1. 컨테이너 상태 + +```bash +cd /Users/aidev/dev/goldenChart +docker compose ps +``` + +기대: `gc-mysql`, `gc-backend`, `gc-frontend` 가 **running (healthy)**. + +### 10-2. Frontend + +```bash +curl -s -o /dev/null -w "Frontend HTTP: %{http_code}\n" http://localhost:80/ +# 기대: 200 +``` + +브라우저: http://exdev.co.kr/ + +### 10-3. Backend API + +```bash +curl -s -o /dev/null -w "Backend API HTTP: %{http_code}\n" \ + http://localhost:8080/api/indicators/list +# 기대: 200 +``` + +### 10-4. MySQL + +```bash +docker exec gc-mysql mysqladmin ping -h localhost \ + -u stock -p"${MYSQL_PASSWORD:-analyzer}" +``` + +### 10-5. 배포 로그 + +```bash +grep -E "배포 완료|배포 실패|ERROR" /Users/aidev/logs/goldenchart/deploy.log | tail -20 +``` + +### 10-6. 로컬에서 원격 확인 + +```bash +./deploy-home.sh --status +``` + +--- + +## 11. 일상 운영 명령어 + +```bash +cd /Users/aidev/dev/goldenChart + +# 시작 / 중지 +docker compose up -d +docker compose down +docker compose restart backend + +# 로그 +docker compose logs -f --tail 100 +docker logs -f gc-backend --tail 100 +docker logs -f gc-frontend --tail 50 + +# 이미지 정리 (디스크 부족 시) +docker system prune -f +``` + +| 하고 싶은 작업 | 명령어 | +|-------------|--------| +| 소스 반영 + 자동 배포 | `git push exdev main` | +| UI만 배포 | `SERVICE_NAME=frontend /Users/aidev/git/deploy.sh` | +| API만 배포 | `SERVICE_NAME=backend /Users/aidev/git/deploy.sh` | +| 긴급 전체 동기화 (로컬) | `./deploy-home.sh` | +| Jenkins 수동 배포 | Build with Parameters | +| 배포 로그 | `tail -f /Users/aidev/logs/goldenchart/deploy.log` | + +--- + +## 12. 트러블슈팅 + +### 12-1. git push 후 배포가 안 됨 + +```bash +# 훅 권한 +ls -la /Users/aidev/git/goldenChart.git/hooks/post-receive +chmod +x /Users/aidev/git/goldenChart.git/hooks/post-receive + +# main 브랜치인지 +git branch --show-current + +# 훅 수동 테스트 +echo "0000000000000000000000000000000000000000 $(git rev-parse HEAD) refs/heads/main" | \ + /Users/aidev/git/goldenChart.git/hooks/post-receive +``` + +### 12-2. deploy.sh / Jenkins — docker 명령 없음 + +Jenkins **Global PATH** 및 shell 상단: + +```bash +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" +which docker && docker compose version +``` + +### 12-3. 배포 락 파일 + +동시 배포 방지용 (deploy.sh에 구현 시): + +```bash +cat /tmp/goldenchart_deploy.lock 2>/dev/null +# 프로세스 없을 때만 +rm -f /tmp/goldenchart_deploy.lock +``` + +### 12-4. Backend 기동 실패 (Flyway / MySQL) + +```bash +docker logs gc-backend --tail 150 +docker logs gc-mysql --tail 50 +docker compose up -d mysql +sleep 20 +docker compose up -d --no-deps backend +``` + +### 12-5. Frontend 502 / API 연결 실패 + +```bash +docker compose ps +# backend healthy 후 frontend 재시작 +docker compose up -d --no-deps backend +docker compose up -d --no-deps frontend +``` + +### 12-6. deploy-home.sh SSH 실패 + +```bash +ssh-copy-id -p 22 aidev@exdev.co.kr +ssh aidev@exdev.co.kr "docker info" +./deploy-home.sh --forget-password # 캐시된 비밀번호 초기화 +``` + +### 12-7. 서버 .env가 push마다 삭제됨 + +`deploy-home.sh` 는 work-tree를 **rm -rf 후 재압축 해제** 합니다. +운영 `.env` 는 서버에만 두고, 로컬 `.env` 는 `.gitignore` 처리하세요. Git 기반 `deploy.sh` 는 work-tree를 덮어쓰지 않으므로 `.env` 가 유지됩니다. + +--- + +## 13. 부록: deploy.sh · post-receive · Jenkinsfile + +### 13-1. deploy.sh (서버용) + +서버 `/Users/aidev/git/deploy.sh` 에 저장합니다. 프로젝트에 추가하려면 `scripts/deploy.sh` 로 복사해 Git에 포함할 수 있습니다. + +```bash +#!/bin/bash +# GoldenChart — exdev.co.kr 공통 배포 스크립트 +set -euo pipefail + +GIT_DIR="${GIT_DIR:-/Users/aidev/git/goldenChart.git}" +WORK_TREE="${WORK_TREE:-/Users/aidev/dev/goldenChart}" +LOG_FILE="${LOG_FILE:-/Users/aidev/logs/goldenchart/deploy.log}" +LOCK_FILE="/tmp/goldenchart_deploy.lock" +SERVICE_NAME="${SERVICE_NAME:-all}" + +export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH:-/usr/bin:/bin}" + +mkdir -p "$(dirname "$LOG_FILE")" +exec >> "$LOG_FILE" 2>&1 + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then + log "ERROR: 다른 배포 진행 중 (PID $(cat "$LOCK_FILE"))" + exit 1 +fi +echo $$ > "$LOCK_FILE" +trap 'rm -f "$LOCK_FILE"' EXIT + +log "========== GoldenChart 배포 시작 (SERVICE_NAME=$SERVICE_NAME) ==========" + +# Phase 1: checkout +if [ -d "$GIT_DIR" ]; then + log "Phase 1: git checkout -f main → $WORK_TREE" + git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" checkout -f main +else + log "WARN: GIT_DIR 없음 — work-tree 기존 소스 사용" +fi + +cd "$WORK_TREE" + +# Phase 2–3: build & up +case "$SERVICE_NAME" in + frontend) + log "Phase 2-3: build & up frontend" + docker compose build frontend + docker compose up -d --no-deps frontend + ;; + backend) + log "Phase 2-3: build & up backend" + docker compose build backend + docker compose up -d mysql + docker compose up -d --no-deps backend + ;; + docker-only) + log "Phase 2-3: docker compose up (no build)" + docker compose up -d + ;; + all|*) + log "Phase 2-3: build & up all" + docker compose build + docker compose up -d + ;; +esac + +# Phase 4: verify +FRONTEND_PORT="${FRONTEND_PORT:-80}" +BACKEND_PORT="${BACKEND_PORT:-8080}" +HTTP_F=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${FRONTEND_PORT}/" || echo "000") +HTTP_B=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${BACKEND_PORT}/api/indicators/list" || echo "000") +log "검증: Frontend=$HTTP_F Backend=$HTTP_B" +docker compose ps + +if [[ "$HTTP_F" =~ ^2 ]] && [[ "$HTTP_B" =~ ^2 ]]; then + log "========== 배포 완료 ==========" +else + log "========== 배포 완료 (일부 헬스체크 비정상 — 로그 확인) ==========" +fi +``` + +### 13-2. post-receive (요약) + +[3-4절](#3-4-post-receive-훅-등록-track-a) 과 동일. `main` push 시에만 `deploy.sh` 호출. + +### 13-3. Jenkinsfile (선택 — Pipeline Job용) + +Pipeline Job을 쓸 경우 저장소 루트에 `Jenkinsfile` 추가 예시: + +```groovy +pipeline { + agent any + parameters { + choice(name: 'SERVICE_NAME', choices: ['all', 'frontend', 'backend', 'docker-only'], description: '배포 대상') + booleanParam(name: 'DEPLOY_APPROVED', defaultValue: false, description: '배포 승인') + } + stages { + stage('Approve') { + steps { + script { + if (!params.DEPLOY_APPROVED) { + error('DEPLOY_APPROVED=true 필요') + } + } + } + } + stage('Deploy') { + steps { + sh ''' + export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + SERVICE_NAME=${SERVICE_NAME} /Users/aidev/git/deploy.sh + ''' + } + } + } + post { + always { + sh 'tail -30 /Users/aidev/logs/goldenchart/deploy.log || true' + } + } +} +``` + +### 13-4. HFT 매뉴얼과의 차이 + +| 항목 | HFT (Mac Studio) | GoldenChart (exdev.co.kr) | +|------|------------------|---------------------------| +| 프로젝트명 | goldenAnalysis | **goldenChart** | +| 배포 스크립트 | `/Volumes/ADATA/git/deploy.sh` | `/Users/aidev/git/deploy.sh` (권장) | +| work-tree | `/Users/aidev/apps/goldenChart` | `/Users/aidev/dev/goldenChart` | +| PM2 / AI Agent | 있음 | **없음** (Docker만) | +| 수동 배포 | — | **`deploy-home.sh`** (tar+SSH) | +| 헬스체크 | Actuator `/actuator/health` | `/api/indicators/list` | + +--- + +*GoldenChart · exdev.co.kr · Docker Compose · 구축 가이드 2026-05-23* diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..781f00a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +# ───────────────────────────────────────────────────────────────────────────── +# GoldenChart — Docker Compose +# 실행: docker compose up --build +# 중지: docker compose down +# 데이터 완전 삭제: docker compose down -v +# ───────────────────────────────────────────────────────────────────────────── + +networks: + goldenchart-net: + driver: bridge + +volumes: + mysql-data: + driver: local + +services: + + # ── MySQL 8.0 ──────────────────────────────────────────────────────────── + mysql: + image: mysql:8.0 + container_name: gc-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} + MYSQL_DATABASE: ${MYSQL_DATABASE:-stockAnalyzer} + MYSQL_USER: ${MYSQL_USER:-stock} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-analyzer} + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql-data:/var/lib/mysql + - ./docker/mysql/init:/docker-entrypoint-initdb.d:ro + command: > + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --default-time-zone=+09:00 + --innodb-buffer-pool-size=256M + --max-connections=200 + --binlog-expire-logs-seconds=86400 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", + "-u", "${MYSQL_USER:-stock}", "-p${MYSQL_PASSWORD:-analyzer}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 40s + networks: + - goldenchart-net + + # ── Spring Boot Backend ────────────────────────────────────────────────── + backend: + build: + context: . # 루트를 컨텍스트로 (ta4j-master 포함) + dockerfile: backend/Dockerfile + container_name: gc-backend + restart: unless-stopped + depends_on: + mysql: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: docker + DB_URL: "jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-stockAnalyzer}?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true" + DB_USERNAME: ${MYSQL_USER:-stock} + DB_PASSWORD: ${MYSQL_PASSWORD:-analyzer} + # 업비트 API 키 DB 암호화 (운영 환경에서 반드시 변경) + GC_SECRETS_ENCRYPTION_KEY: ${GC_SECRETS_ENCRYPTION_KEY:-goldenchart-local-dev-key-change-me} + ports: + - "${BACKEND_PORT:-8080}:8080" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/indicators/list || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - goldenchart-net + + # ── React Frontend (Nginx) ─────────────────────────────────────────────── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: /api + container_name: gc-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${FRONTEND_PORT:-80}:80" + networks: + - goldenchart-net diff --git a/docker/mysql/init/01_charset.sql b/docker/mysql/init/01_charset.sql new file mode 100644 index 0000000..ff0562a --- /dev/null +++ b/docker/mysql/init/01_charset.sql @@ -0,0 +1,2 @@ +-- MySQL 초기화: 문자셋 및 기본 설정 +ALTER DATABASE stockAnalyzer CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e260dae --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env.local +.env*.local +*.log diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..fd12db3 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# GoldenChart Frontend 환경 변수 +# 이 파일을 .env.local 로 복사하여 사용 + +# Spring Boot 백엔드 URL (Vite 프록시 사용 시 /api 로 통일) +VITE_API_BASE_URL=/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3d3f59e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +# ── 1단계: 빌드 ─────────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +# 의존성 레이어 캐시 (package*.json 변경 시만 재설치) +COPY package*.json ./ +RUN npm ci + +# 소스 복사 후 프로덕션 빌드 +# Docker 환경에서 /api 는 nginx 가 backend:8080 으로 프록시하므로 그대로 사용 +COPY . . +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +RUN npm run build + +# ── 2단계: 서비스 ───────────────────────────────────────────────────────────── +FROM nginx:1.27-alpine AS production + +# 빌드 결과물 복사 +COPY --from=builder /app/dist /usr/share/nginx/html + +# SPA 라우팅 + 정적 파일 캐시 설정 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e0950fc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + GoldenChart + + + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..01346c6 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,133 @@ +# WebSocket Upgrade 헤더 동적 처리 (Connection: upgrade ↔ close) +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # 외부 호스트명 DNS 조회용 resolver (Upbit + Backend 프록시에 필요) + resolver 127.0.0.11 8.8.8.8 8.8.4.4 valid=30s; + resolver_timeout 5s; + + # ── STOMP / SockJS (Spring context-path /api → /api/ws/trading) ─── + # SockJS 가 /api/ws/trading/{id}/{session}/websocket 으로 업그레이드하므로 + # 일반 REST 프록시와 분리해 Upgrade 헤더·장시간 타임아웃을 반드시 설정한다. + location /api/ws/ { + proxy_pass http://backend:8080; + 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; + proxy_buffering off; + proxy_request_buffering off; + gzip off; + chunked_transfer_encoding on; + } + + # ── GoldenChart Spring Boot Backend 프록시 ──────────────────────── + # 브라우저 → /api/... → http://backend:8080/api/... + location /api/ { + proxy_pass http://backend:8080; + 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 10s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_buffering off; + } + + # ── Upbit REST API 역방향 프록시 ────────────────────────────────────── + # 브라우저 → /upbit-api/v1/... → https://api.upbit.com/v1/... + # CORS 없이 동일 오리진 요청으로 처리됨 (dev: Vite proxy, prod: nginx proxy) + location /upbit-api/ { + proxy_pass https://api.upbit.com/; + proxy_ssl_server_name on; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + proxy_set_header Host api.upbit.com; + proxy_set_header Accept application/json; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin *; + proxy_cache_bypass $http_upgrade; + } + + # ── Upbit WebSocket 역방향 프록시 ───────────────────────────────────── + # 브라우저 → ws://HOST/upbit-ws → wss://api.upbit.com/websocket/v1 + location /upbit-ws { + proxy_pass https://api.upbit.com/websocket/v1; + proxy_http_version 1.1; + proxy_ssl_server_name on; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + proxy_ssl_verify off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host api.upbit.com; + proxy_set_header Origin "https://upbit.com"; + proxy_set_header X-Real-IP $remote_addr; + # 쿠키/Authorization 헤더 제거 (업비트 WebSocket 인증 불필요) + proxy_set_header Authorization ""; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + + # ── STOMP / SockJS WebSocket 프록시 ────────────────────────────────── + # Spring context-path /api → 실제 엔드포인트는 /api/ws/trading + # (구버전 클라이언트 /ws/** 요청도 /api/ws/** 로 전달) + location /ws/ { + proxy_pass http://backend:8080/api/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; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + proxy_buffering off; + } + + # ── React SPA 폴백 ──────────────────────────────────────────────────── + location / { + try_files $uri $uri/ /index.html; + } + + # JS/CSS/이미지 등 정적 자산 — 1년 장기 캐시 (Vite 콘텐츠 해시 포함 파일명) + location ~* \.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|jpeg|gif|webp)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # HTML 은 캐시 안 함 (항상 최신 index.html 로드) + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # gzip 압축 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/javascript application/javascript + application/json image/svg+xml; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ca816d6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1948 @@ +{ + "name": "react-trading-chart", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-trading-chart", + "version": "0.1.0", + "dependencies": { + "@stomp/stompjs": "^7.3.0", + "lightweight-charts": "^5.2.0", + "lightweight-charts-indicators": "^0.4.1", + "oakscriptjs": "^0.2.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sockjs-client": "^1.6.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@types/sockjs-client": "^1.5.4", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/sockjs-client": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", + "integrity": "sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.356", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", + "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightweight-charts": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz", + "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, + "node_modules/lightweight-charts-indicators": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lightweight-charts-indicators/-/lightweight-charts-indicators-0.4.1.tgz", + "integrity": "sha512-l/ULwRz63xr1m9huktB0US/iginUQYDxEbeowqpPV/ZzLb9LRoSewlOEb0qJVygANUaXeaRmchPLDyMLmFYhAA==", + "license": "MIT", + "peerDependencies": { + "oakscriptjs": "^0.2.8" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/oakscriptjs": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/oakscriptjs/-/oakscriptjs-0.2.8.tgz", + "integrity": "sha512-SRLR1QpnO3k8MNFzT5ZLEauWeULAcDTJ+0L0JXtshMvfDc5f8ezXVwgQ3D9Lv2g4aiUbJNmK+sNuZ8NKeC1I7Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "lightweight-charts": "^5.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1dc607d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "react-trading-chart", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@stomp/stompjs": "^7.3.0", + "lightweight-charts": "^5.2.0", + "lightweight-charts-indicators": "^0.4.1", + "oakscriptjs": "^0.2.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sockjs-client": "^1.6.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@types/sockjs-client": "^1.5.4", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} diff --git a/frontend/public/firebase-messaging-sw.js b/frontend/public/firebase-messaging-sw.js new file mode 100644 index 0000000..e17bb1a --- /dev/null +++ b/frontend/public/firebase-messaging-sw.js @@ -0,0 +1,44 @@ +/** + * Firebase Cloud Messaging Service Worker + * VITE_FIREBASE_* 는 빌드 시 index.html 주입 또는 수동 설정 + */ +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'); + +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) => { + const title = payload.notification?.title || 'GoldenChart'; + const body = payload.notification?.body || payload.data?.message || '새 알림'; + self.registration.showNotification(title, { + body, + icon: '/favicon.ico', + tag: 'goldenchart-fcm', + data: payload.data || {}, + }); + }); + + self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.page) || '/'; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => { + for (const c of list) { + if (c.url.includes(self.location.origin) && 'focus' in c) return c.focus(); + } + if (clients.openWindow) return clients.openWindow(self.location.origin + url); + }), + ); + }); +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..237fd92 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,11013 @@ +/* ══════════════════════════════════════════════════════════════════ + TradingView-style Chart App · Global Styles + ══════════════════════════════════════════════════════════════════ */ +*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --font: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + --radius: 3px; + --toolbar-h: 44px; + --sidebar-w: 44px; + --bottombar-h:28px; + + /* 기본 테마 변수 (다크 테마) — document.body 로 포털된 컴포넌트에서도 사용 가능하도록 :root에 선언 */ + --bg: #1a1b26; + --bg1: #1a1b26; + --bg2: #24283b; + --bg3: #1f2335; + --bg4: #414868; + --text: #c0caf5; + --text2: #9aa5ce; + --text3: #6272a4; + --border: rgba(122, 162, 247, 0.15); + --accent: #7aa2f7; + --accent-h: #5a7dc7; + --up: #ff6b6b; + --down: #4dabf7; + --panel-bg: #24283bee; + --sep: rgba(122, 162, 247, 0.15); +} + +/* ── Dark Theme: 도쿄 나이트 (THEME_SETTINGS.md §2.2) ───────────── */ +.app.dark { + --bg: #1a1b26; + --bg1: #1a1b26; + --bg2: #24283b; + --bg3: #1f2335; + --bg4: #414868; + --text: #c0caf5; + --text2: #9aa5ce; + --text3: #6272a4; + --border: rgba(122, 162, 247, 0.15); + --accent: #7aa2f7; + --accent-h: #5a7dc7; + --up: #ff6b6b; + --down: #4dabf7; + --panel-bg: #24283bee; + --sep: rgba(122, 162, 247, 0.15); +} + +/* ── Light Theme (THEME_SETTINGS.md §2.1) ──────────────────────── */ +.app.light { + --bg: #f5f5f5; + --bg1: #f5f5f5; + --bg2: #ffffff; + --bg3: #f0f0f0; + --bg4: #e0e0e0; + --text: #212121; + --text2: #757575; + --text3: #9e9e9e; + --border: rgba(0, 0, 0, 0.10); + --accent: #1976d2; + --accent-h: #1565c0; + --up: #c62828; + --down: #1565c0; + --panel-bg: #ffffffee; + --sep: rgba(0, 0, 0, 0.10); +} + +/* ── Blue Theme: 딥 네이비 (THEME_SETTINGS.md §2.3) ─────────────── */ +.app.blue { + --bg: #0c1929; + --bg1: #0c1929; + --bg2: #15273d; + --bg3: #0f1e30; + --bg4: #1e3a5f; + --text: #e8f4ff; + --text2: #b8d4f0; + --text3: #7aaed4; + --border: rgba(94, 181, 255, 0.15); + --accent: #5eb5ff; + --accent-h: #3a9ae0; + --up: #ff5555; + --down: #55aaff; + --panel-bg: #15273dee; + --sep: rgba(94, 181, 255, 0.15); +} + +/* ── App Root ───────────────────────────────────────────────────── */ +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 13px; + line-height: 1; +} + +/* ══════════════════════════════════════════════════════════════════ + TOP MENU BAR (글로벌 네비게이션) + ══════════════════════════════════════════════════════════════════ */ +.top-menubar { + display: flex; + align-items: center; + height: 46px; + flex-shrink: 0; + background: var(--bg3); + border-bottom: 1px solid var(--border); + padding: 0 12px; + gap: 4px; + z-index: 200; +} + +/* 로고 */ +.tmb-logo { + display: flex; + align-items: center; + gap: 7px; + margin-right: 4px; + text-decoration: none; +} +.tmb-logo-text { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; + color: var(--text); + white-space: nowrap; +} + +/* 구분선 */ +.tmb-divider { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 8px; + flex-shrink: 0; +} +.tmb-sep { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; + flex-shrink: 0; +} + +/* 네비게이션 */ +.tmb-nav { + display: flex; + align-items: center; + gap: 2px; + flex: 1; +} +.tmb-nav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border: none; + border-radius: 6px; + background: none; + color: var(--text2); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s; +} +.tmb-nav-btn:hover { + background: var(--bg4); + color: var(--text); +} +.tmb-nav-btn--active { + background: rgba(var(--accent-rgb, 122, 162, 247), 0.15); + color: var(--accent); +} +.app.dark .tmb-nav-btn--active { background: rgba(122, 162, 247, 0.13); } +.app.blue .tmb-nav-btn--active { background: rgba(94, 181, 255, 0.13); } +.app.light .tmb-nav-btn--active { background: rgba(25, 118, 210, 0.10); } +.tmb-nav-icon { + display: flex; + align-items: center; + opacity: 0.85; +} +.tmb-nav-btn--active .tmb-nav-icon { opacity: 1; } +.tmb-nav-label { font-size: 13px; } + +/* 우측 영역 */ +.tmb-right { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; +} + +/* 테마 버튼 */ +.tmb-theme-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 10px; + border-radius: 5px; + border: 1px solid var(--border); + background: none; + font-size: 12px; + cursor: pointer; + color: var(--text2); + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; +} +.tmb-theme-btn:hover { background: var(--bg4); color: var(--text); } +.tmb-theme-btn--dark { border-color: #7aa2f7; color: #7aa2f7; } +.tmb-theme-btn--blue { border-color: #5eb5ff; color: #5eb5ff; } +.tmb-theme-btn--light { border-color: #1976d2; color: #1976d2; background: rgba(25,118,210,0.06); } +.tmb-theme-icon { font-size: 13px; line-height: 1; } +.tmb-theme-label { font-size: 12px; font-weight: 500; } + +/* 로그인 버튼 */ +.tmb-login-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 12px; + border-radius: 5px; + border: 1px solid var(--accent); + background: none; + color: var(--accent); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} +.tmb-login-btn:hover { + background: var(--accent); + color: #fff; +} +.app.light .tmb-login-btn:hover { color: #fff; } + +/* ══════════════════════════════════════════════════════════════════ + TOP TOOLBAR + ══════════════════════════════════════════════════════════════════ */ +.tv-toolbar { + display: flex; + align-items: center; + height: var(--toolbar-h); + background: var(--bg2); + border-bottom: 1px solid var(--border); + padding: 0 8px; + flex-shrink: 0; + gap: 2px; + /* overflow-x: auto는 overflow-y: visible을 무력화하므로 + 팝업이 toolbar 아래로 보일 수 있도록 overflow: visible 사용. + toolbar 자체 가로 스크롤이 필요한 경우 내부 left/right 섹션에서 처리. */ + overflow: visible; +} +.tv-toolbar-left { display: flex; align-items: center; gap: 2px; flex: 1; overflow: visible; } +.tv-toolbar-right { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } + +/* Separator */ +.tv-sep { + width: 1px; + height: 22px; + background: var(--sep); + margin: 0 4px; + flex-shrink: 0; +} + +/* Generic icon button */ +.tv-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: transparent; + color: var(--text2); + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; +} +.tv-icon-btn:hover { background: var(--bg3); color: var(--text); } +.tv-icon-btn.active, +.tv-icon-btn--active { background: var(--accent); color: #fff; } +.tv-icon-btn--active:hover { background: var(--accent-h); } + +/* Symbol button */ +.tv-sym-wrap { position: relative; } +.tv-sym-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: transparent; + color: var(--text); + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; + transition: background 0.12s; + white-space: nowrap; +} +.tv-sym-btn:hover { background: var(--bg3); } +.tv-sym-btn svg { color: var(--text2); flex-shrink: 0; } +.tv-sym-text { color: var(--text); } + +.tv-sym-input { + font-size: 14px; + font-weight: 700; + background: var(--bg3); + color: var(--text); + border: 1.5px solid var(--accent); + border-radius: var(--radius); + padding: 4px 10px; + width: 120px; + outline: none; +} + +/* Timeframe dropdown */ +.tv-dropdown-wrap { position: relative; } +.tv-tf-btn { + display: flex; + align-items: center; + padding: 5px 8px; + background: transparent; + color: var(--text); + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: background 0.12s; + white-space: nowrap; +} +.tv-tf-btn:hover { background: var(--bg3); } + +.tv-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + z-index: 200; + min-width: 80px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 1px; +} +.tv-dropdown-item { + display: flex; + align-items: center; + padding: 6px 10px; + background: transparent; + color: var(--text); + border: none; + border-radius: 2px; + cursor: pointer; + font-size: 12px; + text-align: left; + transition: background 0.1s; + white-space: nowrap; +} +.tv-dropdown-item:hover { background: var(--bg3); } +.tv-dropdown-item.active { background: var(--accent); color: #fff; } + +/* Chart type icon button */ +.tv-ct-btn { width: 32px; } + +/* Indicators button */ +.tv-indicators-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + background: transparent; + color: var(--text2); + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background 0.12s, color 0.12s; + white-space: nowrap; +} +.tv-indicators-btn:hover { background: var(--bg3); color: var(--text); } +.tv-ind-badge { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: #fff; + border-radius: 9px; + padding: 0 5px; + min-width: 17px; + height: 15px; + font-size: 10px; + font-weight: 700; +} + +/* Save / Mode button */ +.tv-save-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + background: transparent; + color: var(--text2); + border: 1px solid var(--sep); + border-radius: var(--radius); + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.12s; + white-space: nowrap; +} +.tv-save-btn:hover { background: var(--bg3); color: var(--text); } +.tv-save-btn.trading { border-color: var(--accent); color: var(--accent); background: #2962ff18; } +.tv-save-btn.trading:hover { background: #2962ff28; } + +/* Theme Cycle Button */ +.tv-theme-cycle { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border: 1px solid var(--sep); + border-radius: var(--radius); + background: transparent; + color: var(--text2); + cursor: pointer; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + transition: all 0.15s; +} +.tv-theme-cycle:hover { background: var(--bg3); color: var(--text); } +.tv-theme-cycle.theme-dark { border-color: #7aa2f7; color: #7aa2f7; } +.tv-theme-cycle.theme-blue { border-color: #5eb5ff; color: #5eb5ff; } +.tv-theme-cycle.theme-light { border-color: #1976d2; color: #1976d2; background: rgba(25,118,210,0.06); } + +/* ══════════════════════════════════════════════════════════════════ + UPBIT STATUS BAR + ══════════════════════════════════════════════════════════════════ */ +.upbit-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 3px 12px; + height: 28px; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--bg2); + font-size: 12px; +} +.upbit-bar.connected { border-bottom-color: #69f0ae44; } +.upbit-bar.disconnected { border-bottom-color: #ef535044; } +.upbit-bar.error { border-bottom-color: #ef535088; background: #ef535010; } + +.ws-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: var(--text2); +} +/* 연두색 (연결 정상) */ +.ws-badge.dot-lime { color: #69f0ae; } +.ws-badge.dot-yellow { color: #FFB800; } +.ws-badge.dot-red { color: var(--down); } + +/* 기본 상태 표시 점 */ +.ws-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + transition: box-shadow 0.4s ease-out, transform 0.3s ease-out, background 0.3s ease-out; +} + +/* 연결됨: 천천히 페이드 pulse (idle 상태) */ +.ws-badge.dot-lime .ws-dot { + animation: ws-idle-lime 2.5s ease-in-out infinite; +} + +/* ─── 데이터 수신 중: 형광 연두색 glow ─── */ +.ws-dot.receiving { + animation: none !important; /* idle 애니메이션 일시 중단 */ + background: #b9f6ca !important; /* 밝은 연두 */ + transform: scale(1.4); + box-shadow: + 0 0 6px 3px #69f0ae, /* 내부 glow */ + 0 0 14px 6px #69f0ae66, /* 외부 glow */ + 0 0 24px 10px #b9f6ca22; /* 넓은 음영 */ +} + +@keyframes ws-idle-lime { + 0%, 100% { opacity: 1; box-shadow: 0 0 3px 1px #69f0ae55; } + 50% { opacity: 0.4; box-shadow: none; } +} + +.upbit-market { font-weight: 700; color: var(--text); } +.upbit-price { font-weight: 700; font-size: 13px; } +.upbit-change.up { color: var(--up); font-weight: 600; } +.upbit-change.down { color: var(--down); font-weight: 600; } +.upbit-loading { color: var(--text2); } +.upbit-error { color: var(--down); } +.upbit-hint { margin-left: auto; color: var(--text3); font-size: 11px; white-space: nowrap; } + +/* ══════════════════════════════════════════════════════════════════ + MAIN AREA (sidebar + chart) + ══════════════════════════════════════════════════════════════════ */ +.tv-main { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ── Left Drawing Sidebar ───────────────────────────────────────── */ +.tv-drawing-sidebar { + display: flex; + flex-direction: column; + align-items: center; + width: var(--sidebar-w); + background: var(--bg2); + border-right: 1px solid var(--border); + padding: 6px 0; + flex-shrink: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.tv-side-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + color: var(--text3); + border: none; + border-radius: var(--radius); + cursor: pointer; + margin: 1px 0; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; +} +.tv-side-btn:hover { background: var(--bg3); color: var(--text); } +.tv-side-btn.active { background: var(--accent); color: #fff; } +.tv-side-btn.active:hover { background: var(--accent-h); } + +/* Chart mode: all tools dimmed */ +.tv-drawing-sidebar.chart-mode .tv-side-btn { + opacity: 0.4; + cursor: default; + pointer-events: none; +} +.tv-drawing-sidebar.chart-mode .tv-side-btn:first-child { + /* cursor button always active */ + opacity: 1; + pointer-events: auto; +} + +.tv-side-sep { + width: 24px; + height: 1px; + background: var(--sep); + margin: 4px auto; +} +.tv-side-spacer { flex: 1; } +.tv-side-clear:hover { color: var(--down) !important; } +/* 잠금 활성화 시 주황 강조 */ +.tv-side-btn.tv-side-lock-on { background: #FF6D0033 !important; color: #FF6D00 !important; } +.tv-side-btn.tv-side-lock-on:hover { background: #FF6D0055 !important; } +/* 숨기기 활성화 시 회색 강조 */ +.tv-side-btn.tv-side-eye-off { background: var(--bg3) !important; color: var(--text3) !important; opacity: 0.6; } + +/* ── 툴 그룹 래퍼 ──────────────────────────────────────────────── */ +.tv-side-group { position: relative; display: contents; } + +/* 서브툴 보유 버튼 우하단 작은 삼각 표시 */ +.tv-side-group-btn { position: relative !important; } +.tv-side-group-btn--favorite.active { + background: #FFB30033 !important; + color: #FFB300 !important; +} +.tv-side-group-btn--favorite.active:hover { + background: #FFB30055 !important; +} +.tv-side-expand-arrow { + position: absolute; + bottom: 3px; + right: 3px; + width: 0; height: 0; + border-style: solid; + border-width: 0 0 5px 5px; + border-color: transparent transparent currentColor transparent; + opacity: 0.45; + pointer-events: none; +} +.tv-side-group-btn.active .tv-side-expand-arrow { opacity: 0.8; } + +/* ── 플라이아웃 팝업 ─────────────────────────────────────────────── */ +.tv-side-flyout { + position: fixed; + z-index: 9900; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 5px 4px; + min-width: 230px; + box-shadow: 0 10px 32px rgba(0,0,0,.55); + pointer-events: auto; + animation: flyout-in 0.1s ease; +} +@keyframes flyout-in { + from { opacity: 0; transform: translateX(-6px); } + to { opacity: 1; transform: translateX(0); } +} + +.tv-side-flyout-header { + font-size: 10.5px; + font-weight: 700; + color: var(--text3); + padding: 3px 10px 5px; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border); + margin-bottom: 3px; +} + +.tv-side-flyout-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 6px 10px; + background: transparent; + border: none; + border-radius: 7px; + cursor: pointer; + color: var(--text2); + font-size: 12.5px; + text-align: left; + transition: background 0.1s, color 0.1s; +} +.tv-side-flyout-item:hover { + background: var(--bg3); + color: var(--text); +} +.tv-side-flyout-item.active { + background: rgba(122,162,247,.15); + color: var(--accent); +} +.tv-side-flyout-item.active:hover { + background: rgba(122,162,247,.22); +} + +.tv-side-flyout-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + color: inherit; +} +.tv-side-flyout-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tv-side-flyout-shortcut { + font-size: 10.5px; + color: var(--text3); + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; + flex-shrink: 0; + font-family: monospace; + letter-spacing: 0.04em; +} + +/* ── 플라이아웃 섹션 구분선 ─────────────────────────────────────── */ +.tv-side-flyout-sep { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 6px; + height: 18px; +} +.tv-side-flyout-sep::before { + content: ''; + flex: 1; + height: 1px; + background: var(--border); + opacity: 0.7; +} +.tv-side-flyout-sep:empty::after { content: none; } +.tv-side-flyout-sec-label { + font-size: 10px; + color: var(--text3); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; +} +.tv-side-flyout-sec-label::before { content: none !important; } + +/* ── 플라이아웃 토글 행 ────────────────────────────────────────── */ +.tv-side-flyout-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + gap: 10px; +} +.tv-side-flyout-toggle-row .tv-side-flyout-title { + font-size: 12px; + color: var(--text2); +} + +/* ── 토글 스위치 버튼 ──────────────────────────────────────────── */ +.tv-flyout-toggle { + position: relative; + width: 34px; + height: 18px; + border-radius: 9px; + border: none; + background: var(--bg3); + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s; + padding: 0; +} +.tv-flyout-toggle::after { + content: ''; + position: absolute; + top: 2px; left: 2px; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--text3); + transition: transform 0.2s, background 0.2s; +} +.tv-flyout-toggle.on { + background: var(--accent); +} +.tv-flyout-toggle.on::after { + transform: translateX(16px); + background: #fff; +} + +/* ── 눈 부분 활성 표시 ─────────────────────────────────────────── */ +.tv-side-btn.tv-side-eye-partial { + color: var(--text3); + opacity: 0.7; +} + +/* ── Chart Column ───────────────────────────────────────────────── */ +.tv-chart-col { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + border-right: 1px solid var(--border); + min-width: 0; +} + +/* ── Chart Legend ───────────────────────────────────────────────── */ +.tv-legend { + position: absolute; + top: 6px; + left: 8px; + z-index: 10; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + pointer-events: none; + font-size: 12px; + max-width: calc(100% - 80px); +} +.tv-legend-sym { + display: flex; + align-items: center; + gap: 5px; + font-weight: 700; + font-size: 13px; + color: var(--text); +} +.tv-legend-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.tv-legend-tf { + color: var(--text2); + font-size: 12px; +} +.tv-legend-badge { + padding: 1px 6px; + border-radius: 2px; + font-size: 10px; + font-weight: 700; + background: var(--accent); + color: #fff; +} +.tv-legend-badge.trading { background: #ff6d0020; color: #FF6D00; border: 1px solid #FF6D0044; } + +.tv-legend-ohlcv { + display: flex; + align-items: center; + gap: 8px; + color: var(--text2); + font-size: 12px; +} +.tv-legend-ohlcv b { font-weight: 600; } +.tv-legend-chg { font-weight: 600; } +.tv-legend-ind { padding: 1px 5px; background: var(--bg3); border-radius: 2px; font-size: 11px; color: var(--text2); display: inline-flex; align-items: center; gap: 4px; } +.tv-legend-ind-name { color: var(--text2); } +.tv-legend-ind-vals { display: inline-flex; gap: 5px; } +.tv-legend-ind-vals b { font-weight: 600; font-size: 11px; } + +/* 차트 스크롤 래퍼 + - position: relative : DrawingCanvas 절대좌표 기준 + - overflow-y: auto : 보조지표 많을 때 세로 스크롤 + - flex: 1 / min-height:0 : 상위 flex 컨테이너에서 가용 높이 확보 + - display: block (flex 아님): flex+overflow 조합 시 자식 height 계산 오류 방지 */ +.tv-chart-wrap { + flex: 1; + min-height: 0; + position: relative; + overflow-y: auto; + overflow-x: hidden; +} +.tv-chart-wrap::-webkit-scrollbar { width: 6px; } +.tv-chart-wrap::-webkit-scrollbar-track { background: transparent; } +.tv-chart-wrap::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; } + +/* Chart container + - height: 100% : 기본 상태에서 래퍼를 가득 채움 + - JS 에서 스크롤 필요 시 명시적 height (e.g. 900px)로 전환 */ +.chart-container { + width: 100%; + height: 100%; +} + +/* Loading overlay */ +/* 과거 데이터 추가 로드 중 표시 (차트 좌상단 소형 배지) */ +.chart-history-loading { + position: absolute; + top: 6px; + left: 8px; + z-index: 30; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + background: var(--bg3, rgba(30,30,46,0.85)); + color: var(--text2, #9a9cad); + font-size: 11px; + pointer-events: none; +} + +.chart-loading { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--bg)cc; + color: var(--text2); + font-size: 13px; +} +.loading-spinner { + width: 32px; height: 32px; + border: 3px solid var(--bg4); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ══════════════════════════════════════════════════════════════════ + BOTTOM BAR + ══════════════════════════════════════════════════════════════════ */ +.tv-bottom-bar { + display: flex; + align-items: center; + height: var(--bottombar-h); + background: var(--bg2); + border-top: 1px solid var(--border); + padding: 0 8px; + flex-shrink: 0; + gap: 2px; + overflow-x: auto; + overflow-y: visible; + position: relative; + z-index: 120; +} +.tv-period-group { display: flex; align-items: center; gap: 1px; } +.tv-period-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 3px 7px; + background: transparent; + color: var(--text2); + border: none; + border-radius: 2px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background 0.1s, color 0.1s; + white-space: nowrap; + height: 22px; +} +.tv-period-btn:hover { background: var(--bg3); color: var(--text); } +.tv-period-btn.active { color: var(--accent); font-weight: 700; } + +.tv-toggle-btn.active { color: var(--accent); font-weight: 700; } + +.tv-bottom-spacer { flex: 1; } +.tv-time-display { font-size: 12px; color: var(--text2); padding: 0 8px; white-space: nowrap; } +.tv-right-toggles { margin-left: 4px; } + +/* 시간대 선택 (하단 바) */ +.tz-picker { position: relative; flex-shrink: 0; } +.tz-picker-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + cursor: pointer; + font-size: 12px; + color: var(--text2); + padding: 3px 8px; + border-radius: 2px; + white-space: nowrap; +} +.tz-picker-trigger:hover { background: var(--bg3); color: var(--text); } +.tz-picker-clock { font-variant-numeric: tabular-nums; } +.tz-picker-abbr { color: var(--text); font-weight: 600; } +.tz-picker-caret { font-size: 9px; opacity: 0.7; } +.tz-picker-menu { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + min-width: 240px; + max-height: 280px; + overflow-y: auto; + margin: 0; + padding: 4px 0; + list-style: none; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + z-index: 600; +} +/* 하단 바 — overflow 클리핑 회피 (body 포털) */ +.tz-picker-menu--portal { + position: fixed; + bottom: auto; + right: auto; + z-index: 9900; +} +.tz-picker-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 7px 12px; + background: transparent; + border: none; + color: var(--text); + font-size: 12px; + cursor: pointer; + text-align: left; +} +.tz-picker-item:hover { background: var(--bg3); } +.tz-picker-item.active { color: var(--accent); } +.tz-picker-item-abbr { color: var(--text2); font-size: 11px; margin-left: 8px; } + +/* ══════════════════════════════════════════════════════════════════ + FLOATING PANELS (Stats / Watchlist / Alert) + ══════════════════════════════════════════════════════════════════ */ +.stats-panel, .watchlist-panel, .alert-panel { + position: fixed; + top: calc(var(--toolbar-h) + 8px); + right: 10px; + z-index: 500; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 24px rgba(0,0,0,.35); + padding: 12px; + width: 240px; + max-height: 420px; + overflow-y: auto; +} +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-weight: 700; + font-size: 13px; +} +.panel-close { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 2px; +} +.panel-close:hover { color: var(--text); } +.panel-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 12px; } +.panel-row:last-child { border-bottom: none; } +.panel-label { color: var(--text2); } +.panel-val { font-weight: 600; } +.panel-val.up { color: var(--up); } +.panel-val.down { color: var(--down); } + +.watchlist-item { display: flex; align-items: center; gap: 6px; padding: 5px 0; cursor: pointer; border-bottom: 1px solid var(--border); } +.watchlist-item:last-child { border-bottom: none; } +.watchlist-item:hover .wl-sym { color: var(--accent); } +.wl-sym { font-weight: 700; font-size: 12px; flex: 1; } +.wl-price { font-size: 12px; font-weight: 600; } +.wl-chg { font-size: 11px; min-width: 52px; text-align: right; } +.wl-rm { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 12px; padding: 0 2px; } +.wl-rm:hover { color: var(--down); } +.wl-add { display: flex; gap: 4px; margin-top: 8px; } +.wl-add input { flex: 1; background: var(--bg3); color: var(--text); border: 1px solid var(--border); border-radius: 2px; padding: 4px 8px; font-size: 12px; outline: none; } +.wl-add input:focus { border-color: var(--accent); } +.wl-add-btn { background: var(--accent); color: #fff; border: none; border-radius: 2px; padding: 4px 10px; cursor: pointer; font-size: 12px; } + +.alert-input-row { display: flex; gap: 4px; margin-bottom: 8px; } +.alert-input-row input { flex: 1; background: var(--bg3); color: var(--text); border: 1px solid var(--border); border-radius: 2px; padding: 4px 8px; font-size: 12px; outline: none; } +.alert-input-row input:focus { border-color: var(--accent); } +.alert-add-btn { background: var(--accent); color: #fff; border: none; border-radius: 2px; padding: 4px 10px; cursor: pointer; font-size: 12px; } +.alert-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; border-bottom: 1px solid var(--border); } +.alert-item:last-child { border-bottom: none; } +.alert-price { font-weight: 700; flex: 1; font-size: 12px; } +.alert-label { color: var(--text2); font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.alert-rm { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 12px; padding: 0 2px; } +.alert-rm:hover { color: var(--down); } + +/* ══════════════════════════════════════════════════════════════════ + INDICATOR PANEL (fixed overlay) + ══════════════════════════════════════════════════════════════════ */ + +/* 반투명 배경 오버레이 */ +.ind-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 2000; +} + +/* 패널 본체 */ +.ind-panel { + width: min(520px, 95vw); + max-height: calc(100vh - 48px); + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 검색 행 */ +.ind-panel-search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.ind-panel-search-wrap { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + background: var(--bg3); + border: 1.5px solid var(--border); + border-radius: 5px; + padding: 0 10px; + color: var(--text2); + transition: border-color 0.15s; +} +.ind-panel-search-wrap:focus-within { border-color: var(--accent); } +.ind-panel-search { + flex: 1; + background: transparent; + border: none; + color: var(--text); + font-size: 14px; + outline: none; + padding: 9px 0; +} +.ind-panel-search::placeholder { color: var(--text2); } +.ind-panel-clear { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 13px; + padding: 0; + line-height: 1; +} +.ind-panel-clear:hover { color: var(--text); } +.ind-panel-close { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + flex-shrink: 0; + transition: background 0.1s; +} +.ind-panel-close:hover { background: var(--bg4); color: var(--text); } + +/* 카테고리 탭 */ +.ind-panel-cats { + display: flex; + gap: 2px; + padding: 7px 10px; + border-bottom: 1px solid var(--border); + overflow-x: auto; + overflow-y: hidden; + flex-shrink: 0; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--bg4) transparent; + -webkit-overflow-scrolling: touch; +} +.ind-panel-cats::-webkit-scrollbar { + height: 5px; +} +.ind-panel-cats::-webkit-scrollbar-thumb { + background: var(--bg4); + border-radius: 3px; +} +.ind-panel-cat { + padding: 5px 10px; + background: transparent; + color: var(--text2); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.1s; +} +.ind-panel-cat:hover { background: var(--bg3); color: var(--text); } +.ind-panel-cat.active { background: var(--accent); color: #fff; } +.ind-panel-cat-cnt { font-size: 10px; opacity: 0.75; } + +/* 목록 */ +.ind-panel-list { + flex: 1; + overflow-y: auto; + padding: 6px 8px; +} +.ind-panel-section-title { + font-size: 10px; + font-weight: 700; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 6px 8px 4px; +} +.ind-panel-divider { + height: 1px; + background: var(--border); + margin: 6px 0; +} +.ind-panel-item { + display: flex; + align-items: center; + padding: 9px 10px; + border-radius: 5px; + gap: 10px; + transition: background 0.1s; +} +.ind-panel-item:hover { background: var(--bg3); } +.ind-panel-item.active { background: #2962ff0e; } +.ind-panel-item-info { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 3px; +} +.ind-panel-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ind-panel-desc { + font-size: 11px; + color: var(--text2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ind-panel-item-right { + display: flex; + align-items: center; + gap: 5px; + flex-shrink: 0; +} +.ind-panel-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + background: #2962ff1a; + color: var(--accent); +} +.ind-panel-add { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background 0.1s; +} +.ind-panel-add:hover { background: var(--accent-h); } +.ind-panel-rm { + padding: 4px 10px; + background: var(--down); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.ind-panel-rm:hover { opacity: 0.85; } +.ind-panel-settings { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg3); + color: var(--text2); + cursor: pointer; + flex-shrink: 0; + line-height: 0; +} +.ind-panel-settings:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(122, 162, 247, 0.12); +} +.ind-panel-empty { + padding: 40px; + text-align: center; + color: var(--text2); + font-size: 13px; +} + +/* tv-indicators-btn 활성 상태 */ +.tv-indicators-btn.active { + background: var(--bg3); + color: var(--text); +} + +/* ══════════════════════════════════════════════════════════════════ + INDICATOR MODAL (레거시, 사용 안함) + ══════════════════════════════════════════════════════════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 500; +} +.modal-box { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + width: min(780px, 95vw); + height: min(540px, 90vh); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0,0,0,0.5); +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + cursor: grab; + user-select: none; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.modal-header:active { cursor: grabbing; } +.modal-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); + min-width: 0; +} +.modal-close-btn { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 20px; + line-height: 1; + padding: 2px; +} +.modal-close-btn:hover { color: var(--text); } +.modal-search-row { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.modal-search { + width: 100%; + background: var(--bg3); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 12px; + font-size: 13px; + outline: none; +} +.modal-search:focus { border-color: var(--accent); } +.modal-body { display: flex; flex: 1; min-height: 0; } +.modal-cats { width: 140px; border-right: 1px solid var(--border); padding: 8px; overflow-y: auto; flex-shrink: 0; } +.modal-cat-btn { + display: block; + width: 100%; + padding: 7px 10px; + background: transparent; + color: var(--text2); + border: none; + border-radius: 3px; + cursor: pointer; + text-align: left; + font-size: 12px; + transition: background 0.1s; + margin-bottom: 1px; +} +.modal-cat-btn:hover { background: var(--bg3); color: var(--text); } +.modal-cat-btn.active { background: var(--accent); color: #fff; } +.modal-list { flex: 1; overflow-y: auto; padding: 6px; } +.indicator-item { + display: flex; + align-items: center; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + transition: background 0.1s; + gap: 10px; +} +.indicator-item:hover { background: var(--bg3); } +.indicator-item.in-use { opacity: 0.6; } +.indicator-item-left { display: flex; flex-direction: column; gap: 2px; overflow: hidden; flex: 1; } +.indicator-name { font-size: 12px; font-weight: 600; color: var(--text); } +.indicator-desc { font-size: 11px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.indicator-item-right { display: flex; align-items: center; gap: 5px; flex-shrink: 0; } +.badge { padding: 1px 5px; border-radius: 2px; font-size: 10px; font-weight: 600; } +.badge.overlay { background: #2962ff1a; color: var(--accent); } +.badge.markers { background: #ff980018; color: #FF9800; } +.add-btn { background: var(--accent); color: #fff; border: none; border-radius: 3px; padding: 3px 10px; cursor: pointer; font-size: 11px; font-weight: 600; } +.rm-btn { background: var(--down); color: #fff; border: none; border-radius: 3px; padding: 3px 8px; cursor: pointer; font-size: 11px; } + +.indicator-settings { width: 210px; flex-shrink: 0; overflow-y: auto; padding: 12px; border-left: 1px solid var(--border); } +.settings-title { font-size: 12px; font-weight: 700; margin-bottom: 4px; } +.settings-desc { font-size: 11px; color: var(--text2); margin-bottom: 10px; line-height: 1.4; } +.param-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; } +.param-label { font-size: 11px; color: var(--text2); } +.param-input { width: 75px; background: var(--bg3); color: var(--text); border: 1px solid var(--border); border-radius: 2px; padding: 3px 6px; font-size: 12px; outline: none; } +.param-input:focus { border-color: var(--accent); } +.param-row .ism-num-combo { width: 75px; } +.param-row .ism-num-combo .param-input { width: 100%; } +.settings-actions { display: flex; gap: 6px; margin-top: 10px; } +.settings-add-btn { flex: 1; background: var(--accent); color: #fff; border: none; border-radius: 3px; padding: 6px; cursor: pointer; font-weight: 600; font-size: 12px; } +.settings-cancel { background: var(--bg3); color: var(--text2); border: none; border-radius: 3px; padding: 6px 10px; cursor: pointer; font-size: 12px; } +.plot-preview { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px; } +.plot-legend-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.plot-color { width: 18px; height: 3px; border-radius: 1px; } +.plot-title { font-size: 11px; color: var(--text2); } +.no-results { padding: 30px; text-align: center; color: var(--text2); font-size: 13px; } +.modal-active { padding: 8px 12px; border-top: 1px solid var(--border); flex-shrink: 0; } +.active-title { font-size: 11px; font-weight: 700; color: var(--text2); margin-bottom: 5px; } +.active-list { display: flex; flex-wrap: wrap; gap: 4px; max-height: 55px; overflow-y: auto; } +.active-tag { display: flex; align-items: center; gap: 4px; background: var(--bg3); border-radius: 2px; padding: 2px 8px; font-size: 11px; color: var(--text); } +.active-tag button { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 11px; padding: 0 1px; } + +/* ══════════════════════════════════════════════════════════════════ + DRAWING CANVAS + ══════════════════════════════════════════════════════════════════ */ +.drawing-overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 20; +} +.drawing-overlay canvas { position: absolute; inset: 0; } + +/* ══════════════════════════════════════════════════════════════════ + UTILITY + ══════════════════════════════════════════════════════════════════ */ +.up { color: var(--up); } +.down { color: var(--down); } + +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text3); } + +/* Responsive */ +@media (max-width: 600px) { + :root { --sidebar-w: 36px; } + .tv-sym-text { max-width: 70px; overflow: hidden; text-overflow: ellipsis; } + .tv-theme-label { display: none; } + .tv-indicators-btn span:first-of-type { display: none; } +} + +/* ═══════════════════════════════════════════════════════════════ + Market Search Panel (msp-*) + ═══════════════════════════════════════════════════════════════ */ + +/* 배경 반투명 오버레이 */ +.msp-backdrop { + position: fixed; + inset: 0; + /* 기본값: 전체화면 투명 backdrop (툴바용) */ + z-index: 1200; + background: transparent; +} +/* 슬롯용 backdrop/panel은 인라인 z-index(99999) 로 override됨 */ + +/* 메인 패널 (툴바에서 열릴 때 기본 위치 - 전체화면 좌측 고정) */ +.msp-panel { + position: fixed; + top: 40px; + left: 0; + width: 480px; + max-width: 95vw; + height: calc(100vh - 40px); + background: var(--bg2); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + z-index: 1201; + display: flex; + flex-direction: column; + box-shadow: 6px 0 24px rgba(0,0,0,0.4); +} + +/* 슬롯 내 심볼 버튼에서 열릴 때: 인라인 스타일로 위치 override됨. + 공통 시각 스타일만 여기서 재정의 */ +.msp-panel--slot { + border-right: none; + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 28px rgba(0,0,0,0.5); + /* top/left/width/height 는 JS 인라인 스타일로 지정됨 */ +} + +/* 검색 입력 행 */ +.msp-search-row { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg2); +} + +.msp-search-icon { + color: var(--text3); + flex-shrink: 0; + display: flex; + align-items: center; +} + +.msp-input { + flex: 1; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: 6px 10px; + font-size: 13px; + outline: none; + min-width: 0; +} +.msp-input:focus { border-color: var(--accent); } +.msp-input::placeholder { color: var(--text3); } + +.msp-btn-icon { + background: none; + border: none; + color: var(--text3); + cursor: pointer; + padding: 5px 7px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.msp-btn-icon:hover { background: var(--bg4); color: var(--text); } + +/* 컬럼 헤더 */ +.msp-header { + display: grid; + grid-template-columns: 32px 1fr 90px 74px 72px; + padding: 5px 12px; + font-size: 11px; + color: var(--text3); + border-bottom: 1px solid var(--border); + background: var(--bg2); + user-select: none; +} + +/* 목록 */ +.msp-list { + flex: 1; + overflow-y: auto; +} +.msp-list::-webkit-scrollbar { width: 4px; } +.msp-list::-webkit-scrollbar-track { background: transparent; } +.msp-list::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; } + +/* 빈 상태 */ +.msp-empty { + padding: 24px 16px; + text-align: center; + color: var(--text3); + font-size: 13px; +} + +/* 각 종목 행 */ +.msp-item { + display: grid; + grid-template-columns: 32px 1fr 90px 74px 72px; + padding: 7px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border); + align-items: center; + transition: background 0.1s; +} +.msp-item:hover { background: var(--bg3); } +.msp-item.active { background: color-mix(in srgb, var(--accent) 18%, transparent); } + +/* 컬럼 공통 */ +.msp-col { font-size: 12px; } + +/* 즐겨찾기 별 */ +.msp-col-fav { + text-align: center; + font-size: 16px; + line-height: 1; +} +.msp-star { + color: var(--text3); + cursor: pointer; + transition: color 0.15s; +} +.msp-star:hover { color: #FFB800; } +.msp-star.starred { color: #FFB800; } + +/* 종목명 컬럼 */ +.msp-col-name { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.msp-kor { + font-size: 13px; + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.msp-sym { + font-size: 11px; + color: var(--text3); +} + +/* 현재가 */ +.msp-col-price { + text-align: right; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +/* 전일대비 */ +.msp-col-change { + text-align: right; + font-variant-numeric: tabular-nums; +} +.msp-col-change.up { color: var(--up); } +.msp-col-change.down { color: var(--down); } + +/* 거래대금 */ +.msp-col-vol { + text-align: right; + color: var(--text2); + font-size: 11px; +} + +/* 반응형 - 작은 화면 */ +@media (max-width: 520px) { + .msp-panel { width: 100vw; } +} + +/* ── ObjectTree (오브젝트 트리) ─────────────────────────────────────── */ +.obj-tree-overlay { + position: fixed; + inset: 0; + z-index: 3000; + pointer-events: none; +} +.obj-tree-panel { + position: absolute; + top: 48px; + right: 8px; + width: 260px; + max-height: 420px; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 6px; + box-shadow: 0 8px 28px rgba(0,0,0,0.35); + display: flex; + flex-direction: column; + pointer-events: auto; + overflow: hidden; +} +.obj-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--sep); + flex-shrink: 0; + cursor: grab; + user-select: none; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.obj-tree-header:active { cursor: grabbing; } +.obj-tree-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); +} +.obj-tree-close { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 14px; + padding: 0 2px; + line-height: 1; +} +.obj-tree-close:hover { color: var(--text); } +.obj-tree-empty { + padding: 16px 12px; + color: var(--text3); + font-size: 12px; + text-align: center; +} +.obj-tree-list { + overflow-y: auto; + flex: 1; +} +.obj-tree-list::-webkit-scrollbar { width: 4px; } +.obj-tree-list::-webkit-scrollbar-track { background: transparent; } +.obj-tree-list::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; } +.obj-tree-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--sep); + cursor: default; + transition: background 0.1s; +} +.obj-tree-item:hover { background: var(--bg3); } +.obj-tree-item.hidden { opacity: 0.38; } +.obj-tree-swatch { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; +} +.obj-tree-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} +.obj-tree-name { + flex: 1; + font-size: 11px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.obj-tree-text-preview { + color: var(--text2); + font-size: 10px; +} +.obj-tree-num { + font-size: 10px; + color: var(--text3); + flex-shrink: 0; +} +.obj-tree-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text2); + padding: 2px; + border-radius: 3px; + display: flex; + align-items: center; + flex-shrink: 0; +} +.obj-tree-btn:hover { background: var(--bg4); color: var(--text); } +.obj-tree-del:hover { color: var(--down) !important; } + +/* Toolbar active 버튼 스타일 (그리드 토글 등) */ +.tv-icon-btn.active { + background: var(--accent); + color: #fff; +} +.tv-icon-btn.active:hover { background: var(--accent-h); } +.tv-btn-disabled { cursor: default !important; } + +/* ── PaneLegend (보조지표 pane 좌측 상단 레전드) ─────────────────────── */ +.pane-legend-row { + display: flex; + align-items: center; + gap: 6px; + line-height: 1; + cursor: default; + user-select: none; + pointer-events: auto; +} +.pane-legend-name--hidden { + opacity: 0.42; + color: var(--text-muted, #787b86); +} +.pane-legend-name { + font-size: 11px; + font-weight: 600; + color: var(--text2); + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.pane-legend-row:hover .pane-legend-name, +.pane-legend-name:hover { + background: var(--bg3); + color: var(--text); +} +.pane-legend-val { + font-size: 11px; + font-weight: 700; +} + +/* ── Pane 배경색 오버레이 ───────────────────────────────────────────── */ +/* position:fixed + pointer-events:none 으로 차트 캔버스 위에 투명 색상 레이어 */ +.pane-bg-overlay { + pointer-events: none; + transition: opacity 0.2s; + /* 캔버스 위에 렌더되도록 높은 z-index 이지만 버튼보다는 아래 */ +} + +/* ── Pane 버튼 공통 ─────────────────────────────────────────────────── */ +.pane-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: rgba(20, 24, 40, 0.82); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 4px; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + padding: 0; + transition: color 0.15s, background 0.15s, border-color 0.15s; + pointer-events: auto; + opacity: 0.65; +} +.pane-btn:hover { + opacity: 1; + color: #ffffff; + background: rgba(122, 162, 247, 0.3); + border-color: rgba(122, 162, 247, 0.7); +} +.pane-btn.active { + opacity: 1; + color: var(--accent); + background: rgba(122, 162, 247, 0.22); + border-color: var(--accent); +} + +/* 닫기 버튼: 호버 시 빨간 계열 */ +.pane-btn-close:hover { + color: #ff6b6b; + background: rgba(255, 107, 107, 0.22); + border-color: rgba(255, 107, 107, 0.6); +} + +/* 캔들 pane 좌측 하단 — 전체보기 / 원복 */ +.candle-pane-controls { + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: auto; + opacity: 0.72; + transition: opacity 0.15s; +} +.candle-pane-controls:hover { + opacity: 1; +} +.tv-chart-wrap:hover .candle-pane-controls, +.slot-chart-wrap:hover .candle-pane-controls { + opacity: 1; +} + +/* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */ +.pane-legend-item { + /* fixed position 은 인라인 style 로 지정되므로 여기선 공통 속성만 */ + user-select: none; +} +.pane-drag-handle { + width: 22px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + touch-action: none; + color: rgba(255, 255, 255, 0.65); + opacity: 1; + flex-shrink: 0; + transition: color 0.15s, background 0.15s, box-shadow 0.15s; + border-radius: 4px; + pointer-events: auto; + background: rgba(20, 24, 40, 0.82); + border: 1px solid rgba(255, 255, 255, 0.18); +} +.pane-drag-handle:hover { + color: #ffffff; + background: rgba(122, 162, 247, 0.3); + border-color: rgba(122, 162, 247, 0.7); +} +.pane-drag-handle:active { + cursor: grabbing !important; + color: #ffffff; + background: rgba(122, 162, 247, 0.4); +} + +/* ── 원본 위치 플레이스홀더 ─────────────────────────────────────────── */ +.pane-drag-placeholder { + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 8px, + rgba(122, 162, 247, 0.04) 8px, + rgba(122, 162, 247, 0.04) 16px + ); + border: 1px dashed rgba(122, 162, 247, 0.3); + pointer-events: none; + z-index: 495; +} + +/* ── 드롭 위치 표시선 ────────────────────────────────────────────────── */ +.pane-drop-line { + position: fixed; + height: 3px; + background: linear-gradient(90deg, + transparent 0%, + var(--accent) 8%, + var(--accent) 92%, + transparent 100% + ); + border-radius: 2px; + z-index: 600; + pointer-events: none; + animation: pane-drop-line-pulse 0.45s ease-in-out infinite alternate; +} +@keyframes pane-drop-line-pulse { + from { opacity: 0.8; box-shadow: 0 0 8px var(--accent), 0 0 2px var(--accent); } + to { opacity: 1; box-shadow: 0 0 16px var(--accent), 0 0 4px #fff; } +} + +/* ── 드롭: 합치기(+) ─────────────────────────────────────────────────── */ +.pane-drop-merge { + z-index: 598; + pointer-events: none; + border: 2px dashed rgba(122, 200, 120, 0.85); + border-radius: 4px; + background: rgba(80, 180, 100, 0.12); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} +.pane-drop-merge-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 16px; + border-radius: 8px; + background: rgba(20, 36, 28, 0.88); + border: 1px solid rgba(120, 220, 140, 0.55); + color: #9ee8a8; + font-size: 12px; + font-weight: 700; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} +.pane-drop-merge-badge svg { + opacity: 0.95; +} + +/* ── 드롭: 이동 힌트 ─────────────────────────────────────────────────── */ +.pane-drop-move-hint { + position: fixed; + z-index: 601; + pointer-events: none; + font-size: 11px; + font-weight: 700; + color: var(--accent); + background: rgba(20, 24, 40, 0.9); + padding: 2px 8px; + border-radius: 4px; + border: 1px solid rgba(122, 162, 247, 0.45); +} + +.pane-btn-split:hover { + color: #9ee8a8; + background: rgba(80, 180, 100, 0.28); + border-color: rgba(120, 220, 140, 0.65); +} + +.pane-btn-copy:hover { + color: #a8c8ff; + background: rgba(122, 162, 247, 0.32); + border-color: rgba(122, 162, 247, 0.75); +} + +.pane-drag-ghost-mode { + margin-left: 4px; + font-weight: 500; + opacity: 0.85; +} + +/* ── 차트 내용 이미지 고스트 ─────────────────────────────────────────── */ +.pane-drag-ghost-chart { + pointer-events: none; + z-index: 700; + border: 1px solid rgba(122, 162, 247, 0.7); + border-radius: 3px; + box-shadow: 0 8px 28px rgba(0,0,0,0.55), 0 2px 8px rgba(0,0,0,0.4); + overflow: hidden; + opacity: 0.88; +} + +/* 고스트 이미지 위에 겹치는 레이블 배지 */ +.pane-drag-ghost-label { + position: absolute; + top: 5px; + left: 6px; + background: rgba(30, 34, 54, 0.82); + color: var(--text); + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 3px; + pointer-events: none; + border: 1px solid rgba(122, 162, 247, 0.35); + white-space: nowrap; +} + +/* ── 색상 블록 고스트 (캔버스 캡처 실패 fallback) ───────────────────── */ +/* inline style 로 대부분 지정되므로 여기선 공통 속성만 */ +.pane-drag-ghost-block { + overflow: hidden; +} + +/* ── 텍스트 고스트 (구 fallback, 미사용) ────────────────────────────── */ +.pane-drag-ghost { + position: fixed; + background: var(--bg2); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: 3px 10px; + font-size: 11px; + font-weight: 600; + color: var(--text); + pointer-events: none; + z-index: 700; + opacity: 0.92; + white-space: nowrap; + box-shadow: 0 3px 10px rgba(0,0,0,0.35); + transform: translateY(-50%); +} + +/* ── IndicatorContextToolbar (인디케이터 클릭 플로팅 툴바) ─────────── */ +.ind-ctx-toolbar { + position: fixed; + z-index: 4000; + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-size: 12px; + white-space: nowrap; + user-select: none; + pointer-events: auto; + animation: ctxFadeIn 0.12s ease; +} +@keyframes ctxFadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.ind-ctx-name { + color: var(--text); + font-weight: 600; + font-size: 12px; + padding: 0 6px 0 2px; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + border-right: 1px solid var(--sep); + margin-right: 4px; +} +.ind-ctx-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text2); + padding: 4px 5px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s, color 0.1s; +} +.ind-ctx-btn:hover { background: var(--bg3); color: var(--text); } +.ind-ctx-del:hover { color: var(--down) !important; } + +/* ── IndicatorSettingsModal ───────────────────────────────────────── */ +.ism-overlay { + position: fixed; + inset: 0; + z-index: 5000; + background: rgba(0,0,0,0.45); + animation: ismFadeIn 0.15s ease; +} +@keyframes ismFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +.ism-dialog { + background: var(--bg); + border: 1px solid var(--sep); + border-radius: 10px; + box-shadow: 0 16px 48px rgba(0,0,0,0.5); + width: 720px; + max-width: 96vw; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + animation: ismSlideIn 0.18s ease; +} +@keyframes ismSlideIn { + from { transform: scale(0.95) translateY(8px); } + to { transform: scale(1) translateY(0); } +} +.ism-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--sep); + flex-shrink: 0; + cursor: grab; + user-select: none; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.ism-header:active { cursor: grabbing; } +.ism-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ism-close { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 16px; + padding: 4px; + border-radius: 4px; + line-height: 1; +} +.ism-close:hover { color: var(--text); background: var(--bg3); } +.ism-tabs { + display: flex; + gap: 0; + padding: 0 20px; + border-bottom: 1px solid var(--sep); + flex-shrink: 0; +} +.ism-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 10px 16px; + font-size: 13px; + font-weight: 600; + color: var(--text2); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} +.ism-tab.active { + color: var(--text); + border-bottom-color: var(--accent); +} +.ism-tab:hover:not(.active) { color: var(--text); } +.ism-body { + flex: 1; + overflow-y: auto; + padding: 16px 0; +} +.ism-body::-webkit-scrollbar { width: 4px; } +.ism-body::-webkit-scrollbar-track { background: transparent; } +.ism-body::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; } +.ism-section { padding: 0 20px; } +.ism-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--sep); +} +.ism-row:last-child { border-bottom: none; } +.ism-label { + font-size: 13px; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 120px; + max-width: 58%; + line-height: 1.35; +} +.ism-control { display: flex; align-items: center; gap: 8px; } +.ism-input { + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 5px; + padding: 5px 10px; + font-size: 13px; + color: var(--text); + width: 140px; + outline: none; +} +.ism-input.ism-narrow { width: 84px; } +.ism-input:focus { border-color: var(--accent); } +/* 숫자 combobox — 입력란 클릭 시 목록 펼침, 직접 입력 가능 */ +.ism-num-combo { + position: relative; + display: inline-flex; + align-items: stretch; + width: 140px; + max-width: 100%; + flex-shrink: 0; +} +.ism-num-combo .ism-input { + flex: 1; + min-width: 0; + box-sizing: border-box; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.ism-num-combo-toggle { + flex-shrink: 0; + width: 22px; + padding: 0; + border: 1px solid var(--sep); + border-left: none; + border-radius: 0 5px 5px 0; + background: var(--bg3); + color: var(--text2); + font-size: 10px; + line-height: 1; + cursor: pointer; +} +.ism-num-combo-toggle:hover:not(:disabled) { + background: var(--bg2); + color: var(--text); +} +.ism-num-combo-toggle:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ism-num-combo:has(.ism-narrow) { + width: 106px; +} +.ism-num-combo-list { + position: absolute; + z-index: 6100; + top: calc(100% + 2px); + left: 0; + right: 0; + margin: 0; + padding: 4px 0; + list-style: none; + max-height: 200px; + overflow-y: auto; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 5px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); +} +.ism-num-combo-opt { + display: block; + width: 100%; + padding: 6px 10px; + border: none; + background: transparent; + color: var(--text); + font-size: 13px; + text-align: left; + cursor: pointer; +} +.ism-num-combo-opt:hover { + background: var(--bg3); +} +.ism-num-combo-opt.selected { + color: var(--accent); + font-weight: 600; +} +.ism-num-combo-spacer { + width: 84px; + height: 31px; + flex-shrink: 0; +} +.ism-color-wrap .ism-num-combo { + width: 72px; +} +.ism-color-wrap .ism-num-combo .ism-input.ism-alpha-input { + width: 100%; + text-align: right; + font-size: 12px; + padding: 4px 6px; +} +.ism-input:focus { border-color: var(--accent); } +.ism-select { + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 5px; + padding: 5px 8px; + font-size: 13px; + color: var(--text); + outline: none; + cursor: pointer; +} +.ism-select.ism-narrow { width: 96px; min-width: 96px; flex-shrink: 0; } +.ism-select:focus { border-color: var(--accent); } +/* 스타일 탭 — 타이틀 열 너비를 섹션 내 최장 라벨에 맞춰 컨트롤 상하 정렬 */ +.ism-style-tab-section { + padding-bottom: 8px; +} +.ism-plot-title-measure { + position: absolute; + visibility: hidden; + pointer-events: none; + height: 0; + overflow: hidden; + white-space: nowrap; +} +.ism-plot-title-measure .ism-plot-title { + display: inline-block; + font-size: 13px; + font-weight: 600; + padding-right: 10px; +} +.ism-style-section { + --ism-plot-title-width: 13.5rem; + display: grid; + grid-template-columns: var(--ism-plot-title-width) minmax(0, auto) minmax(0, auto) minmax(0, auto); + column-gap: 14px; + align-items: center; +} +.ism-style-section > .ism-style-row:not(.ism-hline-row) { + display: contents; +} +.ism-style-section > .ism-style-row:not(.ism-hline-row) > * { + padding: 10px 0; + border-bottom: 1px solid var(--sep); + min-width: 0; +} +.ism-style-section > .ism-style-row:not(.ism-hline-row):last-child:not(:has(+ .ism-hline-divider)) > * { + border-bottom: none; +} +.ism-style-section > .ism-hline-divider, +.ism-style-section > .ism-hline-header-row, +.ism-style-section > .ism-hline-row { + grid-column: 1 / -1; +} +/* 스타일 탭 행 — 수평선 등 (플롯 행은 grid + display:contents) */ +.ism-style-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 10px 14px; + padding: 10px 0; + border-bottom: 1px solid var(--sep); + overflow-x: auto; +} +.ism-style-row:last-child { border-bottom: none; } +.ism-style-field-spacer { + visibility: hidden; + pointer-events: none; +} +.ism-plot-title-cell { + display: flex; + align-items: center; + width: var(--ism-plot-title-width); + max-width: var(--ism-plot-title-width); + min-width: var(--ism-plot-title-width); + flex-shrink: 0; + box-sizing: border-box; + padding-right: 4px; +} +.ism-style-plot-toggle { flex-shrink: 0; } +.ism-plot-title { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; +} +.ism-style-section > .ism-style-row:not(.ism-hline-row) > .ism-plot-title-cell { + justify-self: start; +} +.ism-style-width-select { + width: 52px; + min-width: 52px; + flex-shrink: 0; +} +.ism-style-type-select { + min-width: 72px; + flex-shrink: 0; +} +.ism-style-type-static { + font-size: 12px; + color: var(--text2); + min-width: 72px; +} +.ism-hline-row--off { + opacity: 0.45; +} +.ism-hline-price-readonly { + font-size: 13px; + color: var(--text2); +} +.ism-style-field { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + white-space: nowrap; +} +.ism-style-label { + font-size: 11px; + color: var(--text2); + flex-shrink: 0; +} +/* 색상 입력 */ +.ism-color-wrap { display: flex; align-items: center; gap: 4px; position: relative; } +.ism-color-swatch-btn { + width: 36px; + height: 28px; + padding: 3px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg2); + cursor: pointer; + flex-shrink: 0; +} +.ism-color-swatch-btn:hover { + border-color: var(--text3); +} +.ism-color-swatch-preview { + display: block; + width: 100%; + height: 100%; + border-radius: 2px; + border: 1px solid rgba(255, 255, 255, 0.12); +} +.ism-color-picker { + width: 36px; height: 28px; + border: 1px solid var(--sep); + border-radius: 4px; + padding: 1px; + background: none; + cursor: pointer; + flex-shrink: 0; +} +.ism-color-picker--compact { + width: 32px; + height: 28px; +} +.ism-color-popover { + position: fixed; + z-index: 6200; + width: 280px; + max-width: calc(100vw - 16px); + padding: 12px; + background: var(--bg); + border: 1px solid var(--sep); + border-radius: 8px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} +.ism-color-popover-native { + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--sep); +} +.ism-color-native-label { + display: flex; + align-items: center; + gap: 8px; +} +.ism-alpha-input { + width: 68px; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + color: var(--text); + outline: none; + text-align: right; +} +.ism-alpha-label { font-size: 11px; color: var(--text2); } +/* 토글 스위치 */ +.ism-toggle { + position: relative; + display: inline-block; + width: 38px; height: 22px; + cursor: pointer; +} +.ism-toggle input { opacity: 0; width: 0; height: 0; } +.ism-toggle-slider { + position: absolute; + inset: 0; + background: var(--bg4); + border-radius: 11px; + transition: background 0.2s; +} +.ism-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; height: 16px; + left: 3px; top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.ism-toggle input:checked + .ism-toggle-slider { background: var(--accent); } +.ism-toggle input:checked + .ism-toggle-slider::before { transform: translateX(16px); } +/* plot swatch */ +.ism-plot-swatch { + display: inline-block; + width: 10px; height: 10px; + border-radius: 2px; + flex-shrink: 0; +} +/* empty */ +.ism-empty { + text-align: center; + color: var(--text3); + font-size: 12px; + padding: 20px; +} +/* ── 수평선 (과열선·중앙선·침체선) ──────────────────────────────────────── */ +.ism-hline-divider { + border: none; + border-top: 1px solid var(--sep); + margin: 10px 0 6px; +} + +/* 헤더 행 */ +.ism-hline-header-row { + display: grid; + grid-template-columns: 1fr 70px minmax(200px, 1fr); + gap: 6px; + padding: 2px 2px 4px; + font-size: 10px; + font-weight: 700; + color: var(--text3); + text-transform: uppercase; + letter-spacing: .4px; + border-bottom: 1px solid var(--sep); + margin-bottom: 2px; +} + +/* 각 hline 행 */ +.ism-hline-row { + display: grid; + grid-template-columns: 1fr 70px minmax(200px, 1fr); + gap: 6px; + align-items: center; + padding: 4px 2px; + border-radius: 4px; + transition: background .1s; +} +.ism-hline-row:hover { background: var(--bg3); } + +/* 이름 셀 (토글 + 레이블) */ +.ism-hline-name-cell { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} +.ism-hline-label-text { + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 일반 셀 */ +.ism-hline-cell { + display: flex; + align-items: center; +} +.ism-hline-cell .ism-input, +.ism-hline-cell .ism-select { width: 100%; } +.ism-hline-cell--style .plsp-inline { + width: 100%; + max-width: 100%; +} + +.ism-hline-toggle { flex-shrink: 0; } + +/* 비활성 상태 */ +.ism-hline-disabled .ism-hline-label-text { opacity: 0.45; } +.ism-hline-disabled .ism-hline-cell { opacity: 0.35; pointer-events: none; } +/* 일목균형표 구름 영역 색상 */ +.ism-ichimoku-cloud-divider { + grid-column: 1 / -1; + margin: 12px 0 8px; + border: none; + border-top: 1px solid var(--sep); +} +.ism-ichimoku-cloud-section { + grid-column: 1 / -1; + padding: 0 0 8px; +} +.ism-ichimoku-cloud-title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 700; + color: var(--text); +} +.ism-ichimoku-cloud-hint { + margin: 0 0 12px; + padding: 8px 10px; + font-size: 12px; + line-height: 1.45; + color: var(--text2); + background: rgba(255, 193, 7, 0.08); + border: 1px solid rgba(255, 193, 7, 0.35); + border-radius: 6px; +} +.ism-ichimoku-cloud-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 560px) { + .ism-ichimoku-cloud-grid { grid-template-columns: 1fr; } +} +.ism-ichimoku-cloud-card { + padding: 10px 12px; + border: 1px solid var(--sep); + border-radius: 8px; + background: var(--bg2); +} +.ism-ichimoku-cloud-card-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + color: var(--text); +} +.ism-ichimoku-cloud-card-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.ism-ichimoku-cloud-meta { + font-size: 11px; + color: var(--text3); + line-height: 1.4; + word-break: break-all; +} +/* ── Hlines Background / 출력값 / 타임프레임 가시성 ──────────────────── */ +.ism-output-section-title { + padding: 10px 0 6px; + font-size: 11px; + font-weight: 600; + color: var(--text3); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.ism-output-row .ism-plot-title { + flex: 1; + font-size: 13px; +} +/* 타임프레임 체크박스 그리드 */ +.ism-tf-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px 4px; + padding: 4px 0 10px; +} +.ism-tf-item { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + font-size: 12px; + color: var(--text2); + user-select: none; +} +.ism-tf-item input[type="checkbox"] { + width: 13px; + height: 13px; + cursor: pointer; + accent-color: var(--accent, #2196F3); + flex-shrink: 0; +} +.ism-tf-label { + white-space: nowrap; +} + +/* SMA MA1~8 입력 행 */ +.sma-inputs-section { + --ism-row-label-width: 13.5rem; +} +.sma-inputs-section .ism-sma-ma-row { + display: grid; + grid-template-columns: 38px var(--ism-row-label-width) minmax(0, 1fr); + align-items: center; + gap: 10px 14px; + justify-content: start; +} +.sma-inputs-section .ism-sma-ma-label { + width: 100%; + min-width: 0; + max-width: none; + flex: none; +} +.sma-inputs-section .ism-sma-ma-control { + justify-content: flex-start; +} +.ism-sma-divider { + height: 1px; + background: var(--sep); + margin: 8px 0 12px; +} +.ism-sma-ma-row { + flex-wrap: nowrap; + gap: 10px; + justify-content: flex-start; +} +.ism-sma-ma-row.ism-sma-ma-off .ism-sma-ma-label, +.ism-sma-ma-row.ism-sma-ma-off .ism-control { + opacity: 0.45; +} +.ism-sma-ma-toggle { + flex-shrink: 0; +} +.ism-sma-ma-label { + min-width: 0; + max-width: none; + flex: 0 0 auto; + white-space: nowrap; +} +.ism-sma-ma-control .plsp-inline { + flex-wrap: nowrap; +} +.ism-sma-ma-control { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex: 1; + min-width: 0; +} +.ism-sma-period-input { + width: 72px; + flex-shrink: 0; +} + +/* 보조지표 통합 설정 (일괄 설정·embedded) — 라벨 열 고정 */ +.unified-settings-section { + padding-bottom: 8px; + --ism-row-label-width: 13.5rem; +} +.unified-settings-section .ism-sma-ma-row { + display: grid; + grid-template-columns: 38px var(--ism-row-label-width) minmax(0, 1fr); + align-items: center; + gap: 10px 14px; + justify-content: start; +} +.unified-settings-section .ism-sma-ma-label { + width: 100%; + min-width: 0; + max-width: none; + flex: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.unified-settings-section .ism-sma-ma-control { + justify-content: flex-start; +} +.unified-settings-section .ism-global-param-row { + display: grid; + grid-template-columns: var(--ism-row-label-width) minmax(0, 1fr); + gap: 10px 14px; +} +.unified-settings-section .ism-global-param-row .ism-sma-ma-label { + width: 100%; +} +.ism-global-param-row { + gap: 10px; +} +.ism-global-param-row .ism-sma-ma-label { + min-width: 120px; +} +/* 볼린저밴드 — 업비트 심볼 선택 */ +.ism-bb-symbol-section { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 4px; +} +.ism-bb-symbol-option { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} +.ism-bb-symbol-option input[type="radio"] { + accent-color: var(--text); + flex-shrink: 0; +} +.ism-bb-symbol-hint { + font-size: 12px; + color: var(--text2); + margin-left: 4px; +} +.ism-bb-symbol-other { + flex-wrap: wrap; +} +.ism-bb-ref-symbol { + flex: 1; + min-width: 140px; + max-width: 200px; +} +.ism-bb-ref-symbol:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ism-plot-param-inputs { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ism-param-inline-select { + min-width: 100px; + max-width: 160px; +} +.ism-hlines-bg-spacer { + visibility: hidden; + pointer-events: none; +} + +/* 일목균형표 — SMA와 동일 행 레이아웃 */ +.ichimoku-inputs-section { + padding-bottom: 8px; +} +.ichimoku-inputs-section .ism-ichimoku-cloud-divider { + margin: 14px 0 10px; +} +.ichimoku-inputs-section .ism-ichimoku-cloud-section { + padding: 0 0 4px; +} +.ism-ichimoku-auto-param { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + height: 28px; + padding: 0 8px; + font-size: 12px; + color: var(--text3); + background: var(--bg3); + border: 1px solid var(--sep); + border-radius: 5px; + flex-shrink: 0; +} +.ism-ichimoku-extra-row { + border-bottom: none; +} +.ism-ichimoku-tf-grid { + margin-top: 4px; + padding-bottom: 8px; +} + +/* PlotLineStylePicker — 색상·선 굵기·선 유형 */ +.plsp-inline { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.plsp-inline--disabled { + opacity: 0.4; + pointer-events: none; +} +.plsp-inline-select { + height: 28px; + min-width: 44px; + padding: 0 6px; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 4px; + font-size: 12px; + color: var(--text); + cursor: pointer; + outline: none; +} +.plsp-inline-select--style { + min-width: 64px; +} +.plsp-inline-select:hover:not(:disabled) { + border-color: var(--text3); +} +.plsp-inline-select:focus { + border-color: var(--accent); +} +.plsp-inline-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ism-style-field--line-picker .plsp-inline { + flex-wrap: nowrap; +} +.plsp-swatch { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 3px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.plsp-swatch:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.plsp-swatch-inner { + display: block; + width: 100%; + height: 100%; + border-radius: 2px; + border: 1px solid rgba(255,255,255,0.15); +} +.plsp-portal-root { + position: fixed; + inset: 0; + z-index: 6000; + pointer-events: none; +} +.plsp-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + pointer-events: auto; + animation: plspFadeIn 0.12s ease; +} +.plsp-popup { + position: fixed; + z-index: 6001; + width: min(320px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + overflow-y: auto; + padding: 14px 16px 12px; + background: var(--bg); + border: 1px solid var(--sep); + border-radius: 10px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); + pointer-events: auto; + animation: plspPopIn 0.15s ease; +} +@keyframes plspFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes plspPopIn { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} +.plsp-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: -14px -16px 12px; + padding: 10px 14px; + cursor: grab; + user-select: none; + border-bottom: 1px solid var(--sep); + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); + border-bottom: 1px solid var(--sep); +} +.plsp-popup-title { + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); +} + +/* 드로잉 설정 팝업 */ +.dsm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 5000; +} +.dsm-dialog { + display: flex; + flex-direction: column; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + max-width: 96vw; + max-height: 90vh; + overflow: hidden; +} +.dsm-body { + padding: 16px 20px 20px; + overflow-y: auto; + color: var(--text); +} +.dsm-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; +} +.plsp-popup-close { + background: none; + border: none; + color: var(--text2); + font-size: 16px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; +} +.plsp-popup-close:hover { + color: var(--text); + background: var(--bg3); +} +.plsp-popup-footer { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--sep); + display: flex; + justify-content: flex-end; +} +.plsp-popup-ok { + padding: 7px 20px; + font-size: 13px; + font-weight: 600; + color: #fff; + background: var(--accent, #2196F3); + border: none; + border-radius: 5px; + cursor: pointer; +} +.plsp-popup-ok:hover { + filter: brightness(1.08); +} +.plsp-palette { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 3px; + margin-bottom: 0; +} +/* ColorPickerPanel — 스와치 팔레트 + 고급 선택기 */ +.cpp-panel { + display: flex; + flex-direction: column; + gap: 0; +} +.cpp-quick-palette .cpp-palette-grid { + margin-bottom: 0; +} +.cpp-advanced-divider { + height: 1px; + background: var(--sep); + margin: 12px 0; +} +.plsp-popup .cpp-panel { + margin-bottom: 12px; +} +/* AdvancedColorPicker */ +.acp-root { + display: flex; + flex-direction: column; + gap: 10px; +} +.acp-sv-panel { + position: relative; + width: 100%; + height: 160px; + border-radius: 6px; + cursor: crosshair; + touch-action: none; + border: 1px solid var(--sep); +} +.acp-sv-pointer { + position: absolute; + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45), 0 1px 4px rgba(0, 0, 0, 0.35); + pointer-events: none; +} +.acp-controls-row { + display: flex; + align-items: center; + gap: 10px; +} +.acp-eyedropper { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--sep); + border-radius: 50%; + background: var(--bg2); + color: var(--text); + cursor: pointer; + padding: 0; +} +.acp-eyedropper:hover { + background: var(--bg3); +} +.acp-preview { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--sep); +} +.acp-hue-track { + position: relative; + flex: 1; + height: 14px; + border-radius: 7px; + cursor: pointer; + touch-action: none; + border: 1px solid rgba(255, 255, 255, 0.08); +} +.acp-hue-thumb { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); + pointer-events: none; + background: transparent; +} +.acp-rgb-row { + display: flex; + align-items: flex-end; + gap: 8px; +} +.acp-rgb-field { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 0; +} +.acp-hex-field { + flex: 3; +} +.acp-rgb-input { + width: 100%; + box-sizing: border-box; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 4px; + padding: 6px 8px; + font-size: 13px; + color: var(--text); + text-align: center; + outline: none; +} +.acp-rgb-input:focus { + border-color: var(--accent); +} +.acp-rgb-input::-webkit-outer-spin-button, +.acp-rgb-input::-webkit-inner-spin-button { + opacity: 1; +} +.acp-rgb-label { + font-size: 11px; + color: var(--text2); + font-weight: 600; +} +.acp-mode-toggle { + flex-shrink: 0; + width: 28px; + height: 32px; + border: none; + background: transparent; + color: var(--text2); + font-size: 12px; + cursor: pointer; + padding: 0; + align-self: center; +} +.acp-mode-toggle:hover { + color: var(--text); +} +.plsp-color-cell { + width: 100%; + aspect-ratio: 1; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 2px; + cursor: pointer; + padding: 0; +} +.plsp-color-cell:hover { + transform: scale(1.08); + z-index: 1; +} +.plsp-color-cell.selected { + outline: 2px solid var(--accent, #2196F3); + outline-offset: 1px; +} +.plsp-section { + margin-bottom: 10px; +} +.plsp-section:last-child { + margin-bottom: 0; +} +.plsp-section-label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text2); + margin-bottom: 6px; +} +.plsp-opacity-row { + display: flex; + align-items: center; + gap: 8px; +} +.plsp-opacity-slider { + flex: 1; + accent-color: var(--accent, #2196F3); +} +.plsp-opacity-pct { + font-size: 12px; + color: var(--text2); + min-width: 36px; + text-align: right; +} +.plsp-btn-group { + display: flex; + gap: 4px; +} +.plsp-icon-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 32px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg2); + color: var(--text); + cursor: pointer; + padding: 0 4px; +} +.plsp-icon-btn:hover { + background: var(--bg3); +} +.plsp-icon-btn.active { + background: var(--bg4); + border-color: var(--accent, #2196F3); + color: var(--accent, #2196F3); +} +/* 하단 버튼 */ +.ism-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-top: 1px solid var(--sep); + flex-shrink: 0; +} +.ism-footer-right { display: flex; gap: 8px; } +.ism-btn-defaults { + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 5px; + padding: 7px 14px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} +.ism-btn-defaults:hover { background: var(--bg3); } +.ism-btn-cancel { + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 5px; + padding: 7px 18px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} +.ism-btn-cancel:hover { background: var(--bg3); } +.ism-btn-ok { + background: var(--text); + border: 1px solid var(--text); + border-radius: 5px; + padding: 7px 22px; + font-size: 13px; + font-weight: 600; + color: var(--bg); + cursor: pointer; +} +.ism-btn-ok:hover { opacity: 0.85; } + +/* ── IndicatorBulkSettingsModal (보조지표 일괄 설정) ───────────────────── */ +.ibsm-overlay { + position: fixed; + inset: 0; + z-index: 5100; + background: transparent; + pointer-events: none; +} +.ibsm-dialog { + pointer-events: auto; + width: min(720px, 96vw); + max-height: 85vh; + display: flex; + flex-direction: column; + background: var(--bg); + border: 2px solid rgba(80, 100, 160, 0.45); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + overflow: hidden; +} +.ibsm-dialog-wide { + width: min(760px, 96vw); +} +.ibsm-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + cursor: grab; + user-select: none; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.ibsm-header:active { cursor: grabbing; } +.ibsm-header-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); +} +.ibsm-header-actions { display: flex; gap: 4px; } +.ibsm-icon-btn { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; +} +.ibsm-icon-btn:hover { background: var(--bg4); color: var(--text); } +.ibsm-main-tab-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px 0; + flex-shrink: 0; +} +.ibsm-main-tab { + font-size: 12px; + font-weight: 700; + color: var(--accent); + padding: 4px 10px; + border-radius: 4px; + background: rgba(122, 162, 247, 0.15); +} +.ibsm-main-tab-count { + font-size: 11px; + color: var(--text3); +} +.ibsm-hint { + font-size: 11px; + color: var(--text2); + padding: 8px 16px 0; + flex-shrink: 0; + line-height: 1.45; +} +/* 보조지표 설정 목록 검색 (일괄·설정 화면 공통) */ +.ind-settings-search-wrap { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} +.ibsm-search-block { + padding: 10px 14px 0; + border-bottom: 1px solid var(--sep); +} +.stg-ind-search-block { + margin-bottom: 10px; +} +.modal-search-input, +.ind-settings-search-input { + width: 100%; + box-sizing: border-box; + background: var(--bg3); + color: var(--text); + border: 1px solid var(--sep); + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + outline: none; +} +.modal-search-input:focus, +.ind-settings-search-input:focus { + border-color: var(--accent); +} +.ind-settings-search-meta { + font-size: 11px; + color: var(--text3); +} +.ind-settings-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.ind-settings-section + .ind-settings-section { + margin-top: 14px; + padding-top: 4px; + border-top: 1px solid var(--sep); +} +.ind-settings-section-title { + margin: 0 0 4px; + padding: 0 2px; + font-size: 12px; + font-weight: 700; + color: var(--text2); +} +.ind-settings-section-count { + font-weight: 600; + color: var(--text3); +} +.ind-settings-no-results { + padding: 24px 12px; + text-align: center; + font-size: 13px; + color: var(--text3); +} +.ibsm-main-row--active { + border-color: rgba(122, 162, 247, 0.4); +} +.ibsm-main-row-head { + flex-wrap: wrap; + gap: 8px; +} +.ibsm-main-row-info { + flex: 1; + min-width: 160px; + display: flex; + flex-direction: column; + gap: 2px; +} +.ibsm-main-row-ko { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.ibsm-main-row-en { + font-size: 11px; + color: var(--text3); +} +.ibsm-main-row-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.ibsm-main-badge { + font-size: 10px; + padding: 2px 8px; + border-radius: 3px; + background: var(--bg3); + color: var(--text3); +} +.ibsm-main-badge--on { + background: rgba(122, 162, 247, 0.2); + color: var(--accent); +} +.ibsm-main-row .ind-panel-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + background: rgba(41, 98, 255, 0.12); + color: var(--accent); +} +.ibsm-body { + flex: 1; + overflow-y: auto; + padding: 12px 14px 16px; +} +.ibsm-body::-webkit-scrollbar { width: 5px; } +.ibsm-body::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; } +.ibsm-empty { + text-align: center; + color: var(--text3); + font-size: 13px; + padding: 24px 8px; +} +.ibsm-card { + margin-bottom: 6px; + border: 1px solid var(--sep); + border-radius: 6px; + background: var(--bg2); + transition: border-color 0.12s, opacity 0.12s; +} +.ibsm-card-off { opacity: 0.65; } +.ibsm-card-drag-over { border-color: var(--accent); } +.ibsm-card-main { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + min-height: 48px; + flex-wrap: wrap; +} +.ibsm-drag { + cursor: grab; + color: var(--text3); + font-size: 14px; + user-select: none; + flex-shrink: 0; +} +.ibsm-drag:active { cursor: grabbing; } +.ibsm-name { + font-size: 13px; + font-weight: 700; + color: var(--text); + min-width: 72px; + flex-shrink: 0; +} +.ibsm-name-sub { + font-size: 11px; + color: var(--text3); + flex: 1; + min-width: 40px; +} +.ibsm-card-expanded { + border-color: var(--accent); +} +.ibsm-embedded-form { + padding: 0 !important; +} +.ibsm-embedded-form .ism-section { + padding: 0; +} +.ibsm-form-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} +.ibsm-form-defaults { + font-size: 11px; + padding: 4px 10px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg2); + color: var(--text2); + cursor: pointer; +} +.ibsm-form-defaults:hover { + color: var(--text); + border-color: var(--accent); +} +.ibsm-fields { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 120px; +} +.ibsm-field { + display: flex; + align-items: center; + gap: 4px; +} +.ibsm-field-label { + font-size: 11px; + color: var(--text2); + min-width: 28px; +} +.ibsm-mini-input { + background: var(--bg); + border: 1px solid var(--sep); + border-radius: 4px; + padding: 3px 6px; + font-size: 11px; + color: var(--text); + outline: none; +} +.ibsm-mini-num { width: 48px; text-align: center; } +.ibsm-color-swatch { + position: relative; + display: inline-block; + width: 22px; + height: 22px; + border-radius: 3px; + border: 1px solid var(--sep); + overflow: hidden; + cursor: pointer; +} +.ibsm-color-swatch span { + display: block; + width: 100%; + height: 100%; +} +.ibsm-color-swatch input[type="color"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} +.ibsm-width { + display: flex; + align-items: center; + gap: 6px; + min-width: 130px; + flex-shrink: 0; +} +.ibsm-width input[type="range"] { width: 72px; } +.ibsm-width span { font-size: 10px; color: var(--text2); } +.ibsm-line-preview { + display: inline-block; + width: 28px; + border-radius: 2px; + flex-shrink: 0; +} +.ibsm-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + flex-shrink: 0; +} +.ibsm-toggle input { opacity: 0; width: 0; height: 0; } +.ibsm-toggle .ism-toggle-slider { + position: absolute; + inset: 0; + background: var(--bg4); + border-radius: 10px; + transition: background 0.2s; +} +.ibsm-toggle .ism-toggle-slider::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 3px; + top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.ibsm-toggle input:checked + .ism-toggle-slider { + background: var(--accent); +} +.ibsm-toggle input:checked + .ism-toggle-slider::before { + transform: translateX(16px); +} +.ibsm-expand, .ibsm-expand-spacer { + width: 22px; + flex-shrink: 0; + text-align: center; +} +.ibsm-expand { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 10px; + padding: 2px; +} +.ibsm-card-expand { + padding: 10px 12px 12px; + border-top: 1px solid var(--sep); + background: var(--bg3); +} +.ibsm-hlines-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + color: var(--text2); +} +.ibsm-hlines-add { + font-size: 11px; + padding: 2px 8px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg2); + color: var(--text); + cursor: pointer; +} +.ibsm-hlines-empty { font-size: 11px; color: var(--text3); text-align: center; margin: 4px 0; } +.ibsm-hlines-table-head, +.ibsm-hlines-row { + display: grid; + grid-template-columns: 1.2fr 0.8fr 0.7fr 1fr 32px; + gap: 6px; + align-items: center; +} +.ibsm-hlines-table-head { + font-size: 10px; + font-weight: 700; + color: #fff; + background: var(--accent); + padding: 4px 6px; + border-radius: 4px; + margin-bottom: 4px; + opacity: 0.9; +} +.ibsm-hlines-row { + margin-bottom: 4px; + padding: 4px; + background: var(--bg2); + border: 1px solid var(--sep); + border-radius: 4px; +} +.ibsm-mini-select { + font-size: 10px; + padding: 3px 4px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} +.ibsm-hlines-del { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + opacity: 0.7; +} +.ibsm-hlines-del:hover { opacity: 1; } +.ibsm-detail-btn { + margin-top: 8px; + font-size: 12px; + color: var(--accent); + background: none; + border: none; + cursor: pointer; + text-decoration: underline; +} +.ibsm-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--sep); + flex-shrink: 0; +} + +/* ── MainChartSettingsModal ─────────────────────────────────────────────── */ +.mcs-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.55); + z-index: 3000; +} +.mcs-dialog { + background: var(--panel-bg, #1e222d); + border: 1px solid var(--border, #333); + border-radius: 12px; + width: 640px; + max-width: 96vw; + max-height: 80vh; + display: flex; flex-direction: column; + overflow: hidden; + box-shadow: 0 8px 40px rgba(0,0,0,.6); +} +.mcs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border, #2a2e39); + flex-shrink: 0; + cursor: grab; + user-select: none; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.mcs-header:active { cursor: grabbing; } +.mcs-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); +} +.mcs-close { + background: none; border: none; cursor: pointer; + color: var(--text-muted, #888); font-size: 18px; padding: 2px 6px; +} +.mcs-close:hover { color: var(--text); } + +.mcs-tabs { + display: flex; gap: 0; + border-bottom: 1px solid var(--border, #2a2e39); + padding: 0 22px; +} +.mcs-tab { + background: none; border: none; cursor: pointer; + padding: 10px 16px; + font-size: 14px; font-weight: 500; + color: var(--text-muted, #888); + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.mcs-tab.active { color: var(--accent, #2962ff); border-bottom-color: var(--accent, #2962ff); } +.mcs-tab:hover { color: var(--text); } + +.mcs-body { + flex: 1; overflow-y: auto; + padding: 20px 28px; + display: flex; flex-direction: column; gap: 4px; +} +.mcs-section-label { + font-size: 10px; font-weight: 700; letter-spacing: .08em; + color: var(--text-muted, #888); + text-transform: uppercase; + margin-bottom: 12px; margin-top: 4px; +} + +.mcs-row { + display: flex; align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border, #2a2e3922); +} +.mcs-row-label { + flex: 1; font-size: 14px; color: var(--text); +} +.mcs-row-colors { + display: flex; gap: 8px; +} + +/* 색상 스와치 */ +.mcs-swatch { + position: relative; cursor: pointer; +} +.mcs-swatch input[type=color] { + position: absolute; inset: 0; opacity: 0; cursor: pointer; + width: 100%; height: 100%; +} +.mcs-swatch span { + display: block; width: 38px; height: 28px; + border-radius: 5px; + border: 2px solid var(--border, #444); + transition: border-color .15s; +} +.mcs-swatch:hover span { border-color: var(--accent, #2962ff); } + +.mcs-footer { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 22px; + border-top: 1px solid var(--border, #2a2e39); +} +.mcs-footer-right { display: flex; gap: 10px; align-items: center; } +.mcs-btn-defaults { + background: var(--btn-bg, #2a2e3a); border: 1px solid var(--border, #444); + border-radius: 6px; padding: 7px 14px; + font-size: 13px; color: var(--text); cursor: pointer; +} +.mcs-btn-defaults:hover { background: var(--btn-hover, #333); } +.mcs-btn-cancel { + background: none; border: 1px solid var(--border, #555); + border-radius: 6px; padding: 7px 18px; + font-size: 13px; color: var(--text); cursor: pointer; +} +.mcs-btn-cancel:hover { background: var(--btn-bg, #2a2e3a); } +.mcs-btn-ok { + background: var(--text, #fff); + border: 1px solid var(--text, #fff); + border-radius: 6px; padding: 7px 22px; + font-size: 13px; font-weight: 600; + color: var(--bg, #131722); cursor: pointer; +} +.mcs-btn-ok:hover { opacity: .88; } + +/* ══════════════════════════════════════════════════════════════════ + Layout Picker · LayoutPicker.tsx + ══════════════════════════════════════════════════════════════════ */ + +/* 팝업 컨테이너 */ +.lp-popup { + background: var(--panel, #1e2130); + border: 1px solid var(--border, #2a2e3a); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,.55); + padding: 12px 0 8px; + width: 420px; + max-height: 80vh; + overflow-y: auto; + color: var(--text, #d1d4dc); + font-size: 13px; + user-select: none; +} + +/* 레이아웃 그리드 행 */ +.lp-grid-section { padding: 0 12px; } + +.lp-row { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 0; + border-bottom: 1px solid var(--border, #2a2e3a); +} +.lp-row:last-child { border-bottom: none; } + +.lp-row-num { + width: 20px; + text-align: center; + font-size: 11px; + color: var(--text-muted, #787b86); + flex-shrink: 0; +} + +.lp-row-icons { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +/* 레이아웃 아이콘 버튼 */ +.lp-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid transparent; + border-radius: 4px; + background: none; + color: var(--text, #d1d4dc); + cursor: pointer; + transition: background .12s, border-color .12s; +} +.lp-icon-btn:hover { + background: var(--btn-bg, rgba(255,255,255,.07)); + border-color: var(--border, #3a3e4a); +} +.lp-icon-btn.active { + background: var(--accent-muted, rgba(41,98,255,.22)); + border-color: var(--accent, #2962ff); + color: var(--accent, #2962ff); +} + +/* 구분선 */ +.lp-divider { + height: 1px; + background: var(--border, #2a2e3a); + margin: 6px 0; +} + +/* SYNC IN LAYOUT */ +.lp-sync-section { padding: 4px 16px 8px; } + +.lp-sync-label { + font-size: 10px; + font-weight: 600; + letter-spacing: .08em; + color: var(--text-muted, #787b86); + margin-bottom: 8px; + text-transform: uppercase; +} + +.lp-sync-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; +} + +.lp-sync-name { + font-size: 13px; + color: var(--text, #d1d4dc); +} + +/* 토글 스위치 */ +.lp-toggle { + position: relative; + width: 36px; + height: 20px; + border: none; + border-radius: 10px; + background: var(--btn-bg, #3a3e4a); + cursor: pointer; + transition: background .2s; + flex-shrink: 0; +} +.lp-toggle::after { + content: ''; + position: absolute; + top: 2px; left: 2px; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--text-muted, #787b86); + transition: transform .2s, background .2s; +} +.lp-toggle.on { + background: var(--accent, #2962ff); +} +.lp-toggle.on::after { + transform: translateX(16px); + background: #fff; +} + +/* ── 레이아웃 버튼 (툴바 오른쪽) ──────────────────────────────────── */ +.lp-trigger-btn { + position: relative; +} +.lp-trigger-btn.active { + color: var(--accent, #2962ff); +} + +/* ══════════════════════════════════════════════════════════════════ + Multi-Chart Grid Layout + ══════════════════════════════════════════════════════════════════ */ + +.multi-chart-grid { + display: grid; + flex: 1; + /* 높이를 명시해야 fr 행이 계산된다 */ + height: 100%; + min-height: 0; + min-width: 0; + gap: 2px; + background: var(--border, #2a2e3a); + overflow: hidden; +} + +.multi-slot-wrap { + position: relative; + overflow: hidden; + background: var(--bg, #131722); + min-width: 0; + min-height: 0; + /* grid stretch로 높이가 정해지므로 height 상속 허용 */ + display: flex; + flex-direction: column; +} + +/* 개별 차트 슬롯 */ +.chart-slot { + display: flex; + flex-direction: column; + width: 100%; + /* 부모 multi-slot-wrap (flex col)이 flex:1을 주므로 이 쪽도 flex:1 */ + flex: 1; + min-height: 0; + border: 2px solid transparent; + transition: border-color .15s; +} +.chart-slot.active { + border-color: var(--accent, #2962ff); +} + +/* 슬롯 미니 헤더 */ +.slot-header { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + background: var(--panel, #1e2130); + border-bottom: 1px solid var(--border, #2a2e3a); + flex-shrink: 0; + min-height: 30px; + flex-wrap: wrap; +} + +.slot-sym-btn { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: 1px solid transparent; + color: var(--text, #d1d4dc); + cursor: pointer; + font-size: 12px; + font-weight: 600; + padding: 3px 7px; + border-radius: 4px; + transition: background .12s, border-color .12s; + max-width: 160px; + overflow: hidden; +} +.slot-sym-btn:hover { + background: var(--btn-bg, rgba(255,255,255,.08)); + border-color: var(--border, #3a3e4a); +} +.slot-sym-btn.open { + background: var(--accent-muted, rgba(41,98,255,.15)); + border-color: var(--accent, #2962ff); + color: var(--accent, #2962ff); +} +.slot-sym-kr { + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.slot-sym-code { + font-size: 10px; + opacity: .55; + margin-left: 2px; + white-space: nowrap; + flex-shrink: 0; +} + +/* 타임프레임 버튼 */ +.slot-tf-row { + display: flex; + gap: 1px; +} +.slot-tf-btn { + background: none; + border: none; + color: var(--text-muted, #787b86); + font-size: 10px; + padding: 2px 4px; + border-radius: 2px; + cursor: pointer; +} +.slot-tf-btn:hover { color: var(--text, #d1d4dc); background: var(--btn-bg, rgba(255,255,255,.07)); } +.slot-tf-btn.active { color: var(--accent, #2962ff); font-weight: 700; } + +/* 현재가 */ +.slot-price { + font-size: 12px; + font-weight: 600; + margin-left: auto; +} +.slot-change { font-size: 10px; margin-left: 4px; } +.slot-loading { font-size: 10px; color: var(--text-muted, #787b86); } + +/* 차트 래퍼 (TradingChart가 flex로 차오르게) */ +.slot-chart-wrap { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} +/* TradingChart가 내부에서 tv-chart-wrap을 flex:1로 렌더하므로 이 div도 stretch */ +.slot-chart-wrap > div, +.slot-chart-wrap > .tv-chart-wrap { + flex: 1; + min-height: 0; +} + +/* ══════════════════════════════════════════════════════════════════ + Chart Hover Toolbar (차트 마우스오버 플로팅 버튼바) + ══════════════════════════════════════════════════════════════════ */ +.chart-hover-toolbar { + position: absolute; + bottom: 36px; /* BottomBar / X축 레이블 위 */ + left: 50%; + transform: translateX(-50%); + z-index: 200; + + display: flex; + align-items: center; + gap: 4px; + + background: var(--bg2, #1e2130); + border: 1px solid var(--border, #2a2e3a); + border-radius: 10px; + padding: 5px 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.45); + + /* 진입/이탈 애니메이션 */ + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} +/* 부모 .tv-chart-wrap 또는 .slot-chart-wrap 에 hover 시 표시 */ +.tv-chart-wrap:hover .chart-hover-toolbar, +.slot-chart-wrap:hover .chart-hover-toolbar { + opacity: 1; + pointer-events: auto; +} + +.cht-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 7px; + background: var(--bg, #131722); + color: var(--text, #c0caf5); + cursor: pointer; + transition: background 0.12s, color 0.12s, transform 0.1s; + flex-shrink: 0; +} +.cht-btn:hover { + background: var(--accent, #2962ff); + color: #fff; + transform: scale(1.08); +} +.cht-btn:active { + transform: scale(0.95); +} + +/* 라이트 테마 override */ +.app.light .chart-hover-toolbar { + background: #f5f5f5; + border-color: #d0d0d0; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); +} +.app.light .cht-btn { + background: #ffffff; + color: #212121; + border: 1px solid #e0e0e0; +} +.app.light .cht-btn:hover { + background: #2962ff; + color: #fff; + border-color: #2962ff; +} + +/* ─── Chart Magnifier (확대경) ──────────────────────────────────────────── */ +.chart-magnifier { + position: fixed; /* wrapper 밖으로도 자유롭게 이동 */ + z-index: 2000; + border-radius: 6px; + cursor: grab; + overflow: hidden; + box-shadow: + 0 4px 24px rgba(0,0,0,0.55), + 0 0 0 1px rgba(255,200,50,0.45); + background: #111218; + display: flex; + flex-direction: column; + /* pointer-events: all — 내부 drag/resize 가 동작하도록 */ + user-select: none; +} + +/* 헤더 (드래그 바) */ +.chart-magnifier-header { + height: 30px; + background: linear-gradient(90deg, #1a1d2e 0%, #1e2035 100%); + border-bottom: 1px solid rgba(255,200,50,0.3); + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + flex-shrink: 0; +} +.chart-magnifier:active { cursor: grabbing; } +/* 리사이즈 핸들 위에서는 grab 커서 대신 방향 커서 표시 */ +.chart-magnifier-handle { cursor: inherit; } + +.chart-magnifier-icon { + opacity: 0.75; + flex-shrink: 0; +} + +.chart-magnifier-title { + font-size: 11px; + font-weight: 600; + color: rgba(255,200,50,0.9); + letter-spacing: 0.03em; + flex: 1; +} + +/* LIVE 지시등 */ +.chart-magnifier-live { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + color: rgba(255,255,255,0.25); + background: rgba(255,255,255,0.07); + border-radius: 3px; + padding: 1px 4px; + flex-shrink: 0; + transition: color 0.1s, background 0.1s; +} +.chart-magnifier-live.live-on { + color: #00e676; + background: rgba(0, 230, 118, 0.15); + animation: magnifier-live-pulse 0.6s ease-out; +} +@keyframes magnifier-live-pulse { + 0% { box-shadow: 0 0 0 0 rgba(0,230,118,0.6); } + 60% { box-shadow: 0 0 0 5px rgba(0,230,118,0); } + 100% { box-shadow: 0 0 0 0 rgba(0,230,118,0); } +} + +/* 줌 컨트롤 (XY / Y 공통) */ +.chart-magnifier-zoom, +.chart-magnifier-yzoom { + display: flex; + align-items: center; + gap: 3px; +} +/* Y-Zoom 구분선 */ +.chart-magnifier-yzoom { + border-left: 1px solid rgba(255,255,255,0.12); + padding-left: 6px; +} +.chart-magnifier-zoom-btn { + width: 18px; + height: 18px; + border-radius: 3px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.75); + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.1s; +} +.chart-magnifier-zoom-btn:hover { background: rgba(255,200,50,0.25); color: #fff; } +/* Y-Zoom 버튼은 주황 계열 */ +.chart-magnifier-yzoom-btn { border-color: rgba(255,150,30,0.35); } +.chart-magnifier-yzoom-btn:hover { background: rgba(255,150,30,0.3); } +.chart-magnifier-zoom-val { + font-size: 11px; + font-weight: 600; + color: rgba(255,200,50,0.9); + min-width: 30px; + text-align: center; +} +.chart-magnifier-yzoom .chart-magnifier-zoom-val { + color: rgba(255,150,30,0.95); + min-width: 28px; +} + +/* 닫기 버튼 */ +.chart-magnifier-close { + width: 18px; + height: 18px; + border-radius: 3px; + border: none; + background: transparent; + color: rgba(255,255,255,0.5); + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.1s, color 0.1s; +} +.chart-magnifier-close:hover { background: rgba(220,60,60,0.5); color: #fff; } + +/* 확대 캔버스 */ +.chart-magnifier-canvas { + flex: 1; + display: block; + width: 100%; + height: 0; /* flex:1 로 채워짐 */ +} + +/* 리사이즈 핸들 */ +.chart-magnifier-handle { + background: rgba(255,200,50,0.6); + border-radius: 2px; + z-index: 10; +} +.chart-magnifier-handle:hover { + background: rgba(255,200,50,1); +} + +/* 라이트 테마 오버라이드 */ +.app.light .chart-magnifier { + background: #f0f2f5; + box-shadow: + 0 4px 24px rgba(0,0,0,0.22), + 0 0 0 1px rgba(41,98,255,0.35); +} +.app.light .chart-magnifier-header { + background: linear-gradient(90deg, #e8eaf0 0%, #eceef5 100%); + border-bottom-color: rgba(41,98,255,0.25); +} +.app.light .chart-magnifier-title { + color: rgba(41,98,255,0.9); +} +.app.light .chart-magnifier-zoom-val { + color: rgba(41,98,255,0.9); +} +.app.light .chart-magnifier-zoom-btn { + border-color: rgba(0,0,0,0.15); + background: rgba(0,0,0,0.04); + color: rgba(0,0,0,0.7); +} +.app.light .chart-magnifier-zoom-btn:hover { + background: rgba(41,98,255,0.2); +} +.app.light .chart-magnifier-live { color: rgba(0,0,0,0.3); background: rgba(0,0,0,0.06); } +.app.light .chart-magnifier-live.live-on { color: #00a854; background: rgba(0,168,84,0.12); } +.app.light .chart-magnifier-yzoom { border-left-color: rgba(0,0,0,0.12); } +.app.light .chart-magnifier-yzoom .chart-magnifier-zoom-val { color: rgba(200,90,0,0.9); } +.app.light .chart-magnifier-yzoom-btn { border-color: rgba(200,90,0,0.3); } +.app.light .chart-magnifier-yzoom-btn:hover { background: rgba(200,90,0,0.15); } + + +/* ══════════════════════════════════════════════════════════════════ + MARKET PANEL (좌측 접이식 마켓 패널) + ══════════════════════════════════════════════════════════════════ */ + +/* ── 패널 컨테이너 ─────────────────────────────────────────────── */ +.market-panel { + flex-shrink: 0; + width: 0; + overflow: hidden; + background: var(--bg2); + border-right: 1px solid var(--border); + transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} +.market-panel--open { + width: 268px; +} + +/* ── 패널 본문 (스크롤 포함) ──────────────────────────────────── */ +.mp-body { + display: flex; + flex-direction: column; + width: 268px; + min-width: 268px; + height: 100%; + overflow: hidden; + background: var(--bg2); +} + +/* ── 탭 ───────────────────────────────────────────────────────── */ +.mp-tabs { + display: flex; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg3); +} +.mp-tab { + flex: 1; + padding: 9px 0; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text3); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + letter-spacing: 0.2px; +} +.mp-tab:hover { color: var(--text); } +.mp-tab--active { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} + +/* ── 검색 ─────────────────────────────────────────────────────── */ +.mp-search-row { + padding: 7px 8px; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} +.mp-search-box { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 5px; + padding: 5px 8px; +} +.mp-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-size: 12px; + min-width: 0; +} +.mp-search-input::placeholder { color: var(--text3); } +.mp-search-clear { + background: transparent; + border: none; + color: var(--text3); + cursor: pointer; + font-size: 13px; + padding: 0 2px; + line-height: 1; +} +.mp-search-clear:hover { color: var(--text); } + +/* ── 컬럼 헤더 ────────────────────────────────────────────────── */ +.mp-list-header { + display: flex; + align-items: center; + padding: 5px 8px 5px 28px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg3); +} +.mp-h-name { flex: 1; } +.mp-h-price { width: 80px; text-align: right; } +.mp-h-change { width: 62px; text-align: right; padding-right: 4px; } + +/* 정렬 버튼 공통 */ +.mp-h-sort-btn { + display: inline-flex; + align-items: center; + gap: 2px; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 10px; + color: var(--text3); + letter-spacing: 0.3px; + white-space: nowrap; + user-select: none; + transition: color 0.15s; +} +.mp-h-sort-btn:hover { color: var(--text1); } +.mp-h-sort-btn--active { color: var(--accent, #2196f3); } +.mp-sort-icon { + font-size: 9px; + opacity: 0.6; + line-height: 1; +} +.mp-h-sort-btn--active .mp-sort-icon { opacity: 1; } + +/* ── 보유 추가 영역 ───────────────────────────────────────────── */ +.mp-hold-add-area { + padding: 6px 8px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + position: relative; +} +.mp-hold-add-btn { + width: 100%; + background: transparent; + border: 1px dashed var(--border); + border-radius: 4px; + color: var(--text3); + font-size: 11px; + padding: 5px; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; +} +.mp-hold-add-btn:hover { color: var(--accent); border-color: var(--accent); } + +.mp-hold-add-form { display: flex; gap: 6px; align-items: center; } +.mp-hold-add-input { + flex: 1; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + padding: 4px 8px; + outline: none; + min-width: 0; +} +.mp-hold-add-cancel { + background: transparent; + border: none; + color: var(--text3); + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} +.mp-hold-add-cancel:hover { color: var(--text); } + +.mp-hold-add-dropdown { + position: absolute; + top: calc(100% - 6px); + left: 8px; + right: 8px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 5px; + z-index: 100; + max-height: 200px; + overflow-y: auto; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); +} +.mp-hold-add-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 7px 10px; + font-size: 12px; + cursor: pointer; + color: var(--text); +} +.mp-hold-add-item:hover { background: var(--bg3); } +.mp-hold-add-code { color: var(--text3); font-size: 11px; } + +/* ── 목록 스크롤 영역 ─────────────────────────────────────────── */ +.mp-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} +.mp-list::-webkit-scrollbar { width: 4px; } +.mp-list::-webkit-scrollbar-track { background: transparent; } +.mp-list::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; } + +.mp-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 0; + color: var(--text3); + font-size: 11px; +} +.mp-empty { + text-align: center; + padding: 32px 12px; + color: var(--text3); + font-size: 12px; +} + +/* ── 개별 행 ──────────────────────────────────────────────────── */ +.mp-row { + display: flex; + align-items: center; + padding: 6px 6px 6px 0; + cursor: pointer; + border-bottom: 1px solid rgba(122,162,247,0.06); + transition: background 0.1s; + gap: 0; +} +.mp-row:hover { background: var(--bg3); } +.mp-row--active { background: rgba(122,162,247,0.1); } +.mp-row--active:hover { background: rgba(122,162,247,0.14); } + +/* 별 버튼 */ +.mp-star { + width: 24px; + min-width: 24px; + background: transparent; + border: none; + color: var(--text3); + font-size: 13px; + cursor: pointer; + padding: 0 2px; + line-height: 1; + transition: color 0.12s; + text-align: center; +} +.mp-star:hover { color: #f0b040; } +.mp-star--on { color: #f0b040; } + +/* 이름 컬럼 */ +.mp-name { + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + overflow: hidden; +} +.mp-korean { + font-size: 12px; + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mp-code { + font-size: 10px; + color: var(--text3); + white-space: nowrap; +} + +/* 가격 / 등락 */ +.mp-price { + width: 80px; + text-align: right; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.mp-change { + width: 58px; + text-align: right; + font-size: 11px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + padding-right: 4px; +} +.mp-up { color: var(--up); } +.mp-dn { color: var(--down); } + +/* 보유 토글 버튼 */ +.mp-hold-btn { + width: 30px; + min-width: 30px; + background: transparent; + border: none; + color: var(--text3); + font-size: 10px; + cursor: pointer; + padding: 0 2px; + white-space: nowrap; + transition: color 0.12s; +} +.mp-hold-btn:hover { color: var(--accent); } +.mp-hold-btn--on { color: var(--accent); font-weight: 700; } + +/* 보유 탭 제거 버튼 */ +.mp-hold-rm { + width: 22px; + min-width: 22px; + background: transparent; + border: none; + color: var(--text3); + font-size: 14px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: color 0.12s; +} +.mp-hold-rm:hover { color: var(--down); } + +/* ── 패널 접기/펼치기 핸들 (tv-main flex 자식, 항상 표시) ─────── */ +.mp-panel-handle { + /* flex 자식으로 배치 – 패널 바로 오른쪽 */ + align-self: center; + flex-shrink: 0; + position: relative; + z-index: 20; + width: 16px; + height: 56px; + /* 패널 경계에 완전히 붙도록 음수 마진으로 겹치기 */ + margin-left: -1px; + background: var(--bg2); + border: 1px solid var(--border); + border-left: none; + border-radius: 0 8px 8px 0; + color: var(--text3); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.35); + padding: 0; +} +.mp-panel-handle:hover { + background: var(--bg3); + color: var(--text); + box-shadow: 3px 0 14px rgba(0, 0, 0, 0.45); +} +.mp-panel-handle--open { + /* 패널 열렸을 때: 살짝 강조 */ + border-color: rgba(122, 162, 247, 0.3); +} + +/* ── 라이트 테마 오버라이드 ───────────────────────────────────── */ +.app.light .mp-tabs { background: #f0f0f0; } +.app.light .mp-list-header { background: #f5f5f5; } +.app.light .mp-search-box { background: #f5f5f5; } +.app.light .mp-row { border-bottom-color: rgba(0,0,0,0.05); } +.app.light .mp-row:hover { background: #f5f5f5; } +.app.light .mp-row--active { background: rgba(25,118,210,0.08); } +.app.light .mp-panel-handle { background: #fff; border-color: rgba(0,0,0,0.15); box-shadow: 2px 0 8px rgba(0,0,0,0.12); } + + +/* ══════════════════════════════════════════════════════════════════ + MARKET PANEL – 상단/하단 분할 레이아웃 + ══════════════════════════════════════════════════════════════════ */ + +/* 마켓 목록만 전체 높이 사용 */ +.mp-body--full { + display: flex; + flex-direction: column; + height: 100%; +} +.mp-body--full .mp-list { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +/* ══════════════════════════════════════════════════════════════════ + ORDERBOOK PANEL (실시간 호가창) + ══════════════════════════════════════════════════════════════════ */ + +.ob-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg2); + font-size: 11px; +} + +/* 헤더 */ +.ob-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 8px 4px; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--bg3); +} +.ob-header-title { + font-size: 12px; + font-weight: 600; + color: var(--text); +} +.ob-header-market { + color: var(--accent); + font-size: 11px; + margin-left: 4px; +} + +/* WS 연결 상태 점 */ +.ob-dot { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: var(--text3); +} +.ob-dot-circle { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text3); +} +.ob-dot--ok .ob-dot-circle { background: #22c55e; } +.ob-dot--ok { color: #22c55e; } +.ob-dot--wait .ob-dot-circle { background: #eab308; } +.ob-dot--wait { color: #eab308; } +.ob-dot--err .ob-dot-circle { background: #ef4444; } +.ob-dot--err { color: #ef4444; } + +/* ── 실시간 호가 (증권앱형 통합) ── */ +.ob-panel--classic { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg2); + font-size: 11px; +} + +.ob-quote-header { + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg3); +} +.ob-quote-name { + display: block; + font-size: 11px; + color: var(--text2); + margin-bottom: 4px; +} +.ob-quote-price-row { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.ob-quote-price { + font-size: 22px; + font-weight: 800; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.ob-quote-price--up { color: var(--up); } +.ob-quote-price--dn { color: var(--down); } +.ob-quote-change, +.ob-quote-rate { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.ob-quote-change--up, +.ob-quote-rate--up { color: var(--up); } +.ob-quote-change--dn, +.ob-quote-rate--dn { color: var(--down); } + +.ob-quote-right { + text-align: right; + flex-shrink: 0; +} +.ob-quote-vol, +.ob-quote-ratio { + margin-bottom: 4px; +} +.ob-quote-vol-label, +.ob-quote-ratio-label { + font-size: 9px; + color: var(--text3); + margin-right: 6px; +} +.ob-quote-vol-val, +.ob-quote-ratio-val { + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.ob-quote-ratio-val--up { color: var(--up); } + +.ob-subtabs { + flex-shrink: 0; + display: flex; + border-bottom: 1px solid var(--border); + background: var(--bg2); +} +.ob-subtab { + flex: 1; + padding: 8px 4px; + border: none; + background: transparent; + color: var(--text3); + font-size: 11px; + cursor: default; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.ob-subtab--active { + color: var(--text); + font-weight: 700; + border-bottom-color: var(--accent); + cursor: default; +} +.ob-subtab:disabled:not(.ob-subtab--active) { + opacity: 0.45; +} + +.ob-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ob-block { + flex: 1 1 50%; + min-height: 0; + display: flex; + overflow: hidden; +} +.ob-block--ask { + background: rgba(77, 171, 247, 0.1); +} +.ob-block--bid { + background: rgba(255, 107, 107, 0.08); +} +.ob-block-table { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ob-table-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} +.ob-table-scroll::-webkit-scrollbar { width: 4px; } +.ob-table-scroll::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; } +.ob-table-scroll--ask { + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.ob-table--hoga { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-variant-numeric: tabular-nums; +} +.ob-col-side { width: 32%; } +.ob-col-price { width: 36%; } + +.ob-table--mid { + flex-shrink: 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--bg2); +} + +.ob-tr-mid .ob-td-price--current { + border: 2px solid #1565c0; + text-align: center; + vertical-align: middle; + padding: 5px 4px; + background: var(--bg2); +} +.app.light .ob-tr-mid .ob-td-price--current { border-color: #1976d2; } +.ob-mid-price { + display: block; + font-size: 13px; + font-weight: 800; +} +.ob-mid-pct { + display: block; + font-size: 9px; + margin-top: 1px; +} +.ob-td-price--up .ob-mid-price { color: var(--up); } +.ob-td-price--dn .ob-mid-price { color: var(--down); } + +.ob-tr--clickable { cursor: pointer; } +.ob-tr--clickable:hover { background: rgba(255, 255, 255, 0.06); } + +.ob-td { + height: 21px; + padding: 0 3px; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: none; + font-size: 10px; +} +.ob-td--pad { padding: 0; } +.ob-td-empty { text-align: center; color: var(--text3); padding: 6px; } + +.ob-td-price { + text-align: center; + font-weight: 700; + font-size: 11px; + color: var(--text); +} +.ob-td-price-sm { + font-size: 9px; + font-weight: 500; + color: var(--text); + text-align: left; +} +.ob-td-pct { + text-align: right; + font-size: 9px; + color: var(--text3); + padding-right: 5px; +} +.ob-td-pct--left { + text-align: left; + padding-left: 5px; + padding-right: 0; +} +.ob-td-pct--up { color: var(--up); } +.ob-td-pct--dn { color: var(--down); } + +.ob-td-qty { position: relative; padding: 0; } +.ob-td-qty--ask .ob-td-bar-text { text-align: right; padding-right: 5px; } +.ob-td-qty--bid .ob-td-bar-text { text-align: left; padding-left: 5px; } +.ob-td-bar-wrap { + position: relative; + height: 21px; + display: flex; + align-items: center; +} +.ob-td-bar-wrap--right { justify-content: flex-end; } +.ob-td-bar { + position: absolute; + top: 0; + height: 100%; + opacity: 0.28; + pointer-events: none; + max-width: 92%; +} +.ob-td-bar--ask { left: 0; background: #4dabf7; } +.ob-td-bar--bid { right: 0; background: #ff6b6b; } +.ob-td-bar-text { + position: relative; + z-index: 1; + font-size: 10px; + color: var(--text); + width: 100%; +} + +/* 우측 시세 (매도 구간) */ +.ob-stats-rail { + flex-shrink: 0; + width: 72px; + padding: 6px 5px; + border-left: 1px solid var(--border); + background: rgba(0, 0, 0, 0.08); + font-size: 9px; + overflow-y: auto; +} +.ob-stat-row { + display: flex; + flex-direction: column; + margin-bottom: 8px; + gap: 1px; +} +.ob-stat-k { + color: var(--text3); + font-size: 8px; +} +.ob-stat-v { + font-weight: 700; + font-variant-numeric: tabular-nums; + font-size: 9px; + word-break: break-all; +} +.ob-stat-v--up { color: var(--up); } +.ob-stat-v--dn { color: var(--down); } + +/* 좌측 체결 (매수 구간) */ +.ob-trades-panel { + flex-shrink: 0; + width: 88px; + padding: 5px 4px; + border-right: 1px solid var(--border); + background: rgba(0, 0, 0, 0.06); + overflow: hidden; + display: flex; + flex-direction: column; +} +.ob-trades-strength { + margin-bottom: 5px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} +.ob-trades-strength-k { + display: block; + font-size: 8px; + color: var(--text3); +} +.ob-trades-strength-v { + font-size: 11px; + font-weight: 800; + color: var(--text); +} +.ob-trades-strength-v--up { color: var(--up); } +.ob-table--trades { + width: 100%; + border-collapse: collapse; + font-size: 9px; +} +.ob-table--trades thead th { + font-weight: 500; + color: var(--text3); + padding: 2px 0; + text-align: left; + font-size: 8px; +} +.ob-table--trades thead th:last-child { text-align: right; } +.ob-trades-vol--buy { color: var(--up); text-align: right; } +.ob-trades-vol--sell { color: var(--down); text-align: right; } + +/* 하단 합계 바 */ +.ob-summary-bar { + flex-shrink: 0; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + border-top: 1px solid var(--border); + min-height: 32px; +} +.ob-summary { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + font-variant-numeric: tabular-nums; +} +.ob-summary--ask { + background: rgba(77, 171, 247, 0.35); + justify-content: flex-start; + padding-left: 12px; +} +.ob-summary--bid { + background: rgba(255, 107, 107, 0.35); + justify-content: flex-end; + padding-right: 12px; +} +.ob-summary--time { + background: var(--bg3); + font-size: 11px; + font-weight: 600; + color: var(--text); + padding: 0 14px; + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); +} +.ob-summary-num { + font-size: 12px; + font-weight: 700; + color: var(--text); +} + +.app.light .ob-block--ask { background: #ebf2fa; } +.app.light .ob-block--bid { background: #fdf0f0; } +.app.light .ob-stats-rail, +.app.light .ob-trades-panel { background: rgba(255, 255, 255, 0.6); } +.app.light .ob-summary--ask { background: #bbdefb; } +.app.light .ob-summary--bid { background: #ffcdd2; } +.app.light .ob-panel--classic { background: #fff; } + +/* 로딩/빈 상태 */ +.ob-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 20px 0; + color: var(--text3); + font-size: 11px; + flex: 1; +} + +/* 푸터 총 잔량 */ +.ob-footer { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-top: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg3); +} +.ob-footer-ask { + font-size: 10px; + color: var(--up); + white-space: nowrap; +} +.ob-footer-bid { + font-size: 10px; + color: var(--down); + white-space: nowrap; +} +.ob-footer-bar { + flex: 1; + height: 4px; + background: rgba(77, 171, 247, 0.3); + border-radius: 2px; + overflow: hidden; +} +.ob-footer-ask-fill { + height: 100%; + background: rgba(255, 107, 107, 0.7); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* ── 라이트 테마 오버라이드 ───────────────────────────────────── */ +.app.light .ob-panel { background: #fff; } +.app.light .ob-header { background: #f5f5f5; } +.app.light .ob-col-header { background: #f5f5f5; } +.app.light .ob-spread-bar { background: #f0f0f0; } +.app.light .ob-footer { background: #f5f5f5; } + + +/* ══════════════════════════════════════════════════════════════════ + MARKET ROW – 실시간 가격 변동 플래시 (frontend_golden 동일 로직) + ══════════════════════════════════════════════════════════════════ */ + +/* 상승 플래시: 빨강 → 투명 (0.7s) */ +@keyframes mp-flash-rise { + 0% { background-color: rgba(255, 82, 82, 0.28); } + 100% { background-color: transparent; } +} + +/* 하락 플래시: 파랑 → 투명 (0.7s) */ +@keyframes mp-flash-fall { + 0% { background-color: rgba(59, 130, 246, 0.28); } + 100% { background-color: transparent; } +} + +.mp-row--flash-rise { + animation: mp-flash-rise 0.7s ease-out forwards; +} +.mp-row--flash-fall { + animation: mp-flash-fall 0.7s ease-out forwards; +} + +/* 보합(EVEN) 색상 */ +.mp-even { color: var(--text3); } + +/* 방향 지시자 삼각형 */ +.mp-arrow { + font-size: 8px; + line-height: 1; + flex-shrink: 0; + margin-right: 2px; +} +.mp-arrow--rise { color: var(--up); } +.mp-arrow--fall { color: var(--down); } +.mp-arrow--even { color: var(--text3); font-size: 10px; } + +/* 한글명 + 삼각형 가로 배치 */ +.mp-korean-row { + display: flex; + align-items: center; + gap: 1px; + overflow: hidden; +} +.mp-korean-row .mp-korean { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + chart-screen — 실시간 차트 화면 래퍼 + hidden 상태에서도 chart 인스턴스가 유지되도록 display:none 대신 + visibility+pointer-events 를 사용해 WebSocket 연결을 끊지 않는다. + ═══════════════════════════════════════════════════════════════════════════ */ +.chart-screen { + display: contents; /* flex/grid 레이아웃에 영향 없이 자식 직통 */ +} +/* WebSocket 훅은 React에서 관리되므로 DOM 숨김과 무관하게 유지됨 */ +.chart-screen--hidden { + display: none; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + StrategyPage — 투자전략 화면 + ═══════════════════════════════════════════════════════════════════════════ */ +.strat-page { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); + color: var(--text); +} + +/* ── 상단 요약 카드 바 ──────────────────────────────────────────────────── */ +.strat-summary-bar { + display: flex; + gap: 12px; + padding: 16px 20px 0; + flex-shrink: 0; +} +.strat-summary-card { + flex: 1; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.strat-summary-num { + font-size: 28px; + font-weight: 700; + color: var(--text); + line-height: 1; +} +.strat-summary-label { + font-size: 11px; + color: var(--text2); + letter-spacing: .04em; +} +.strat-summary-card--active { border-left: 3px solid var(--up); } +.strat-summary-card--paused { border-left: 3px solid var(--text3); } +.strat-summary-card--backtest { border-left: 3px solid var(--accent); } + +/* ── 바디 (사이드바 + 메인) ─────────────────────────────────────────────── */ +.strat-body { + flex: 1; + display: flex; + gap: 0; + overflow: hidden; + margin-top: 16px; +} + +/* ── 좌측 사이드바 ──────────────────────────────────────────────────────── */ +.strat-sidebar { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg2); + border-right: 1px solid var(--border); + overflow: hidden; +} +.strat-sidebar-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.strat-sidebar-title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.strat-new-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 5px; + font-size: 12px; + cursor: pointer; + font-weight: 500; + transition: background .15s; +} +.strat-new-btn:hover { background: var(--accent-h); } + +/* 필터 탭 */ +.strat-filter-tabs { + display: flex; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.strat-filter-tab { + flex: 1; + padding: 7px 4px; + font-size: 11px; + background: none; + border: none; + color: var(--text2); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color .15s, border-color .15s; +} +.strat-filter-tab:hover { color: var(--text); } +.strat-filter-tab--on { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* 목록 */ +.strat-list { + flex: 1; + overflow-y: auto; +} +.strat-list-empty { + padding: 20px; + text-align: center; + color: var(--text3); + font-size: 13px; +} +.strat-list-item { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background .12s; +} +.strat-list-item:hover { background: var(--bg3); } +.strat-list-item--selected { background: var(--bg3); } +.strat-item-row1 { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 4px; +} +.strat-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.strat-item-row2 { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.strat-item-symbol { font-size: 11px; color: var(--accent); font-weight: 500; } +.strat-item-date { font-size: 10px; color: var(--text3); } +.strat-item-actions { + display: flex; + gap: 4px; +} +.strat-item-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text2); + cursor: pointer; + transition: background .12s, color .12s; +} +.strat-item-btn:hover { background: var(--bg4); color: var(--text); } +.strat-item-btn--play:hover { color: var(--up); border-color: var(--up); } +.strat-item-btn--pause:hover { color: var(--text3); } +.strat-item-btn--delete:hover { color: var(--down); border-color: var(--down); } + +/* 상태 배지 */ +.strat-badge { + display: inline-block; + padding: 2px 7px; + border-radius: 20px; + font-size: 10px; + font-weight: 600; + letter-spacing: .03em; + white-space: nowrap; + flex-shrink: 0; +} +.strat-badge--active { background: rgba(var(--up-rgb,255,107,107), .15); color: var(--up); } +.strat-badge--paused { background: var(--bg4); color: var(--text3); } +.strat-badge--backtest { background: rgba(var(--accent-rgb,122,162,247), .15); color: var(--accent); } + +/* ── 우측 메인 영역 ──────────────────────────────────────────────────────── */ +.strat-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); +} + +/* 빈 상태 */ +.strat-empty-detail { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text3); +} +.strat-empty-title { font-size: 15px; font-weight: 500; color: var(--text2); margin: 0; } +.strat-empty-sub { font-size: 13px; color: var(--text3); margin: 0; } + +/* 전략 상세 */ +.strat-detail { + flex: 1; + display: flex; + flex-direction: column; + padding: 20px 24px; + overflow-y: auto; + gap: 0; +} +.strat-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 12px; +} +.strat-detail-title { + display: flex; + align-items: center; + gap: 10px; +} +.strat-detail-name { + font-size: 18px; + font-weight: 700; + color: var(--text); +} +.strat-detail-close { + background: none; + border: none; + color: var(--text3); + font-size: 15px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; + transition: background .12s, color .12s; +} +.strat-detail-close:hover { background: var(--bg3); color: var(--text); } + +.strat-detail-meta { + display: flex; + gap: 20px; + margin-bottom: 12px; +} +.strat-meta-item { display: flex; gap: 6px; align-items: center; } +.strat-meta-key { font-size: 11px; color: var(--text3); } +.strat-meta-val { font-size: 12px; color: var(--text); font-weight: 500; } + +.strat-detail-desc { + font-size: 13px; + color: var(--text2); + line-height: 1.6; + padding: 10px 14px; + background: var(--bg2); + border-left: 3px solid var(--accent); + border-radius: 0 5px 5px 0; + margin: 0 0 20px; +} + +/* 조건 영역 */ +.strat-detail-conditions { + display: flex; + gap: 16px; + margin-bottom: 20px; +} +.strat-cond-section { + flex: 1; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} +.strat-cond-section-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 600; + color: var(--text2); + margin-bottom: 2px; +} +.strat-cond-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.strat-cond-dot--buy { background: var(--up); } +.strat-cond-dot--sell { background: var(--down); } +.strat-cond-empty { + font-size: 12px; + color: var(--text3); + font-style: italic; +} +.strat-cond-card { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + background: var(--bg); + border-radius: 5px; + font-size: 12px; +} +.strat-cond-label { color: var(--text); font-weight: 500; } +.strat-cond-op { color: var(--text3); } +.strat-cond-value { color: var(--accent); font-weight: 500; margin-left: auto; } + +/* 액션 버튼 */ +.strat-detail-actions { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.strat-act-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); + background: var(--bg2); + color: var(--text); + transition: background .12s, border-color .12s; +} +.strat-act-btn:hover { background: var(--bg3); } +.strat-act-btn--primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.strat-act-btn--primary:hover { background: var(--accent-h); border-color: var(--accent-h); } +.strat-act-btn--test { color: var(--accent); border-color: var(--accent); } +.strat-act-btn--chart { color: var(--text2); } + + +/* ═══════════════════════════════════════════════════════════════════════════ + StrategyPage (sp-*) — frontend_golden 완전 재현, 좌우 반전 + Layout: [전략목록탭(L, 360px)] [편집기(C, 1fr)] [전략구성요소(R, 340px)] + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ── 페이지 래퍼 ──────────────────────────────────────────────────────────── */ +.sp-page { + flex: 1; + display: flex; + flex-direction: column; + height: calc(100vh - 44px); + overflow: hidden; + background: var(--bg); + color: var(--text); +} + +/* ── 상단 툴바 ────────────────────────────────────────────────────────────── */ +.sp-topbar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg2); + min-height: 44px; + flex-shrink: 0; +} +.sp-topbar-title { font-size: 15px; font-weight: 600; white-space: nowrap; } +.sp-topbar-chip { + font-size: 12px; padding: 2px 8px; + border: 1px solid var(--accent); border-radius: 99px; color: var(--accent); +} +.sp-topbar-spacer { flex: 1; } +.sp-tb { + padding: 5px 12px; font-size: 13px; border-radius: var(--radius); + border: 1px solid var(--border); background: transparent; color: var(--text); + cursor: pointer; white-space: nowrap; +} +.sp-tb:hover:not(:disabled) { background: var(--bg3); } +.sp-tb:disabled { opacity: .45; cursor: not-allowed; } +.sp-tb-icon { padding: 4px 8px; font-size: 14px; } +.sp-tb-save { + background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; +} +.sp-tb-save:hover:not(:disabled) { background: var(--accent-h); } + +/* ── 본문 3컬럼 ───────────────────────────────────────────────────────────── */ +.sp-body { + flex: 1; + display: grid; + grid-template-columns: 420px 1fr 390px; + overflow: hidden; +} + +/* ── 좌측 패널 (전략목록/상세보기/알림목록) ─────────────────────────────── */ +.sp-left { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + background: var(--bg2); + overflow: hidden; +} + +/* ── 패널 탭 (좌측 패널 + 우측 패널 공용) ──────────────────────────────── */ +.sp-panel-tabs { + display: flex; + border-bottom: 1px solid var(--border); + background: var(--bg3); + flex-shrink: 0; +} +.sp-panel-tab { + flex: 1; padding: 10px 4px; font-size: 13px; + background: transparent; border: none; border-bottom: 2px solid transparent; + color: var(--text2); cursor: pointer; +} +.sp-panel-tab--on { + color: var(--text); border-bottom-color: var(--accent); font-weight: 600; +} +.sp-panel-body { + flex: 1; overflow-y: auto; padding-bottom: 16px; +} + +/* ── 전략 목록 ────────────────────────────────────────────────────────────── */ +.sp-list-header { + display: flex; align-items: center; justify-content: space-between; + padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text2); +} +.sp-sort-sel { + background: var(--bg3); border: 1px solid var(--border); color: var(--text); + font-size: 11px; padding: 2px 4px; border-radius: var(--radius); +} +.sp-list-item { + display: flex; flex-direction: column; gap: 5px; + padding: 10px 14px; border-bottom: 1px solid var(--border); + cursor: pointer; user-select: none; +} +.sp-list-item:hover { background: var(--bg3); } +.sp-list-item--sel { background: rgba(122,162,247,.1); border-left: 2px solid var(--accent); } +.sp-li-row1 { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.sp-li-name { font-size: 14px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sp-li-chip { + font-size: 11px; padding: 2px 8px; border-radius: 99px; + background: var(--bg4); color: var(--text2); +} +.sp-li-chip--type { border: 1px solid var(--accent); color: var(--accent); background: transparent; } +.sp-li-chip--on { background: rgba(72,199,116,.2); color: #48c774; } +.sp-li-chip--off { background: var(--bg3); color: var(--text3); } +.sp-li-actions { display: flex; justify-content: flex-end; gap: 2px; } +.sp-li-btn { + background: none; border: none; color: var(--text2); + cursor: pointer; padding: 3px 6px; font-size: 14px; border-radius: var(--radius); +} +.sp-li-btn:hover { color: var(--text); background: var(--bg3); } +.sp-li-btn--del:hover { color: #ff6b6b; } + +/* ── 상세보기 / 미리보기 ──────────────────────────────────────────────────── */ +.sp-preview { padding: 12px; } +.sp-prev-sec { margin-bottom: 12px; } +.sp-prev-badge { + display: inline-block; font-size: 12px; padding: 3px 10px; + border-radius: 99px; margin-bottom: 8px; +} +.sp-prev-badge--buy { background: rgba(72,199,116,.2); color: #48c774; } +.sp-prev-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; } +.sp-prev-text { + background: var(--bg3); border: 1px solid var(--border); + padding: 12px; font-size: 13px; line-height: 1.6; + white-space: pre-wrap; word-break: break-word; border-radius: var(--radius); +} +.sp-prev-section-title { + font-size: 13px; font-weight: 600; color: var(--accent); + border-bottom: 2px solid var(--border); padding-bottom: 4px; margin-bottom: 10px; +} +.sp-prev-formula { font-size: 13px; font-family: monospace; line-height: 1.6; margin-bottom: 6px; } +.sp-prev-buy { color: #48c774; font-weight: 700; } +.sp-prev-sell { color: #ff6b6b; font-weight: 700; } +.sp-validation { margin-bottom: 8px; } +.sp-val-head { font-size: 12px; font-weight: 600; margin-bottom: 4px; } +.sp-val-ok { color: #48c774; font-size: 13px; padding: 5px 10px; background: rgba(72,199,116,.1); border-radius: var(--radius); } +.sp-val-err { color: #ff6b6b; font-size: 13px; padding: 2px 0; } +.sp-val-warn { color: #f7a140; font-size: 13px; padding: 2px 0; } + +/* ── 알림 목록 ────────────────────────────────────────────────────────────── */ +.sp-alert-item { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border-bottom: 1px solid var(--border); +} +.sp-alert-dot { + width: 8px; height: 8px; border-radius: 50%; + background: #48c774; flex-shrink: 0; +} +.sp-alert-name { flex: 1; font-size: 14px; } + +/* ── 빈 상태 안내 ─────────────────────────────────────────────────────────── */ +.sp-empty-info { + color: var(--text2); font-size: 14px; + padding: 18px 14px; line-height: 1.6; +} +.sp-loading { text-align: center; padding: 24px; color: var(--text2); font-size: 13px; } + +/* ── 중앙 편집기 ──────────────────────────────────────────────────────────── */ +.sp-editor { + display: flex; flex-direction: column; + border-left: 1px solid var(--border); border-right: 1px solid var(--border); + overflow: hidden; background: var(--bg2); +} + +/* 매수/매도 탭 + 우측 액션(저장·새로고침) */ +.sp-signal-tabs-bar { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--border); + background: var(--bg3); + flex-shrink: 0; +} +.sp-signal-tabs { + display: flex; + flex: 1; + min-width: 0; +} +.sp-signal-tab-actions { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + flex-shrink: 0; + border-left: 1px solid var(--border); +} +.sp-signal-tab { + flex: 1; padding: 11px 8px; font-size: 14px; + background: transparent; border: none; border-bottom: 2px solid transparent; + color: var(--text2); cursor: pointer; + display: flex; align-items: center; justify-content: center; gap: 4px; +} +.sp-signal-tab--buy { color: #ff6b6b; border-bottom-color: #ff6b6b; font-weight: 600; background: rgba(255,107,107,.06); } +.sp-signal-tab--sell { color: #4dabf7; border-bottom-color: #4dabf7; font-weight: 600; background: rgba(77,171,247,.06); } +.sp-signal-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.sp-signal-dot--buy { background: #48c774; } +.sp-signal-dot--sell { background: #ff6b6b; } + +/* 드롭존 */ +.sp-dropzone { + flex: 1; overflow-y: auto; padding: 12px; +} +.sp-drop-empty { + color: var(--text2); font-size: 14px; text-align: center; + margin-top: 48px; padding: 28px; + border: 2px dashed var(--border); border-radius: 8px; line-height: 1.6; +} + +/* ── 트리 노드 ────────────────────────────────────────────────────────────── */ +.sp-tree-node { + margin-bottom: 8px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg3); + transition: border-color .15s; +} +.sp-tree-node--over { + border-color: var(--accent) !important; + box-shadow: 0 0 0 2px rgba(122,162,247,.25); +} +.sp-node-head { + display: flex; align-items: center; gap: 8px; + padding: 9px 12px; + border-left: 3px solid transparent; + border-radius: var(--radius) var(--radius) 0 0; + background: rgba(0,0,0,.15); +} +.sp-node-badge { + font-size: 12px; font-weight: 700; padding: 3px 10px; + border-radius: 99px; color: #fff; white-space: nowrap; +} +.sp-node-label { + flex: 1; font-size: 13px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sp-node-del { + background: none; border: none; color: var(--text3); cursor: pointer; + font-size: 12px; padding: 2px 4px; border-radius: var(--radius); + line-height: 1; flex-shrink: 0; +} +.sp-node-del:hover { color: #ff6b6b; background: rgba(255,107,107,.1); } + +/* 자식 영역 */ +.sp-node-children { + padding: 8px 8px 8px 20px; + border-top: 1px solid var(--border); +} +.sp-drop-hint-inner { + border: 2px dashed var(--accent); border-radius: var(--radius); + padding: 8px 12px; font-size: 12px; color: var(--accent); text-align: center; + margin-top: 6px; +} + +/* ── 조건 인라인 편집기 ──────────────────────────────────────────────────── */ +.sp-cond-editor { padding: 10px 12px; } +.sp-cond-row4 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} +.sp-cond-field { display: flex; flex-direction: column; gap: 3px; } +.sp-cond-lbl { font-size: 12px; color: var(--text2); font-weight: 500; } +.sp-cond-sel { + font-size: 13px; padding: 5px 7px; + background: var(--bg2); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius); + appearance: none; cursor: pointer; +} +.sp-cond-sel:hover { border-color: var(--accent); } +.sp-cond-sel:focus { outline: none; border-color: var(--accent); } +.sp-cond-extra { margin-top: 6px; display: flex; align-items: center; gap: 8px; } +.sp-cond-num { + font-size: 12px; padding: 4px 8px; width: 120px; + background: var(--bg2); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius); +} +.sp-cond-num:focus { outline: none; border-color: var(--accent); } + +/* ── 우측 팔레트 ──────────────────────────────────────────────────────────── */ +.sp-palette { + display: flex; flex-direction: column; + background: var(--bg2); overflow: hidden; +} +.sp-pal-header { + font-size: 14px; font-weight: 600; + padding: 11px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.sp-pal-tabs { + display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.sp-pal-tab { + flex: 1; padding: 9px 4px; font-size: 13px; + background: transparent; border: none; border-bottom: 2px solid transparent; + color: var(--text2); cursor: pointer; +} +.sp-pal-tab--on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } +.sp-pal-body { flex: 1; overflow-y: auto; padding: 10px 10px 16px; } + +/* 팔레트 섹션 */ +.sp-pal-sec { margin-bottom: 12px; } +.sp-pal-sec-title { font-size: 12px; color: var(--text2); font-weight: 600; margin-bottom: 8px; } +.sp-pal-chips { display: flex; flex-wrap: wrap; gap: 5px; } + +/* 팔레트 칩 */ +.sp-chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 5px 12px; border-radius: 99px; font-size: 13px; font-weight: 500; + cursor: grab; user-select: none; border: 1px solid transparent; + transition: transform .1s, box-shadow .1s; +} +.sp-chip:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,.3); } +.sp-chip:active { cursor: grabbing; transform: scale(.96); } +.sp-chip--green { background: rgba(72,199,116,.2); border-color: rgba(72,199,116,.4); color: #48c774; } +.sp-chip--blue { background: rgba(77,171,247,.2); border-color: rgba(77,171,247,.4); color: #4dabf7; } +.sp-chip--orange { background: rgba(247,161,64,.2); border-color: rgba(247,161,64,.4); color: #f7a140; } +.sp-chip--primary { background: rgba(122,162,247,.15); border-color: rgba(122,162,247,.35); color: var(--accent); } +.sp-chip--ind { background: rgba(156,39,176,.15); border-color: rgba(156,39,176,.35); color: #ce93d8; } + +/* 자동설정 템플릿 */ +.sp-pal-auto { padding: 0; } +.sp-pal-auto-info { + font-size: 12px; color: var(--text2); padding: 10px; margin-bottom: 6px; + background: var(--bg3); border-radius: var(--radius); +} +.sp-pal-tmpl { + display: flex; align-items: center; gap: 8px; padding: 10px 8px; + border-bottom: 1px solid var(--border); cursor: pointer; + font-size: 13px; +} +.sp-pal-tmpl:hover { background: var(--bg3); } +.sp-tmpl-badge { + font-size: 11px; padding: 3px 8px; border-radius: 99px; white-space: nowrap; font-weight: 600; +} +.sp-tmpl-badge--buy { background: rgba(72,199,116,.2); color: #48c774; } +.sp-tmpl-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; } +.sp-tmpl-name { flex: 1; color: var(--text); } + +/* ── 모달 ─────────────────────────────────────────────────────────────────── */ +.sp-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + z-index: 1000; +} +.sp-modal { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + width: 440px; + max-width: calc(100vw - 32px); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 16px 48px rgba(0,0,0,0.5); +} +.sp-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + flex-shrink: 0; + cursor: grab; + user-select: none; + border-bottom: 1px solid var(--border); + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.sp-modal-header:active { cursor: grabbing; } +.sp-modal-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); + margin: 0; +} +.sp-modal-body { padding: 16px 20px 20px; } +.sp-modal-info { + font-size: 12px; color: var(--text2); background: var(--bg3); + padding: 8px; border-radius: var(--radius); margin-bottom: 10px; +} +.sp-modal-chips { display: flex; gap: 6px; margin-bottom: 12px; } +.sp-modal-chip { + font-size: 11px; padding: 2px 8px; border-radius: 99px; + border: 1px solid var(--border); color: var(--text2); +} +.sp-modal-chip--buy { border-color: #48c774; color: #48c774; } +.sp-modal-chip--sell { border-color: #ff6b6b; color: #ff6b6b; } +.sp-modal-lbl { display: block; font-size: 12px; color: var(--text2); margin: 10px 0 4px; } +.sp-modal-inp { + width: 100%; padding: 8px 10px; font-size: 13px; + background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); font-family: var(--font); +} +.sp-modal-inp:focus { outline: none; border-color: var(--accent); } +.sp-modal-ta { resize: vertical; min-height: 72px; } +.sp-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } +.sp-modal-btn { + padding: 7px 18px; font-size: 13px; border-radius: var(--radius); cursor: pointer; font-weight: 500; +} +.sp-modal-btn--cancel { background: transparent; border: 1px solid var(--border); color: var(--text); } +.sp-modal-btn--cancel:hover { background: var(--bg3); } +.sp-modal-btn--ok { background: var(--accent); border: none; color: #fff; } +.sp-modal-btn--ok:hover { background: var(--accent-h); } +.sp-modal-btn--ok:disabled { opacity: .5; cursor: not-allowed; } +.sp-modal-btn--del { background: #ff6b6b; border: none; color: #fff; } +.sp-modal-btn--del:hover { background: #e05555; } + +/* ── 스낵바 ───────────────────────────────────────────────────────────────── */ +.sp-snack { + position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%); + padding: 11px 22px; border-radius: 20px; font-size: 14px; font-weight: 500; + z-index: 2000; box-shadow: 0 4px 16px rgba(0,0,0,.4); + animation: sp-fadein .25s ease; +} +.sp-snack--ok { background: rgba(72,199,116,.9); color: #fff; } +.sp-snack--err { background: rgba(255,107,107,.9); color: #fff; } +@keyframes sp-fadein { + from { opacity:0; transform: translateX(-50%) translateY(12px); } + to { opacity:1; transform: translateX(-50%) translateY(0); } +} + +/* ── Light Theme 오버라이드 ──────────────────────────────────────────────── */ +.app.light .sp-node-head { background: rgba(0,0,0,.04); } +.app.light .sp-cond-sel { background: #fff; } +.app.light .sp-modal-inp { background: #fff; } +.app.light .sp-pal-tmpl:hover { background: #f0f0f0; } + +/* ══════════════════════════════════════════════════════════════════ + 백테스팅 패널 (BacktestPanel) + ══════════════════════════════════════════════════════════════════ */ +/* ══════════════════════════════════════════════ + 백테스팅 — 툴바 버튼 +══════════════════════════════════════════════ */ +.bt-toolbar-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text2); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background .12s, color .12s; +} +.bt-toolbar-btn:hover { background: var(--bg3); color: var(--text); } +.bt-toolbar-btn--active { + background: rgba(122,162,247,.18); + border-color: var(--accent); + color: var(--accent); +} + +/* ── 백테스팅 버튼 그룹 (백테스팅 버튼 + 설정 버튼 가로 배치) */ +.bt-group { + display: flex; + align-items: center; + gap: 2px; +} + +/* ── 백테스팅 버튼 + 드롭다운 래퍼 */ +.bt-drop-wrap { + position: relative; +} + +/* ── 전략 드롭다운 */ +.bt-strategy-drop { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 500; + min-width: 260px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 28px rgba(0,0,0,.45); + overflow: hidden; +} + +.bt-drop-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg3); + border-bottom: 1px solid var(--sep); + font-size: 11px; + font-weight: 700; + color: var(--text2); + text-transform: uppercase; + letter-spacing: .5px; +} + +.bt-drop-refresh { + background: transparent; + border: none; + color: var(--text2); + font-size: 14px; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} +.bt-drop-refresh:hover { color: var(--accent); } + +.bt-drop-empty { + padding: 16px 14px; + font-size: 12px; + color: var(--text3); + text-align: center; + line-height: 1.6; +} + +.bt-drop-list { + list-style: none; + margin: 0; + padding: 4px 0; + max-height: 280px; + overflow-y: auto; +} + +.bt-drop-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 12px; + gap: 8px; + transition: background .1s; +} +.bt-drop-item:hover { background: var(--bg3); } + +.bt-drop-name { + flex: 1; + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 5px; +} + +.bt-drop-local-badge { + font-size: 9px; + background: rgba(255,200,0,.2); + color: #FFC107; + border: 1px solid rgba(255,200,0,.3); + border-radius: 3px; + padding: 1px 4px; + flex-shrink: 0; +} + +.bt-drop-run { + flex-shrink: 0; + padding: 4px 12px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: opacity .12s; + white-space: nowrap; +} +.bt-drop-run:hover:not(:disabled) { opacity: .82; } +.bt-drop-run:disabled { opacity: .45; cursor: not-allowed; } +.bt-drop-run.running { background: var(--bg4); color: var(--text2); } + +/* ══════════════════════════════════════════════════════════════════ + 실시간 전략 체크 패널 (LiveStrategyPanel) +══════════════════════════════════════════════════════════════════ */ +.lsp-wrap { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 240px; + padding: 12px 14px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + color: var(--text); +} + +.lsp-header { + display: flex; + align-items: center; + gap: 6px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.lsp-icon { opacity: 0.75; flex-shrink: 0; color: #00BCD4; } +.lsp-title { font-weight: 700; font-size: 12.5px; flex: 1; } +.lsp-saving { font-size: 10px; color: var(--text3); } +.lsp-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: none; + border: none; + border-radius: 4px; + color: var(--text3); + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +} +.lsp-close-btn:hover { background: var(--bg3); color: var(--text); } + +.lsp-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.lsp-row--disabled { opacity: 0.45; pointer-events: none; } + +.lsp-label { color: var(--text2); white-space: nowrap; } + +/* 토글 (stg-toggle 재활용 패턴) */ +.lsp-toggle { position: relative; display: inline-flex; align-items: center; cursor: pointer; } +.lsp-toggle input { opacity: 0; width: 0; height: 0; position: absolute; } +.lsp-toggle-slider { + width: 34px; height: 18px; + background: var(--bg3); + border-radius: 9px; + transition: background 0.2s; + position: relative; +} +.lsp-toggle-slider::after { + content: ''; position: absolute; + width: 14px; height: 14px; + border-radius: 50%; + background: #fff; + top: 2px; left: 2px; + transition: transform 0.2s; +} +.lsp-toggle input:checked + .lsp-toggle-slider { background: #00BCD4; } +.lsp-toggle input:checked + .lsp-toggle-slider::after { transform: translateX(16px); } + +.lsp-select { + flex: 1; + padding: 3px 6px; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 11.5px; + cursor: pointer; + min-width: 0; +} + +.lsp-exec-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} +.lsp-radio-group { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 4px; +} +.lsp-radio { + display: flex; + align-items: flex-start; + gap: 6px; + cursor: pointer; + padding: 4px 6px; + border-radius: 5px; + transition: background 0.15s; +} +.lsp-radio:hover { background: var(--bg3); } +.lsp-radio input { margin-top: 2px; accent-color: #00BCD4; flex-shrink: 0; } +.lsp-radio-label { display: flex; flex-direction: column; gap: 1px; } +.lsp-radio-title { font-size: 11.5px; font-weight: 600; } +.lsp-radio-desc { font-size: 10px; color: var(--text3); } + +.lsp-monitor-stats { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text2); + margin-bottom: 6px; +} +.lsp-monitor-sep { opacity: 0.5; } +.lsp-monitor-on { color: #00BCD4; font-weight: 600; } +.lsp-monitor-warn { + font-size: 10.5px; + color: #ff9800; + line-height: 1.45; + margin-bottom: 8px; + padding: 6px 8px; + background: rgba(255, 152, 0, 0.08); + border: 1px solid rgba(255, 152, 0, 0.25); + border-radius: 4px; +} +.lsp-apply-all-btn { + display: block; + margin-top: 6px; + width: 100%; + padding: 5px 8px; + font-size: 11px; + font-weight: 600; + color: #fff; + background: #3f7ef5; + border: none; + border-radius: 4px; + cursor: pointer; +} +.lsp-apply-all-btn:hover:not(:disabled) { background: #5a92f7; } +.lsp-apply-all-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.lsp-monitor-hint { color: var(--text3); } +.lsp-monitor-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} +.lsp-monitor-chip { + font-size: 9.5px; + padding: 2px 6px; + border-radius: 3px; + background: rgba(0, 188, 212, 0.12); + color: #00BCD4; + border: 1px solid rgba(0, 188, 212, 0.25); +} +.stg-btn-secondary { + background: var(--bg3); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} +.stg-btn-secondary:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.stg-btn-secondary:disabled { opacity: 0.45; cursor: not-allowed; } + +.stg-sound-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} +.stg-sound-row .stg-select--wide { + min-width: 160px; + flex: 1; + max-width: 220px; +} +.stg-sound-row .stg-btn-secondary { + flex-shrink: 0; + font-size: 12px; + padding: 6px 12px; +} + +.lsp-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 10.5px; + color: var(--text3); + padding: 4px 6px; + background: var(--bg3); + border-radius: 4px; +} +.lsp-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--text3); + flex-shrink: 0; +} +.lsp-dot--on { + background: #00BCD4; + box-shadow: 0 0 5px rgba(0,188,212,0.6); + animation: lsp-pulse 1.5s ease-in-out infinite; +} +@keyframes lsp-pulse { + 0%,100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ══════════════════════════════════════════════ + 백테스팅 결과 통계 배지 (차트 위 오버레이) +══════════════════════════════════════════════ */ +.bt-result-badge { + position: absolute; + top: calc(var(--toolbar-h, 40px) + 8px); + left: 50%; + transform: translateX(-50%); + z-index: 200; + display: flex; + align-items: center; + gap: 10px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 20px; + padding: 5px 14px 5px 12px; + box-shadow: 0 4px 16px rgba(0,0,0,.35); + font-size: 12px; + color: var(--text); + white-space: nowrap; +} + +.bt-result-sep { + width: 1px; + height: 14px; + background: var(--sep); + flex-shrink: 0; +} + +.bt-result-row { + display: flex; + align-items: center; + gap: 4px; +} + +.bt-result-label { + font-size: 10px; + color: var(--text3); +} + +.bt-result-val { + font-size: 12px; + font-weight: 600; +} + +.bt-result-clear { + background: transparent; + border: none; + color: var(--text3); + font-size: 11px; + cursor: pointer; + padding: 1px 3px; + margin-left: 2px; + border-radius: 3px; + line-height: 1; +} +.bt-result-clear:hover { color: #EF5350; background: rgba(239,83,80,.1); } + +/* ══════════════════════════════════════════════ + 백테스팅 설정 버튼 (툴바) +══════════════════════════════════════════════ */ +.bt-settings-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text2); + cursor: pointer; + margin-left: 3px; + flex-shrink: 0; + transition: background .15s, color .15s, border-color .15s; +} +.bt-settings-btn:hover { + background: var(--bg3); + color: var(--text); + border-color: var(--text3); +} + +/* ══════════════════════════════════════════════ + 백테스팅 설정 모달 (드래그 이동 가능, 2열 레이아웃) +══════════════════════════════════════════════ */ +.bts-modal { + width: 800px; + max-width: 98vw; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 24px 64px rgba(0,0,0,.55); + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; +} + +/* 타이틀바 — 드래그 핸들 */ +.bts-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + cursor: grab; + user-select: none; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.bts-header:active { cursor: grabbing; } +.bts-title { + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); + flex: 1; +} +.bts-close { + background: transparent; + border: none; + color: var(--text3); + font-size: 13px; + cursor: pointer; + padding: 3px 6px; + border-radius: 4px; + flex-shrink: 0; +} +.bts-close:hover { background: rgba(239,83,80,.18); color: #EF5350; } + +/* ── 본문: 섹션들이 2열 마소리 배치 ── */ +.bts-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 1px; + overflow-y: auto; + max-height: calc(100vh - 200px); + background: var(--border); /* 열 구분선 */ + padding: 0; +} + +.bts-section { + background: var(--bg2); + padding: 6px 0 2px; +} + +.bts-section-title { + font-size: 10px; + font-weight: 700; + color: var(--text3); + padding: 4px 12px 3px; + letter-spacing: .5px; + text-transform: uppercase; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; +} + +/* 섹션 내부: 필드들을 1열로 */ +.bts-section-grid { + display: flex; + flex-direction: column; +} + +.bts-row { + display: flex; + align-items: center; + padding: 5px 12px; + gap: 6px; + cursor: pointer; + transition: background .1s; + border-left: 2px solid transparent; +} +.bts-row:hover { background: var(--bg3); } +.bts-row-focused { + background: rgba(33,150,243,.1); + border-left-color: #2196F3; +} + +.bts-row-label { + flex: 1; + font-size: 11.5px; + color: var(--text2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bts-row-ctrl { + display: flex; + align-items: center; + flex-shrink: 0; +} + +/* 입력 컨트롤 */ +.bts-input { + width: 90px; + padding: 3px 7px; + background: var(--bg1); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + text-align: right; +} +.bts-input:focus { outline: none; border-color: #2196F3; } + +.bts-pct-wrap { + display: flex; + align-items: center; + gap: 3px; +} +.bts-unit { + font-size: 11px; + color: var(--text3); +} + +.bts-select { + padding: 3px 6px; + background: var(--bg1); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 11.5px; + cursor: pointer; + max-width: 160px; +} +.bts-select:focus { outline: none; border-color: #2196F3; } + +.bts-toggle { + padding: 2px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text3); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all .15s; + min-width: 42px; + text-align: center; +} +.bts-toggle-on { + background: rgba(33,150,243,.2); + border-color: #2196F3; + color: #2196F3; +} + +/* ── 하단 설명 패널 ── */ +.bts-desc-panel { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 14px; + background: var(--bg1); + border-top: 1px solid var(--border); + min-height: 62px; + max-height: 110px; + overflow-y: auto; + flex-shrink: 0; +} +.bts-desc-label { + font-size: 11px; + font-weight: 700; + color: #2196F3; + white-space: nowrap; + flex-shrink: 0; + padding-top: 1px; +} +.bts-desc-sep { + font-size: 11px; + color: var(--text3); + flex-shrink: 0; + padding-top: 1px; +} +.bts-desc-text { + font-size: 11.5px; + color: var(--text2); + line-height: 1.65; + white-space: pre-wrap; + word-break: keep-all; +} +.bts-desc-placeholder { + font-size: 11.5px; + color: var(--text3); + font-style: italic; + padding-top: 1px; +} + +/* ── 푸터 ── */ +.bts-footer { + display: flex; + align-items: center; + padding: 8px 14px; + border-top: 1px solid var(--border); + gap: 8px; + flex-shrink: 0; +} +.bts-btn { + padding: 6px 16px; + border-radius: 6px; + border: 1px solid var(--border); + font-size: 12px; + cursor: pointer; + font-weight: 500; + transition: background .15s, border-color .15s; +} +.bts-btn-reset { background: var(--bg1); color: var(--text2); } +.bts-btn-reset:hover { background: var(--bg3); } +.bts-btn-cancel { background: var(--bg3); color: var(--text2); } +.bts-btn-cancel:hover { background: var(--bg4); } +.bts-btn-save { background: #2196F3; color: #fff; border-color: #2196F3; } +.bts-btn-save:hover:not(:disabled) { background: #1976D2; } +.bts-btn-save:disabled { opacity: .6; cursor: not-allowed; } + +/* ═══════════════════════════════════════════════════════════════════════════ + CSS 변수 — 대시보드 색상 토큰 + ─────────────────────────────────────────────────────────────────────────── */ +:root { + --brd-pos: #26c97e; + --brd-neg: #f04e5e; + --brd-warn: #ff9f43; + --brd-blue: #3d9fff; + --brd-purple:#a855f7; + --brd-teal: #14b8a6; + --brd-yellow:#fbbf24; + --brd-green: #22c55e; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + BacktestResultModal (.brm-*) — 모달 껍데기 + ─────────────────────────────────────────────────────────────────────────── */ + +.brm-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.55); + z-index: 9500; + backdrop-filter: blur(2px); +} +.brm-modal { + background: var(--bg1); + border: 1px solid var(--border); + border-radius: 14px; + width: 960px; + max-width: 96vw; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 70px rgba(0,0,0,.6); + overflow: hidden; + user-select: none; +} +.brm-modal--embedded { + width: 100%; max-width: 100%; max-height: 100%; height: 100%; + border-radius: 0; border: none; box-shadow: none; +} +.brm-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + cursor: grab; + user-select: none; + flex-shrink: 0; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.brm-header:active { cursor: grabbing; } +.brm-modal--embedded .brm-header { cursor: default; display: none; } +.brm-header-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; } +.brm-header-title { font-size: 14px; font-weight: 700; color: var(--down, #4dabf7); } +.brm-close { + background: none; border: none; color: var(--text3); font-size: 16px; + cursor: pointer; padding: 3px 8px; border-radius: 5px; transition: background .15s; +} +.brm-close:hover { background: rgba(240,78,94,.15); color: #f04e5e; } +.brm-body { flex: 1; overflow-y: auto; } + +/* ═══════════════════════════════════════════════════════════════════════════ + 대시보드 공통 (.brd-*) + ─────────────────────────────────────────────────────────────────────────── */ + +.brd-pos { color: var(--brd-pos) !important; } +.brd-neg { color: var(--brd-neg) !important; } +.brd-warn { color: var(--brd-warn) !important; } + +/* ── 대시보드 래퍼 ── */ +.brd-dashboard { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 16px 20px; + background: var(--bg1); + min-height: 100%; +} + +/* ── 대시보드 헤더 ── */ +.brd-dash-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: linear-gradient(135deg, rgba(61,159,255,.10) 0%, rgba(168,85,247,.06) 100%); + border: 1px solid rgba(61,159,255,.2); + border-radius: 12px; + padding: 14px 18px; +} +.brd-dash-header-left { display: flex; flex-direction: column; gap: 6px; } +.brd-dash-strategy { font-size: 16px; font-weight: 800; color: var(--text); letter-spacing: -.3px; } +.brd-dash-meta { display: flex; flex-wrap: wrap; gap: 5px; } +.brd-dash-badge { + display: inline-flex; align-items: center; + padding: 2px 9px; border-radius: 10px; + font-size: 11px; font-weight: 500; + background: rgba(61,159,255,.12); color: var(--brd-blue); + border: 1px solid rgba(61,159,255,.25); +} +.brd-dash-badge--time { + background: rgba(128,128,128,.1); color: var(--text3); border-color: var(--border); +} + +.brd-dash-header-kpi { display: flex; gap: 28px; } +.brd-dash-big-kpi { text-align: right; } +.brd-dash-big-val { font-size: 26px; font-weight: 900; letter-spacing: -1px; line-height: 1; } +.brd-dash-big-label { font-size: 10.5px; color: var(--text3); margin-top: 3px; } + +/* ── 행(Row) ── */ +.brd-row { display: flex; flex-direction: column; gap: 10px; } +.brd-row--3col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; } +.brd-row--2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +@media (max-width: 820px) { + .brd-row--3col { grid-template-columns: 1fr 1fr; } + .brd-row--2col { grid-template-columns: 1fr; } +} +@media (max-width: 560px) { + .brd-row--3col { grid-template-columns: 1fr; } +} + +/* ── KPI 그리드 (6개) ── */ +.brd-kpi-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; +} +@media (max-width: 900px) { .brd-kpi-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 560px) { .brd-kpi-grid { grid-template-columns: repeat(2, 1fr); } } + +.brd-kpi-card { + position: relative; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + overflow: hidden; + transition: transform .15s, box-shadow .15s; +} +.brd-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,.25); +} +.brd-kpi-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + border-radius: 10px 10px 0 0; +} +.brd-kpi-card--blue::before { background: var(--brd-blue); } +.brd-kpi-card--red::before { background: var(--brd-neg); } +.brd-kpi-card--yellow::before { background: var(--brd-yellow); } +.brd-kpi-card--purple::before { background: var(--brd-purple); } +.brd-kpi-card--green::before { background: var(--brd-green); } +.brd-kpi-card--teal::before { background: var(--brd-teal); } + +.brd-kpi-top { display: flex; align-items: center; gap: 5px; margin-bottom: 5px; } +.brd-kpi-icon { font-size: 14px; } +.brd-kpi-label { font-size: 10px; color: var(--text3); font-weight: 500; } +.brd-kpi-value { font-size: 18px; font-weight: 800; color: var(--text); line-height: 1; margin-bottom: 3px; } +.brd-kpi-sub { font-size: 9.5px; color: var(--text3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── 카드 공통 ── */ +.brd-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.brd-card--full { width: 100%; } + +.brd-section-title { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px 7px; + background: var(--bg3); + border-bottom: 1px solid var(--border); + font-size: 11.5px; + font-weight: 700; + color: var(--text2); +} +.brd-section-icon { font-size: 13px; } +.brd-section-text { flex: 1; } +.brd-section-right { margin-left: auto; } +.brd-section-count { + font-size: 10.5px; font-weight: 400; color: var(--text3); + background: var(--bg4); padding: 1px 8px; border-radius: 8px; +} +.brd-badge-inline { + font-size: 10.5px; font-weight: 600; + background: var(--bg4); padding: 1px 8px; border-radius: 8px; +} + +.brd-card-body { padding: 8px 12px 10px; } +.brd-card-body--center { + display: flex; align-items: flex-start; gap: 12px; padding: 10px 12px; +} + +/* ── MetricRow ── */ +.brd-metric-row { + display: flex; align-items: center; justify-content: space-between; + padding: 4.5px 0; + border-bottom: 1px solid rgba(128,128,128,.06); +} +.brd-metric-row:last-child { border-bottom: none; } +.brd-metric-label { + font-size: 11.5px; color: var(--text3); + display: flex; flex-direction: column; gap: 1px; +} +.brd-metric-note { font-size: 9px; color: var(--text3); opacity: .7; } +.brd-metric-value { font-size: 12px; font-weight: 600; color: var(--text); } + +/* ── 승률 도넛 ── */ +.brd-donut-wrap { display: flex; flex-direction: column; align-items: center; gap: 5px; flex-shrink: 0; } +.brd-donut-detail { display: flex; gap: 8px; font-size: 10.5px; font-weight: 600; } +.brd-trade-stats { flex: 1; } + +/* ── 벤치마크 비교 바 ── */ +.brd-compare { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; } +.brd-compare-row { display: flex; align-items: center; gap: 8px; } +.brd-compare-label{ font-size: 11px; color: var(--text3); width: 70px; flex-shrink: 0; } +.brd-compare-track{ + flex: 1; height: 8px; background: var(--bg3); + border-radius: 4px; overflow: hidden; +} +.brd-compare-bar { height: 100%; border-radius: 4px; transition: width .5s ease; } +.brd-compare-val { font-size: 12px; font-weight: 700; width: 60px; text-align: right; flex-shrink: 0; } + +.brd-bench-summary{ + display: flex; align-items: center; + gap: 0; background: var(--bg3); + border-radius: 8px; overflow: hidden; + margin-top: 6px; +} +.brd-bench-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 8px 6px; gap: 2px; } +.brd-bench-label { font-size: 9.5px; color: var(--text3); } +.brd-bench-val { font-size: 14px; font-weight: 800; } +.brd-bench-divider{ width: 1px; background: var(--border); align-self: stretch; } + +/* ── 손익 분석 바 ── */ +.brd-pnl-bar-section { margin-bottom: 8px; display: flex; flex-direction: column; gap: 7px; } +.brd-pnl-row { display: flex; align-items: center; gap: 8px; } +.brd-pnl-label{ font-size: 11px; font-weight: 600; width: 44px; flex-shrink: 0; } +.brd-pnl-track{ + flex: 1; height: 10px; background: var(--bg3); + border-radius: 5px; overflow: hidden; +} +.brd-pnl-bar { height: 100%; border-radius: 5px; transition: width .5s; } +.brd-pnl-bar--pos { background: var(--brd-pos); } +.brd-pnl-bar--neg { background: var(--brd-neg); } +.brd-pnl-val { font-size: 11.5px; font-weight: 700; width: 64px; text-align: right; flex-shrink: 0; } +.brd-pnl-net { + display: flex; justify-content: space-between; align-items: center; + padding: 6px 10px; + background: var(--bg3); border-radius: 6px; + margin-bottom: 8px; +} +.brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); } +.brd-pnl-net-val { font-size: 15px; font-weight: 800; } + +/* ── 시그널 테이블 ── */ +.brd-sig-scroll { max-height: 200px; overflow-y: auto; } +.brd-sig-table { width: 100%; border-collapse: collapse; font-size: 11.5px; } +.brd-sig-table th { + position: sticky; top: 0; + padding: 6px 10px; + background: var(--bg3); color: var(--text3); + font-size: 10.5px; font-weight: 600; + text-align: left; border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.brd-sig-table td { + padding: 4.5px 10px; color: var(--text); + border-bottom: 1px solid rgba(128,128,128,.06); +} +.brd-sig-table tr:last-child td { border-bottom: none; } +.brd-sig-even td { background: rgba(128,128,128,.025); } +.brd-sig-table tr:hover td { background: rgba(61,159,255,.04); } + +.brd-sig-badge { + display: inline-flex; align-items: center; + padding: 1px 8px; border-radius: 8px; + font-size: 10px; font-weight: 600; +} +.brd-sig-badge.brd-pos { background: rgba(38,201,126,.12); } +.brd-sig-badge.brd-neg { background: rgba(240,78,94,.12); } +.brd-sig-badge.brd-warn{ background: rgba(255,159,67,.12); } + +.brd-expand-btn { + display: block; width: 100%; + padding: 7px; background: var(--bg3); + border: none; border-top: 1px solid var(--border); + color: var(--text3); font-size: 11.5px; cursor: pointer; + transition: background .15s, color .15s; +} +.brd-expand-btn:hover { background: var(--bg4); color: var(--text); } + +.brd-empty-msg { + text-align: center; color: var(--text3); padding: 24px; font-size: 12px; +} +.brd-empty-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; height: 200px; color: var(--text3); font-size: 13px; +} +.brd-empty-state div { font-size: 40px; } + +/* ── 결과보기 버튼 강조 ── */ +.bt-settings-btn--has-result { + color: #3d9fff !important; + border-color: #3d9fff !important; + background: rgba(61,159,255,.1) !important; +} + +/* ── 매수/매도 마커 삭제 버튼 ── */ +.bt-settings-btn--clear { + color: #ef9a9a; + border-color: rgba(239,83,80,.4); +} +.bt-settings-btn--clear:hover { + color: #ef5350 !important; + border-color: #ef5350 !important; + background: rgba(239,83,80,.12) !important; +} + +/* ── 실시간 전략 체크 토글 버튼 활성 ── */ +.bt-settings-btn--live-on { + color: #00BCD4 !important; + border-color: #00BCD4 !important; + background: rgba(0,188,212,.12) !important; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + BacktestHistoryPage (.bh-*) + ─────────────────────────────────────────────────────────────────────────── */ + +.bh-page { + display: flex; + height: calc(100vh - var(--tmb-h, 44px)); + overflow: hidden; + background: var(--bg1); +} + +/* ── 좌측 사이드바 ── */ +.bh-sidebar { + width: 272px; + min-width: 220px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg2); + flex-shrink: 0; +} + +.bh-sidebar-header { + padding: 10px 12px 8px; + background: var(--bg3); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.bh-sidebar-title-row { + display: flex; align-items: center; gap: 6px; margin-bottom: 7px; +} +.bh-sidebar-title { + font-size: 13px; font-weight: 700; color: var(--text); +} +.bh-sidebar-count { + font-size: 10.5px; font-weight: 600; + background: rgba(61,159,255,.15); color: var(--brd-blue); + padding: 1px 7px; border-radius: 8px; +} + +.bh-sidebar-actions { display: flex; gap: 5px; } +.bh-act-btn { + position: relative; + display: flex; align-items: center; justify-content: center; + gap: 4px; padding: 5px 9px; + background: var(--bg4); border: 1px solid var(--border); + border-radius: 6px; color: var(--text2); font-size: 11.5px; + cursor: pointer; transition: all .15s; +} +.bh-act-btn:hover:not(:disabled) { background: var(--bg1); color: var(--text); } +.bh-act-btn:disabled { opacity: .35; cursor: not-allowed; } +.bh-act-btn--danger:hover:not(:disabled) { background: rgba(240,78,94,.12); color: var(--brd-neg); border-color: var(--brd-neg); } +.bh-act-badge { + position: absolute; top: -4px; right: -4px; + background: var(--brd-neg); color: #fff; + font-size: 9px; font-weight: 700; + min-width: 15px; height: 15px; + border-radius: 8px; display: flex; align-items: center; justify-content: center; + padding: 0 3px; +} + +/* ── 전체 선택 바 ── */ +.bh-select-all-bar { + display: flex; align-items: center; gap: 8px; + padding: 6px 12px; + background: var(--bg3); border-bottom: 1px solid var(--border); + cursor: pointer; font-size: 11.5px; color: var(--text3); + transition: background .12s; + flex-shrink: 0; +} +.bh-select-all-bar:hover { background: var(--bg4); color: var(--text); } +.bh-select-all-label { font-size: 11px; } + +/* ── 체크박스 ── */ +.bh-cb { + width: 14px; height: 14px; flex-shrink: 0; + border: 1.5px solid var(--border); + border-radius: 3px; + background: var(--bg2); + display: flex; align-items: center; justify-content: center; + transition: all .12s; +} +.bh-cb--on { + background: var(--brd-blue); + border-color: var(--brd-blue); + color: #fff; +} +.bh-cb--indeterminate { + background: var(--brd-blue); + border-color: var(--brd-blue); +} +.bh-cb-dash { + width: 8px; height: 2px; + background: #fff; border-radius: 1px; +} + +/* ── 목록 ── */ +.bh-list { + flex: 1; overflow-y: auto; +} + +/* ── 아이템 ── */ +.bh-item { + display: flex; align-items: flex-start; + gap: 8px; padding: 9px 12px; + border-bottom: 1px solid var(--border); + cursor: pointer; transition: background .12s; +} +.bh-item:hover { background: var(--bg3); } +.bh-item--active { + background: rgba(61,159,255,.08) !important; + border-left: 3px solid var(--brd-blue); + padding-left: 9px; +} +.bh-item--checked { background: rgba(61,159,255,.05); } + +.bh-item-cb-wrap { + padding-top: 2px; flex-shrink: 0; +} + +.bh-item-body { flex: 1; min-width: 0; } +.bh-item-row1 { + display: flex; align-items: center; justify-content: space-between; + gap: 6px; margin-bottom: 3px; +} +.bh-item-name { + font-size: 12.5px; font-weight: 600; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; +} +.bh-item-ret { font-size: 13px; font-weight: 800; flex-shrink: 0; } + +.bh-item-row2 { + display: flex; align-items: center; gap: 3px; + font-size: 10.5px; color: var(--text3); margin-bottom: 2px; +} +.bh-item-sep { opacity: .4; } + +.bh-item-row3 { + display: flex; align-items: center; gap: 6px; + font-size: 10px; color: var(--text3); +} +.bh-item-wr { + margin-left: auto; + font-size: 9.5px; font-weight: 600; + background: rgba(61,159,255,.1); + color: var(--brd-blue); + padding: 1px 6px; border-radius: 6px; +} + +.bh-pos { color: var(--brd-pos); } +.bh-neg { color: var(--brd-neg); } + +/* ── 로딩 ── */ +.bh-loading, .bh-empty-list { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; + color: var(--text3); font-size: 13px; + gap: 8px; padding: 24px; text-align: center; +} +.bh-loading-spinner { + width: 24px; height: 24px; + border: 2px solid var(--border); + border-top-color: var(--brd-blue); + border-radius: 50%; + animation: bh-spin .7s linear infinite; +} +@keyframes bh-spin { to { transform: rotate(360deg); } } +.bh-empty-icon { font-size: 40px; opacity: .5; } +.bh-empty-sub { font-size: 11.5px; opacity: .7; line-height: 1.6; } + +/* ── 우측 ── */ +.bh-content { + flex: 1; overflow: hidden; + display: flex; flex-direction: column; +} +.bh-detail { + flex: 1; overflow-y: auto; +} +.bh-no-selection { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; + color: var(--text3); gap: 12px; +} +.bh-no-sel-icon { font-size: 56px; opacity: .3; } + +/* ══════════════════════════════════════════════════════════════════ + SETTINGS PAGE — 카드 2분할 레이아웃 + ══════════════════════════════════════════════════════════════════ */ + +/* ── 페이지 배경 ── */ +.stg-page { + display: flex; + height: calc(100vh - var(--tmb-h, 44px)); + overflow: hidden; + background: var(--bg); /* 바깥 배경 */ + color: var(--text); + padding: 20px; + gap: 16px; + box-sizing: border-box; +} + +/* ════════ 좌측 카드 — 카테고리 목록 ════════ */ +.stg-sidebar { + width: 210px; + min-width: 180px; + flex-shrink: 0; + /* 카드 박스 */ + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 2px 12px rgba(0,0,0,0.18); + display: flex; + flex-direction: column; + overflow: hidden; +} +.stg-sidebar-title { + font-size: 16px; + font-weight: 700; + color: var(--text); + padding: 20px 18px 14px; + border-bottom: 1px solid var(--border); + letter-spacing: -0.3px; + flex-shrink: 0; +} +.stg-sidebar-nav { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 8px 10px; +} +.stg-sidebar-item { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 12px; + border-radius: 9px; + border: none; + background: transparent; + color: var(--text2); + cursor: pointer; + text-align: left; + transition: background 0.14s, color 0.14s; + font-size: 13.5px; + width: 100%; +} +.stg-sidebar-item:hover { + background: var(--bg3); + color: var(--text); +} +.stg-sidebar-item--active { + background: rgba(var(--accent-rgb, 122, 162, 247), 0.15); + color: var(--accent); + font-weight: 600; +} +.app.light .stg-sidebar-item--active { + background: rgba(25, 118, 210, 0.09); + color: #1565C0; +} +.stg-sidebar-icon { + display: flex; + align-items: center; + opacity: 0.75; + flex-shrink: 0; +} +.stg-sidebar-item--active .stg-sidebar-icon { opacity: 1; } +.stg-sidebar-item--active .stg-sidebar-label { letter-spacing: -0.1px; } + +/* ════════ 우측 카드 — 상세 설정 ════════ */ +.stg-content { + flex: 1; + min-width: 0; + /* 카드 박스 */ + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 2px 12px rgba(0,0,0,0.18); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 카드 헤더 */ +.stg-content-header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg2); + flex-shrink: 0; +} +.stg-content-icon { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 10px; + background: rgba(var(--accent-rgb, 122, 162, 247), 0.13); + color: var(--accent); + flex-shrink: 0; +} +.stg-content-title { + font-size: 17px; + font-weight: 700; + color: var(--text); + margin: 0 0 2px; + letter-spacing: -0.3px; +} +.stg-content-desc { + font-size: 12px; + color: var(--text3); + margin: 0; +} + +/* 스크롤 바디 */ +.stg-content-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 18px 24px 24px; + display: flex; + flex-direction: column; + gap: 16px; + background: var(--bg); /* 섹션들이 놓이는 배경 */ +} + +/* ── 섹션 (설정 그룹) ── */ +.stg-section { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + overflow: visible; +} +.stg-section-title { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.9px; + color: var(--text3); + padding: 9px 16px 7px; + border-bottom: 1px solid var(--border); + background: var(--bg3); +} +.stg-section-body { + display: flex; + flex-direction: column; +} +.stg-section-body--grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 12px 14px 14px; +} +@media (max-width: 720px) { + .stg-section-body--grid { grid-template-columns: 1fr; } +} + +.stg-section-hint { + display: flex; + align-items: center; + gap: 8px; + margin: 0 14px 12px; + padding: 9px 12px; + border-radius: 6px; + font-size: 11.5px; + color: var(--text2); + line-height: 1.45; +} + +/* ── 설정 행 ── */ +.stg-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 16px; + gap: 16px; + min-height: 52px; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.stg-row:last-child { border-bottom: none; } +.stg-row:hover { background: var(--bg3); } +.stg-row--block { + flex-direction: column; + align-items: stretch; + gap: 10px; +} +.stg-row--block .stg-row-ctrl { + width: 100%; + justify-content: flex-start; + align-self: stretch; +} +.stg-row-label { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} +.stg-row-title { + font-size: 13px; + color: var(--text); + font-weight: 500; +} +.stg-row-desc { + font-size: 11px; + color: var(--text3); + line-height: 1.45; + white-space: normal; + overflow: visible; +} +.stg-row-ctrl { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding-top: 2px; +} +.stg-row--block .stg-row-ctrl { padding-top: 0; } + +/* 가로 배치 라디오 옵션 (전략 설정 등) */ +.stg-radio-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + width: 100%; +} +@media (max-width: 640px) { + .stg-radio-row { grid-template-columns: 1fr; } +} +.stg-radio-row--disabled { + opacity: 0.45; + pointer-events: none; +} +.stg-radio-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg3); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.stg-radio-option:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.stg-radio-option:has(input:checked) { + border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); + background: color-mix(in srgb, var(--bg3) 75%, var(--accent) 25%); +} +.stg-radio-option input { + margin-top: 3px; + flex-shrink: 0; + accent-color: var(--accent); +} +.stg-radio-option--cyan input { accent-color: #00bcd4; } +.stg-radio-option--cyan:has(input:checked) { + border-color: rgba(0, 188, 212, 0.5); + background: rgba(0, 188, 212, 0.08); +} +.stg-radio-option--blue input { accent-color: #3f7ef5; } +.stg-radio-option--blue:has(input:checked) { + border-color: rgba(63, 126, 245, 0.5); + background: rgba(63, 126, 245, 0.08); +} +.stg-radio-option--orange input { accent-color: #ff9800; } +.stg-radio-option--orange:has(input:checked) { + border-color: rgba(255, 152, 0, 0.5); + background: rgba(255, 152, 0, 0.08); +} +.stg-radio-option-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.stg-radio-option-text strong { + font-size: 12.5px; + font-weight: 600; + color: var(--text); + line-height: 1.35; +} +.stg-radio-option-text span { + font-size: 11px; + color: var(--text3); + line-height: 1.45; +} + +/* ── 컨트롤: select ── */ +.stg-select { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 12.5px; + padding: 5px 24px 5px 9px; + cursor: pointer; + min-width: 130px; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 7px center; + transition: border-color 0.15s; +} +.stg-select:focus { outline: none; border-color: var(--accent); } + +/* ── 컨트롤: input ── */ +.stg-input { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 12.5px; + padding: 5px 9px; + width: 96px; + transition: border-color 0.15s; +} +.stg-input--wide { width: 100%; max-width: 220px; min-width: 120px; } +.stg-input:focus { outline: none; border-color: var(--accent); } +.stg-input--sensitive { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + letter-spacing: 0.02em; +} + +.stg-select--wide { + width: 100%; + max-width: 220px; + min-width: 140px; +} + +/* ════════ 백테스팅 설정 패널 (2열 카드) ════════ */ +.stg-bt-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.stg-bt-status { + font-size: 11.5px; + color: var(--text3); + padding: 4px 2px; +} +.stg-bt-status--ok { color: #4caf50; } + +.stg-bt-section { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + overflow: visible; +} + +.stg-bt-section-title { + margin: 0; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.9px; + color: var(--text3); + padding: 10px 16px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg3); +} + +.stg-bt-section-body { + padding: 12px 14px 14px; +} + +.stg-bt-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: stretch; +} + +@media (max-width: 720px) { + .stg-bt-grid { grid-template-columns: 1fr; } +} + +.stg-bt-field, +.stg-card { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 88px; + padding: 12px 14px; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 0.15s, background 0.15s; +} +.stg-bt-field:hover, +.stg-card:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background: color-mix(in srgb, var(--bg3) 85%, var(--accent) 15%); +} + +.stg-bt-field--full { + grid-column: 1 / -1; + min-height: auto; +} + +.stg-bt-field--placeholder { + visibility: hidden; + pointer-events: none; + min-height: 0; + padding: 0; + border: none; + background: transparent; +} + +.stg-bt-field-head { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + +.stg-bt-field-label { + font-size: 13px; + font-weight: 600; + color: var(--text); + line-height: 1.35; +} + +.stg-bt-field-desc { + font-size: 11px; + color: var(--text3); + line-height: 1.5; + white-space: normal; + word-break: keep-all; + overflow-wrap: break-word; +} + +.stg-bt-field-ctrl { + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; + margin-top: auto; +} + +.stg-bt-radio-group { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.stg-bt-radio { + display: flex; + align-items: flex-start; + gap: 10px; + cursor: pointer; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg3); + transition: border-color 0.15s, background 0.15s; +} +.stg-bt-radio:hover { + background: var(--bg3); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); +} +.stg-bt-radio input { + margin-top: 3px; + flex-shrink: 0; + accent-color: var(--accent); +} + +.stg-bt-radio-text { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.stg-bt-radio-text strong { + font-size: 12.5px; + font-weight: 600; + color: var(--text); +} +.stg-bt-radio-text span { + font-size: 11px; + color: var(--text3); + line-height: 1.45; +} + +.stg-bt-hint { + margin-top: 10px; + padding: 9px 12px; + border-radius: 6px; + font-size: 11.5px; + color: var(--text2); + line-height: 1.45; +} +.stg-bt-hint--warn { + background: rgba(255, 152, 0, 0.1); + border: 1px solid rgba(255, 152, 0, 0.35); +} + +.app.light .stg-bt-field, +.app.light .stg-card { background: #f5f5f7; } +.app.light .stg-bt-radio { background: #fff; } + +/* 공통 2열 카드 (전략 설정 등) */ +.stg-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: stretch; +} +@media (max-width: 720px) { + .stg-grid { grid-template-columns: 1fr; } +} +.stg-card--full { grid-column: 1 / -1; min-height: auto; } +.stg-card-head { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} +.stg-card-label { + font-size: 13px; + font-weight: 600; + color: var(--text); + line-height: 1.35; +} +.stg-card-desc { + font-size: 11px; + color: var(--text3); + line-height: 1.5; + white-space: normal; + word-break: keep-all; + overflow-wrap: break-word; +} +.stg-card-ctrl { + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; + margin-top: auto; +} + +/* ── 컨트롤: color picker ── */ +.stg-color-row { display: flex; align-items: center; gap: 8px; } +.stg-color { + width: 32px; height: 26px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px; + background: var(--bg3); + cursor: pointer; +} +.stg-color-label { + font-size: 12px; + font-family: monospace; + color: var(--text2); + min-width: 60px; +} + +/* ── 컨트롤: 토글 스위치 ── */ +.stg-toggle { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + flex-shrink: 0; +} +.stg-toggle input { opacity: 0; width: 0; height: 0; position: absolute; } +.stg-toggle-slider { + position: absolute; + inset: 0; + background: var(--bg4); + border-radius: 11px; + cursor: pointer; + transition: background 0.2s; + border: 1px solid var(--border); +} +.stg-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; height: 16px; + left: 2px; top: 2px; + background: var(--text3); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} +.stg-toggle input:checked + .stg-toggle-slider { background: var(--accent); border-color: var(--accent); } +.stg-toggle input:checked + .stg-toggle-slider::before { transform: translateX(18px); background: #fff; } + +/* ── 뱃지 (연결 상태) ── */ +.stg-badge { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 20px; } +.stg-badge--ok { background: rgba(76,175,80,0.15); color: #4CAF50; } +.stg-badge--err { background: rgba(239,83,80,0.15); color: #EF5350; } +.stg-badge--on { background: rgba(76,175,80,0.15); color: #4CAF50; margin-left: 8px; } +.stg-badge--off { background: rgba(158,158,158,0.2); color: var(--text2); margin-left: 8px; } +.stg-hint { margin: 0 0 8px; padding: 0 4px; font-size: 12px; color: var(--text2); line-height: 1.5; } + +/* ── 연결 테스트 버튼 ── */ +.stg-btn-test { + padding: 5px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s; +} +.stg-btn-test:hover { border-color: var(--accent); color: var(--accent); } + +/* ── 하단 푸터 (저장/초기화) ── */ +.stg-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding: 13px 24px; + border-top: 1px solid var(--border); + background: var(--bg2); + flex-shrink: 0; +} +.stg-btn-reset { + padding: 7px 16px; + border-radius: 7px; + border: 1px solid var(--border); + background: transparent; + color: var(--text2); + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} +.stg-btn-reset:hover { background: var(--bg3); } +.stg-btn-save { + padding: 7px 22px; + border-radius: 7px; + border: none; + background: var(--accent); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.stg-btn-save:hover { opacity: 0.88; } + +/* ── 라이트 테마 ── */ +.app.light .stg-page { background: #f0f2f5; } +.app.light .stg-sidebar, +.app.light .stg-content { box-shadow: 0 2px 10px rgba(0,0,0,0.08); } +.app.light .stg-content-body { background: #f5f5f7; } + +/* ── 설정: 보조지표 Main 탭 기본값 ───────────────────────────────────── */ +.stg-ind-intro { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + color: var(--text2); + line-height: 1.5; +} +.stg-ind-intro p { margin: 0; flex: 1; min-width: 200px; } +.stg-ind-reset-all { + font-size: 12px; + padding: 6px 12px; + border: 1px solid var(--sep); + border-radius: 6px; + background: var(--bg3); + color: var(--text); + cursor: pointer; + flex-shrink: 0; +} +.stg-ind-reset-all:hover { border-color: var(--accent); color: var(--accent); } +.stg-ind-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.stg-ind-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.stg-ind-card--expanded { + border-color: rgba(122, 162, 247, 0.35); +} +.stg-ind-card--on-chart { + border-color: rgba(122, 162, 247, 0.35); +} +.stg-ind-card-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + margin-right: 8px; +} +.stg-ind-badge { + font-size: 10px; + padding: 2px 8px; + border-radius: 3px; + color: var(--text-muted, #888); + background: rgba(255, 255, 255, 0.06); +} +.stg-ind-badge--on { + background: rgba(122, 162, 247, 0.2); + color: var(--accent, #7aa2f7); +} +.stg-ind-toggle { + flex-shrink: 0; + margin-right: 8px; +} +.stg-ind-card-head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + min-height: 44px; +} +.stg-ind-card-titles { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.stg-ind-card-name { + font-size: 13px; + font-weight: 700; + color: var(--text); +} +.stg-ind-card-sub { + font-size: 11px; + color: var(--text3); +} +.stg-ind-card-defaults { + font-size: 11px; + padding: 4px 10px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.stg-ind-card-defaults:hover { color: var(--text); border-color: var(--accent); } +.stg-ind-card-expand { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + font-size: 10px; + padding: 4px 8px; +} +.stg-ind-card-body { + border-top: 1px solid var(--border); + padding: 8px 12px 14px; + background: var(--bg3); +} +.stg-ind-card-body .ibsm-embedded-form { + padding: 0; +} +.stg-ind-unavailable { + color: var(--text3); + font-size: 13px; + padding: 16px; +} +.stg-footer--hint { + justify-content: flex-end; +} +.stg-footer-hint { + font-size: 12px; + color: var(--text3); +} +.app.light .stg-sidebar-item--active { color: #1565C0; } +.app.light .stg-btn-save { color: #fff; } + +/* ══════════════════════════════════════════════════════════════════ + RIGHT SIDE PANEL (매매 탭 + 호가 탭) + ══════════════════════════════════════════════════════════════════ */ + +/* ── 래퍼: flex 자식, 핸들(좌) + 패널(우) 가로 배치 ── */ +.rsp-wrap { + display: flex; + flex-direction: row; + align-items: stretch; + align-self: stretch; + flex-shrink: 0; + min-height: 0; + position: relative; + z-index: 15; +} + +/* ── 접기/펼치기 핸들 ── */ +.rsp-handle { + align-self: center; + flex-shrink: 0; + position: relative; + z-index: 20; + width: 16px; + height: 56px; + margin-right: -1px; /* 패널 왼쪽 테두리와 겹치도록 */ + background: var(--bg2); + border: 1px solid var(--border); + border-right: none; + border-radius: 8px 0 0 8px; + color: var(--text3); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.30); + padding: 0; +} +.rsp-handle:hover { + background: var(--bg3); + color: var(--text); + box-shadow: -3px 0 14px rgba(0, 0, 0, 0.45); +} +.rsp-wrap--open .rsp-handle { + border-color: rgba(122, 162, 247, 0.35); +} + +/* ── 패널 본체 (슬라이드 애니메이션) ── */ +.rsp-panel { + width: 0; + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--bg2); + border-left: 1px solid var(--border); + transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} +.rsp-wrap--open .rsp-panel { + width: 420px; +} + +/* ── 탭 헤더 ── */ +.rsp-tabs { + display: flex; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg3); + min-width: 420px; +} +.rsp-tab { + flex: 1; + padding: 10px 0; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text3); + font-size: 13px; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + white-space: nowrap; +} +.rsp-tab:hover { color: var(--text); } +.rsp-tab--active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 600; +} + +/* 탭 본문 */ +.rsp-body { + flex: 1; + min-height: 0; + min-width: 420px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 매매 탭: 매수·매도 카드 분리 */ +.rsp-trade-stack { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 10px 6px; +} +.rsp-trade-stack .rsp-trade-card { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; +} +.rsp-trade-stack .rsp-trade-card-body { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.rsp-trade-card { + flex-shrink: 0; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg3); + overflow: hidden; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18); +} +.rsp-trade-card--buy { + border-color: rgba(255, 107, 107, 0.35); + background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px); +} +.rsp-trade-card--sell { + border-color: rgba(77, 171, 247, 0.35); + background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px); +} +.rsp-trade-card-title { + padding: 7px 12px 5px; + font-size: 13px; + font-weight: 700; + border-bottom: 1px solid var(--border); +} +.rsp-trade-card--buy .rsp-trade-card-title { + color: var(--up); +} +.rsp-trade-card--sell .rsp-trade-card-title { + color: var(--down); +} +.rsp-trade-card-body .top-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 6px; + background: transparent; + padding: 6px 10px 6px; +} +.rsp-trade-card-body .top-field { + margin-bottom: 0; + flex-shrink: 0; +} +.rsp-trade-card-body .top-field:has(.top-order-kind) { + margin-bottom: 0; +} +.rsp-trade-card-body .top-label { + margin-bottom: 3px; + font-size: 10px; +} +.rsp-trade-card-body .top-row--balance { + margin-top: 0; + margin-bottom: 0; + flex-shrink: 0; +} +.rsp-trade-card-body .top-input { + padding: 6px 8px; + font-size: 12px; +} +.rsp-trade-card-body .top-kind-btn { + padding: 5px 3px; + font-size: 10px; +} +.rsp-trade-card-body .top-pct-row { + margin-top: 4px; +} +.rsp-trade-card-body .top-pct-btn { + padding: 4px 2px; + font-size: 9px; +} +.rsp-trade-card-body .top-step { + width: 28px; + font-size: 14px; +} +.rsp-trade-card-body .top-meta { + margin: 0; + flex-shrink: 0; +} +.rsp-trade-card-body .top-actions { + flex-shrink: 0; +} +.rsp-trade-card-body .top-reset, +.rsp-trade-card-body .top-submit { + padding: 7px 10px; + font-size: 13px; +} + +/* 호가 탭: 카드 박스 + 실시간 호가 */ +.rsp-ob-stack { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 12px 12px; + overflow: hidden; +} +.rsp-ob-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-color: rgba(122, 162, 247, 0.35); + background: linear-gradient(180deg, rgba(122, 162, 247, 0.08) 0%, var(--bg3) 48px); +} +.rsp-ob-card-title { + color: var(--accent); +} +.rsp-ob-card-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} +.rsp-ob-card-body .ob-panel--classic { + height: 100%; + background: transparent; + border-radius: 0 0 9px 9px; +} +.rsp-ob-card-body .ob-quote-header { + background: transparent; + border-bottom-color: rgba(255, 255, 255, 0.06); + padding-top: 2px; +} +.rsp-ob-card-body .ob-subtabs { + background: transparent; +} +.rsp-ob-card-body .ob-table--mid, +.rsp-ob-card-body .ob-tr-mid .ob-td-price--current { + background: transparent; +} +.rsp-ob-card-body .ob-summary-bar { + border-radius: 0 0 9px 9px; +} +.app.light .rsp-ob-card { + border-color: rgba(21, 101, 192, 0.28); + background: linear-gradient(180deg, rgba(21, 101, 192, 0.06) 0%, #fff 48px); +} +.app.light .rsp-ob-card-body .ob-quote-header { + border-bottom-color: rgba(0, 0, 0, 0.08); +} +.app.light .rsp-ob-card-body .ob-panel--classic { + background: transparent; +} + +/* ── 매수/매도 주문 폼 (top-) ── */ +.top-panel { + padding: 8px 12px 10px; + font-size: 12px; + color: var(--text); +} +.top-paper-hint { + margin: 0 0 8px; + padding: 6px 8px; + font-size: 11px; + line-height: 1.45; + color: var(--text2); + background: rgba(77, 171, 247, 0.08); + border-radius: 4px; + border: 1px solid rgba(77, 171, 247, 0.2); +} +.top-field { + margin-bottom: 10px; +} +.top-label { + display: block; + font-size: 11px; + color: var(--text2); + margin-bottom: 5px; +} +.top-label-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-left: 4px; + border-radius: 50%; + border: 1px solid var(--border); + font-size: 9px; + color: var(--text3); + cursor: help; +} +.top-order-kind { + display: flex; + gap: 4px; +} +.top-kind-btn { + flex: 1; + padding: 6px 4px; + font-size: 11px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text2); + cursor: pointer; + white-space: nowrap; +} +.top-kind-btn--wide { + flex: 1.2; +} +.top-kind-btn--active { + border-color: #1976d2; + color: #1976d2; + background: rgba(25, 118, 210, 0.08); + font-weight: 600; +} +.top-kind-arrow { + margin-left: 2px; + font-size: 9px; +} +.top-row--balance { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} +.top-balance { + font-weight: 700; + font-size: 13px; +} +.top-input-group { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; + background: var(--bg); +} +.top-input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + padding: 8px 10px; + font-size: 13px; + color: var(--text); + outline: none; +} +.top-input--full { + width: 100%; + border: 1px solid var(--border); + border-radius: 4px; + box-sizing: border-box; +} +.top-input--readonly { + background: var(--bg3); + color: var(--text2); +} +.top-input:disabled { + opacity: 0.55; +} +.top-step { + flex-shrink: 0; + width: 32px; + border: none; + border-left: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + font-size: 16px; + cursor: pointer; + padding: 0; +} +.top-step:hover:not(:disabled) { + background: var(--bg2); + color: var(--text); +} +.top-step:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.top-pct-row { + display: flex; + gap: 4px; + margin-top: 6px; +} +.top-pct-btn { + flex: 1; + padding: 5px 2px; + font-size: 10px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.top-pct-btn--direct { + flex: 1.3; +} +.top-pct-btn--active { + border-color: var(--accent); + color: var(--accent); + background: rgba(122, 162, 247, 0.12); +} +.top-meta { + display: flex; + justify-content: flex-end; + font-size: 9px; + color: var(--text3); + margin: 4px 0 8px; + line-height: 1.2; +} +.top-meta-line { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 4px; + white-space: nowrap; +} +.top-meta-sep { + opacity: 0.45; + user-select: none; +} +.top-meta-q { + display: inline-block; + width: 12px; + height: 12px; + line-height: 12px; + text-align: center; + border-radius: 50%; + border: 1px solid var(--border); + font-size: 8px; +} +.top-actions { + display: flex; + gap: 8px; +} +.top-reset { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 4px; + background: #4a4a4a; + color: #fff; + font-size: 12px; + cursor: pointer; +} +.top-reset-icon { + font-size: 14px; +} +.top-submit { + flex: 1; + padding: 10px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 700; + color: #fff; + cursor: pointer; +} +.top-submit--buy { + background: #ef5350; +} +.top-submit--buy:hover { + background: #e53935; +} +.top-submit--sell { + background: #2962ff; +} +.top-submit--sell:hover { + background: #1e53e5; +} +.app.light .top-kind-btn--active { + border-color: #1976d2; + color: #1976d2; +} +.app.light .top-reset { + background: #616161; +} + +.top-symbol-field { + display: flex; + align-items: stretch; + gap: 0; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; + background: var(--bg); +} +.top-symbol-field:focus-within { + border-color: var(--accent); +} +.top-symbol-input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + padding: 8px 10px; + font-size: 13px; + font-weight: 500; + color: var(--text); + outline: none; + cursor: text; +} +.top-symbol-input::placeholder { + color: var(--text3); + font-weight: 400; +} +.top-symbol-search-btn { + flex-shrink: 0; + width: 36px; + border: none; + border-left: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.top-symbol-search-btn:hover { + background: var(--bg2); + color: var(--accent); +} + +.ob-row--clickable { + cursor: pointer; +} +.ob-row--clickable:hover { + filter: brightness(1.08); +} + +.chart-ctx-menu { + position: fixed; + z-index: 10050; + min-width: 160px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + padding: 4px 0; + overflow: hidden; +} +.chart-ctx-menu-hint { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px 6px; + font-size: 11px; + color: var(--text3); + border-bottom: 1px solid var(--border); +} +.chart-ctx-menu-price { + font-weight: 600; + color: var(--text); +} +.chart-ctx-menu-item { + display: block; + width: 100%; + padding: 9px 14px; + border: none; + background: transparent; + text-align: left; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.chart-ctx-menu-item:hover { + background: var(--bg3); +} +.chart-ctx-menu-item--buy { + color: #ef5350; +} +.chart-ctx-menu-item--sell { + color: #2962ff; +} +.app.light .chart-ctx-menu { + background: #fff; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); +} + +/* ── 라이트 테마 ── */ +.app.light .rsp-handle { + background: #fff; + border-color: rgba(0,0,0,0.15); + box-shadow: -2px 0 8px rgba(0,0,0,0.12); +} +.app.light .rsp-tab--active { color: #1565C0; border-bottom-color: #1565C0; } + +/* ════════════════════════════════════════════════════════════════════════════ + TradeAlertModal (tam-) — 매매 알림 팝업 (이미지 디자인 기준 재설계) + ════════════════════════════════════════════════════════════════════════════ */ + +/* 오버레이 — 클릭 투과, 모달만 이벤트 수신 */ +.tam-overlay { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; +} +.tam-overlay > .tam-modal { + pointer-events: auto; +} + +/* 알림 목록 상세 — 화면 중앙 */ +.tam-overlay--center { + display: flex; + align-items: center; + justify-content: center; +} +.tam-overlay--center > .tam-modal--centered { + position: relative; + left: auto; + top: auto; + flex-shrink: 0; +} + +/* ── 모달 컨테이너 ── */ +.tam-modal { + position: fixed; + width: 360px; + display: flex; + flex-direction: column; + background: var(--bg2, #24283b); + border: 1px solid var(--tam-accent, var(--border, rgba(122,162,247,0.15))); + border-radius: 12px; + box-shadow: + 0 0 0 1px var(--tam-accent-glow, rgba(122,162,247,0.08)), + 0 20px 60px rgba(0,0,0,0.65), + 0 0 40px var(--tam-accent-glow, rgba(122,162,247,0.06)); + overflow: hidden; + font-size: 13px; + color: var(--text, #c0caf5); + font-family: var(--font, 'Noto Sans KR', sans-serif); +} + +/* ── 헤더 ── */ +.tam-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border, rgba(122,162,247,0.12)); + cursor: grab; + user-select: none; + flex-shrink: 0; + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3, #1f2335) 100% + ); +} +.tam-header:active { cursor: grabbing; } +.tam-header-left { + display: flex; + align-items: center; + gap: 9px; +} +.tam-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 800; + color: #fff; + letter-spacing: 1.2px; +} +.tam-header-title { + font-size: 14px; + font-weight: 700; + color: var(--text, #c0caf5); + letter-spacing: 0.4px; +} +.tam-header-ko { + font-size: 12px; + font-weight: 400; + color: var(--text2, #9aa5ce); +} +.tam-close { + background: none; + border: none; + color: var(--text3, #6272a4); + font-size: 16px; + cursor: pointer; + padding: 2px 6px; + line-height: 1; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} +.tam-close:hover { color: var(--text, #c0caf5); background: var(--bg4, rgba(255,255,255,0.07)); } + +/* ── 가격 섹션 ── */ +.tam-price-section { + padding: 14px 16px 12px; + background: linear-gradient( + 135deg, + var(--tam-accent-glow, rgba(122,162,247,0.08)) 0%, + transparent 60% + ); + border-bottom: 1px solid var(--border, rgba(122,162,247,0.1)); +} +.tam-price-row { + display: flex; + align-items: center; + gap: 8px; +} +.tam-market-name { + font-size: 15px; + font-weight: 700; + color: var(--text, #c0caf5); + letter-spacing: 0.3px; +} +.tam-price-arrow { + font-size: 18px; + font-weight: 800; + line-height: 1; +} +.tam-price-value { + font-size: 20px; + font-weight: 800; + letter-spacing: -0.5px; +} +.tam-price-time { + margin-top: 5px; + font-size: 11.5px; + color: var(--text3, #6272a4); +} + +/* ── 전략 카드 ── */ +.tam-strategy-card { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 14px; + padding: 11px 14px; + background: var(--bg3, #1f2335); + border: 1px solid var(--border, rgba(122,162,247,0.12)); + border-radius: 9px; +} +.tam-strategy-icon { + flex-shrink: 0; + display: flex; + align-items: center; +} +.tam-strategy-info { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} +.tam-strategy-name, +.tam-strategy-exec { + display: flex; + align-items: baseline; + gap: 6px; +} +.tam-strategy-label { + font-size: 11px; + color: var(--text3, #6272a4); + flex-shrink: 0; + min-width: 50px; +} +.tam-strategy-val { + font-size: 12.5px; + font-weight: 600; + color: var(--text, #c0caf5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tam-strategy-exec-val { + font-size: 12px; + color: var(--text2, #9aa5ce); + font-weight: 400; +} + +/* ── 주문 섹션 ── */ +.tam-order-section { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 14px 14px; +} + +/* 필드 행 */ +.tam-field-row { + display: flex; + align-items: center; + gap: 8px; +} +.tam-field-row--memo { + align-items: center; +} +.tam-field-label { + font-size: 11.5px; + color: var(--text2, #9aa5ce); + flex-shrink: 0; + min-width: 42px; +} + +/* 자산비율 버튼 그룹 */ +.tam-pct-group { + display: flex; + gap: 6px; + flex: 1; +} +.tam-pct-btn2 { + flex: 1; + padding: 6px 4px; + border: 1px solid var(--border, rgba(122,162,247,0.18)); + border-radius: 6px; + background: var(--bg3, #1f2335); + color: var(--text2, #9aa5ce); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.tam-pct-btn2:hover { + border-color: var(--tam-accent, var(--accent, #7aa2f7)); + color: var(--text, #c0caf5); + background: var(--bg4, #414868); +} +.tam-pct-btn2--active { + font-weight: 700; + background: var(--bg4, #414868); +} + +/* 지정가 입력 */ +.tam-price-input-wrap { + flex: 1; + min-width: 0; +} +.tam-price-input { + width: 100%; + box-sizing: border-box; + background: var(--bg3, #1f2335); + border: 1px solid var(--border, rgba(122,162,247,0.18)); + border-radius: 6px; + outline: none; + color: var(--text, #c0caf5); + font-size: 13px; + font-weight: 600; + text-align: right; + padding: 7px 10px; + transition: border-color 0.15s; +} +.tam-price-input:focus { + border-color: var(--tam-accent, var(--accent, #7aa2f7)); +} + +/* 시장가 주문 버튼 */ +.tam-market-btn { + flex-shrink: 0; + padding: 7px 11px; + border: 1px solid var(--border, rgba(122,162,247,0.18)); + border-radius: 6px; + background: var(--bg3, #1f2335); + color: var(--text2, #9aa5ce); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.tam-market-btn:hover { + border-color: var(--tam-accent, var(--accent, #7aa2f7)); + color: var(--text, #c0caf5); + background: var(--bg4, #414868); +} + +/* 메모 입력 */ +.tam-memo-input { + flex: 1; + box-sizing: border-box; + background: var(--bg3, #1f2335); + border: 1px solid var(--border, rgba(122,162,247,0.18)); + border-radius: 6px; + outline: none; + color: var(--text, #c0caf5); + font-size: 12.5px; + padding: 7px 10px; + transition: border-color 0.15s; +} +.tam-memo-input::placeholder { color: var(--text3, #6272a4); } +.tam-memo-input:focus { border-color: var(--tam-accent, var(--accent, #7aa2f7)); } + +/* CTA 버튼 */ +.tam-cta-btn { + width: 100%; + padding: 12px; + border: none; + border-radius: 8px; + color: #fff; + font-size: 14.5px; + font-weight: 800; + letter-spacing: 0.5px; + cursor: pointer; + margin-top: 2px; + transition: opacity 0.15s, transform 0.1s; + box-shadow: 0 4px 16px var(--tam-accent-glow, rgba(122,162,247,0.25)); +} +.tam-cta-btn:hover { opacity: 0.9; } +.tam-cta-btn:active { transform: scale(0.98); opacity: 0.85; } + +/* 면책 고지 */ +.tam-disclaimer { + font-size: 10.5px; + color: var(--text3, #6272a4); + text-align: center; + margin: 0; + line-height: 1.5; +} + +/* ── 라이트 테마 오버라이드 ── */ +.app.light .tam-modal { + background: #ffffff; + border-color: rgba(25,118,210,0.35); + color: #212121; + box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 0 40px rgba(25,118,210,0.08); +} +.app.light .tam-header { background: #f5f7fa; border-color: rgba(0,0,0,0.1); } +.app.light .tam-header-title { color: #1a237e; } +.app.light .tam-header-ko { color: #546e7a; } +.app.light .tam-close { color: #78909c; } +.app.light .tam-close:hover { background: rgba(0,0,0,0.06); color: #37474f; } +.app.light .tam-price-section { border-color: rgba(0,0,0,0.08); } +.app.light .tam-market-name { color: #212121; } +.app.light .tam-price-time { color: #78909c; } +.app.light .tam-strategy-card { background: #f5f7fa; border-color: rgba(0,0,0,0.1); } +.app.light .tam-strategy-label { color: #78909c; } +.app.light .tam-strategy-val { color: #212121; } +.app.light .tam-strategy-exec-val { color: #546e7a; } +.app.light .tam-field-label { color: #546e7a; } +.app.light .tam-pct-btn2 { background: #f0f3f8; border-color: rgba(0,0,0,0.12); color: #546e7a; } +.app.light .tam-pct-btn2:hover { background: #e3eaf6; } +.app.light .tam-price-input, +.app.light .tam-memo-input { background: #f5f7fa; border-color: rgba(0,0,0,0.14); color: #212121; } +.app.light .tam-market-btn { background: #f0f3f8; border-color: rgba(0,0,0,0.12); color: #546e7a; } +.app.light .tam-market-btn:hover { background: #e3eaf6; color: #212121; } +.app.light .tam-disclaimer { color: #90a4ae; } + +/* ═══════════════════════════════════════════════════════════════════════════ + Trade signal notifications (tsn- / tnl- / tmb-notify-) + ═══════════════════════════════════════════════════════════════════════════ */ + +/* 전역 알림 레이어 (body 포털) — 모든 메뉴 화면 위 */ +.app-notification-layer { + position: relative; + z-index: 20000; + pointer-events: none; +} +.app-notification-layer > * { + pointer-events: auto; +} + +.tsn-stack { + position: fixed; + top: 52px; + right: 16px; + z-index: 20001; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0; + max-width: min(400px, calc(100vw - 32px)); + pointer-events: none; +} +/* 그리드·가로열 — 넓은 레이아웃 허용 */ +.tsn-stack--right.tsn-layout--grid, +.tsn-stack--right.tsn-layout--strip { + max-width: calc(100vw - 32px); +} +/* 슬라이드 버튼(‹ ›) — 그리드·가로열·스택 공통 */ +.tsn-stack--right.tsn-stack--has-nav { + max-width: calc(100vw - 32px); +} +.tsn-stack--right.tsn-stack--has-nav .tsn-stack-toolbar { + width: auto; + max-width: calc(100vw - 32px - var(--tsn-nav-reserve, 80px)); + margin-right: calc(32px + 6px); +} +.tsn-stack--left.tsn-stack--has-nav .tsn-stack-toolbar { + margin-left: calc(32px + 6px); + max-width: calc(100vw - 32px - var(--tsn-nav-reserve, 80px)); +} +.tsn-stack--right.tsn-stack--has-nav.tsn-layout--grid .tsn-stack-toolbar, +.tsn-stack--right.tsn-stack--has-nav.tsn-layout--strip .tsn-stack-toolbar { + max-width: min( + calc(100vw - 32px - var(--tsn-nav-reserve, 80px)), + calc(var(--tsn-grid-cols, 2) * 210px) + ); +} +.tsn-stack--left { + right: auto; + left: 16px; + align-items: flex-start; +} +.tsn-stack--bottom { + top: auto; + bottom: 16px; + left: 50%; + right: auto; + transform: translateX(-50%); + align-items: center; + max-width: calc(100vw - 32px); +} +.tsn-stack--preview { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + transform: none; + max-width: none; + z-index: 1; +} +.tsn-stack--preview.tsn-stack--bottom { + top: auto; + bottom: 8px; + left: 50%; + transform: translateX(-50%); +} +.tsn-stack--preview.tsn-stack--left { + left: 8px; + right: auto; +} +.tsn-stack--preview.tsn-stack--right { + right: 8px; + left: auto; +} +.tsn-layout--stack.tsn-stack--bottom .tsn-cards { + flex-direction: column-reverse; +} +.tsn-stack-toolbar { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + width: 380px; + padding-bottom: 8px; + flex-shrink: 0; + pointer-events: auto; +} +.tsn-stack--left .tsn-stack-toolbar { + align-items: flex-start; +} +.tsn-stack--bottom .tsn-stack-toolbar { + align-items: center; + width: auto; + max-width: calc(100vw - 32px); +} +.tsn-toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + width: 100%; +} +.tsn-stack--left .tsn-toolbar-actions { + justify-content: flex-start; +} +.tsn-stack--bottom .tsn-toolbar-actions { + justify-content: center; +} +.tsn-dismiss-page { + font-size: 11px; + padding: 5px 12px; + border-radius: 6px; + border: 1px solid var(--border, rgba(122,162,247,0.2)); + background: var(--bg3, #1a1b26); + color: var(--text2, #a9b1d6); + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} +.tsn-dismiss-page:hover { + background: var(--bg4); + color: var(--text); + border-color: rgba(122, 162, 247, 0.45); +} +.tsn-pager { + display: inline-flex; + align-items: center; + gap: 6px; +} +.tsn-pager-btn { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 0; +} +.tsn-pager-btn:hover:not(:disabled) { + background: var(--bg4); + color: var(--text); +} +.tsn-pager-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.tsn-pager-label { + font-size: 11px; + color: var(--text2); + text-align: center; +} +.tsn-pager-sub { + opacity: 0.7; + font-size: 10px; +} +.tsn-slide-row { + display: flex; + align-items: stretch; + gap: 0; + pointer-events: none; + max-width: 100%; +} +.tsn-slide-row--nav { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + width: fit-content; + max-width: calc(100vw - 32px); + --tsn-nav-reserve: 80px; + pointer-events: none; +} +.tsn-slide-body { + flex: 1 1 auto; + min-width: 0; + max-width: calc(100vw - 32px - var(--tsn-nav-reserve)); + pointer-events: none; +} +.tsn-slide-body .tsn-cards { + width: 100% !important; + max-width: 100% !important; + min-width: 0; + overflow-x: hidden; + position: relative; + z-index: 1; +} +.tsn-slide-row--nav .tsn-slide-nav { + position: static; + flex: 0 0 32px; + transform: none; +} +.tsn-slide-nav { + flex-shrink: 0; + position: relative; + z-index: 100100; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border, rgba(122,162,247,0.25)); + background: var(--bg2, #1e2030); + color: var(--text2, #a9b1d6); + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + pointer-events: auto; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.tsn-slide-nav:hover:not(:disabled) { + background: var(--bg4); + color: var(--accent, #7aa2f7); + border-color: var(--accent, #7aa2f7); +} +.tsn-slide-nav:disabled { + opacity: 0.28; + cursor: not-allowed; + box-shadow: none; +} +.tsn-slide-row .tsn-cards { + flex: 1; + min-width: 0; +} +/* 슬라이드 행 안 — 카드 영역 너비 (slide-body 기준) */ +.tsn-slide-row--nav .tsn-cards:not(.tsn-cards--grid):not(.tsn-cards--strip):not(.tsn-cards--single) { + max-width: min(380px, 100%) !important; +} +.tsn-slide-row--nav .tsn-cards--grid { + max-width: min(100%, calc(var(--tsn-grid-cols, 2) * 210px)) !important; +} +.tsn-slide-row--nav .tsn-cards--strip { + max-width: 100% !important; +} +.tsn-slide-row--nav .tsn-cards--single { + max-width: min(380px, 100%) !important; +} +.tsn-slide-row--nav .tsn-card { + z-index: auto; +} +.tsn-card--dragging { + z-index: 10050 !important; +} +.tsn-stack--bottom .tsn-slide-row { + max-width: calc(100vw - 32px); +} +.tsn-stack--bottom .tsn-slide-row--nav .tsn-slide-nav { + width: 28px; + height: 28px; +} +.tsn-pager-dots { + display: flex; + justify-content: center; + gap: 5px; + padding: 4px 0 8px; + pointer-events: auto; +} +.tsn-pager-dot { + width: 7px; + height: 7px; + border-radius: 50%; + border: none; + padding: 0; + background: var(--border); + cursor: pointer; +} +.tsn-pager-dot--active { + background: var(--accent, #7aa2f7); + transform: scale(1.15); +} +.tsn-cards--paged { + touch-action: pan-y; +} +.tsn-layout--grid .tsn-stack-toolbar { + width: auto; + max-width: min(calc(100vw - 32px), calc(var(--tsn-grid-cols, 2) * 210px)); +} +.tsn-cards { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + width: 380px; + max-height: calc(100vh - 96px); + overflow-y: auto; + overflow-x: hidden; + pointer-events: auto; + padding-bottom: 12px; + scrollbar-width: thin; +} +/* 세로 스택 — 스크롤바 숨김 (페이지 슬라이드로 탐색) */ +.tsn-layout--stack .tsn-cards { + scrollbar-width: none; + -ms-overflow-style: none; +} +.tsn-layout--stack .tsn-cards::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} +.tsn-stack--left .tsn-cards { + align-items: stretch; +} +.tsn-stack--bottom .tsn-cards { + max-height: min(50vh, 420px); +} +.tsn-cards--grid { + display: grid; + grid-template-columns: repeat(var(--tsn-grid-cols, 2), minmax(0, 1fr)); + gap: 8px; + width: auto; + max-width: min(calc(100vw - 32px), calc(var(--tsn-grid-cols, 2) * 210px)); + overflow-y: auto; +} +.tsn-stack--has-nav .tsn-cards--grid { + max-width: min( + calc(100vw - 32px - var(--tsn-nav-reserve, 80px)), + calc(var(--tsn-grid-cols, 2) * 210px) + ); +} +.tsn-cards--grid .tsn-card { + width: auto; + min-width: 0; +} +.tsn-cards--strip { + flex-direction: row; + align-items: stretch; + width: auto; + max-width: calc(100vw - 32px); + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 8px; +} +.tsn-stack--has-nav .tsn-cards--strip { + max-width: calc(100vw - 32px - var(--tsn-nav-reserve, 80px)); +} +.tsn-cards--strip .tsn-card { + width: 280px; + flex-shrink: 0; +} +.tsn-cards--single { + width: 380px; +} +.tsn-single-more { + font-size: 11px; + color: var(--text2, #a9b1d6); + padding: 0 4px; +} +.tsn-cards--preview { + max-height: none; + overflow: visible; + pointer-events: none; +} +.tsn-cards--preview.tsn-cards--grid { + max-width: 100%; +} +.tsn-cards--preview.tsn-cards--strip { + max-width: 100%; +} +.tsn-cards--preview.tsn-cards--strip .tsn-card { + width: 120px; +} +.tsn-cards--preview.tsn-cards--grid .tsn-card { + min-width: 0; +} +.tsn-cards--preview.tsn-cards--single { + width: 160px; +} +.tsn-cards::-webkit-scrollbar { width: 5px; } +.tsn-cards::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.tsn-dismiss-all { + font-size: 11px; + padding: 5px 12px; + border-radius: 6px; + border: 1px solid var(--border, rgba(122,162,247,0.2)); + background: var(--bg3, #1a1b26); + color: var(--text2, #a9b1d6); + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} +.tsn-dismiss-all:hover { background: var(--bg4); color: var(--text); } + +@keyframes tsn-slide-in { + from { + opacity: 0; + transform: translateX(28px) scale(0.96); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} +@keyframes tsn-slide-in-left { + from { + opacity: 0; + transform: translateX(-28px) scale(0.96); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} +@keyframes tsn-slide-in-bottom { + from { + opacity: 0; + transform: translateY(20px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.tsn-stack--left .tsn-card:not(.tsn-card--preview) { + animation: tsn-slide-in-left 0.28s cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: top left; +} +.tsn-stack--bottom .tsn-card:not(.tsn-card--preview) { + animation: tsn-slide-in-bottom 0.28s cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: bottom center; +} + +.tsn-card { + position: relative; + width: 100%; + flex-shrink: 0; + border-radius: 10px; + border: 1px solid var(--border, rgba(122,162,247,0.25)); + background: var(--bg2, #1e2030); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + overflow: hidden; + animation: tsn-slide-in 0.28s cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: top right; +} +.tsn-card--latest { + box-shadow: 0 14px 44px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(77, 171, 247, 0.12); +} +.tsn-card--buy { --tsn-accent: #3f7ef5; } +.tsn-card--sell { --tsn-accent: #ef5350; } + +.tsn-card-header { + cursor: grab; + user-select: none; + touch-action: none; + margin: 0; + border-radius: 0; +} +.tsn-card-header:active { cursor: grabbing; } +.tsn-card-header .tsn-badge { flex-shrink: 0; } +.tsn-card-header .tsn-card-title { + flex: 1; + min-width: 0; +} +.tsn-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 3px; + letter-spacing: 0.04em; +} +.tsn-badge--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; } +.tsn-badge--sell { background: rgba(239,83,80,0.2); color: #f7768e; } +.tsn-card-title { + flex: 1; + font-size: 13px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tsn-icon-btn { + border: none; + background: transparent; + color: var(--text3); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.tsn-icon-btn:hover { color: var(--text); } + +/* 매매 시그널 상세 본문 (스낵바·공통) */ +.tsd-body { + padding: 10px 14px 12px; + border-bottom: 1px solid var(--border); +} +.tsd-body--compact { padding: 10px 14px 8px; } +.tsd-headline { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} +.tsd-direction { + font-size: 15px; + font-weight: 700; +} +.tsd-direction--buy { color: var(--up, #ff6b6b); } +.tsd-direction--sell { color: var(--down, #4dabf7); } +.tsd-headline-price { + font-size: 18px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.tsd-summary { + margin: 0 0 10px; + font-size: 12px; + color: var(--text2); + line-height: 1.4; +} +.tsd-detail-grid { + display: grid; + grid-template-columns: 72px 1fr; + gap: 5px 10px; + margin: 0; +} +.tsd-dt { + margin: 0; + font-size: 11px; + color: var(--text3); + font-weight: 500; +} +.tsd-dd { + margin: 0; + font-size: 12px; + color: var(--text); + line-height: 1.35; + word-break: break-word; +} +.tsd-dd--highlight { + font-weight: 600; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.tsn-card-actions { + display: flex; + gap: 6px; + padding: 8px 10px 10px; + border-top: 1px solid var(--border); +} +.tsn-action-btn { + flex: 1; + font-size: 11px; + padding: 6px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tsn-action-btn:hover { background: var(--bg4); color: var(--text); } +.tsn-action-btn--primary { + border-color: var(--tsn-accent, #7aa2f7); + color: var(--tsn-accent, #7aa2f7); +} +.tsn-card--compact .tsn-card-header { + padding: 6px 8px; +} +.tsn-card--compact .tsn-card-title { + font-size: 11px; +} +.tsn-card-actions--compact { + padding: 6px 8px 8px; + gap: 4px; +} +.tsn-card-actions--compact .tsn-action-btn { + padding: 4px 6px; + font-size: 10px; +} +.tsn-card--preview { + animation: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + pointer-events: none; +} +.tsn-card--preview.tsn-card--compact .tsn-card-title { + font-size: 9px; +} +.tsn-card-header--preview { + cursor: default; + padding: 4px 6px; +} +.tsn-preview-body { + padding: 4px 8px 6px; + font-size: 10px; + color: var(--text2); +} + +/* 설정 — 알림 팝업 미리보기 */ +.stg-alert-preview { + margin-top: 4px; +} +.stg-alert-preview--off { + opacity: 0.45; +} +.stg-alert-preview-frame { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg3); + overflow: hidden; +} +.stg-alert-preview-screen { + position: relative; + height: 180px; + background: linear-gradient(180deg, var(--bg2) 0%, var(--bg) 100%); +} +.stg-alert-preview-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 11px; + color: var(--text3, rgba(169,177,214,0.35)); + pointer-events: none; + user-select: none; +} +.stg-alert-layout-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.stg-alert-layout-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; + font-size: 12px; + min-width: 100px; + transition: border-color 0.15s, background 0.15s; +} +.stg-alert-layout-option:hover { + border-color: var(--accent, #7aa2f7); + color: var(--text); +} +.stg-alert-layout-option--active { + border-color: var(--accent, #7aa2f7); + background: rgba(122, 162, 247, 0.1); + color: var(--text); +} +.stg-alert-layout-option strong { + font-size: 12px; + font-weight: 600; +} +.stg-alert-layout-option span { + font-size: 10px; + opacity: 0.75; + line-height: 1.3; +} + +/* 알림 목록 페이지 */ +.tnl-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + overflow: hidden; +} +.tnl-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.tnl-title { font-size: 20px; font-weight: 700; margin: 0 0 6px; } +.tnl-sub { font-size: 13px; color: var(--text3); margin: 0 0 14px; } +.tnl-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.tnl-chip { + font-size: 12px; + padding: 4px 10px; + border-radius: 12px; + background: var(--bg3); +} +.tnl-chip--warn { background: rgba(239,83,80,0.15); color: #f7768e; } +.tnl-filter { display: flex; gap: 4px; } +.tnl-filter button, +.tnl-btn { + font-size: 12px; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tnl-filter-btn--active, +.tnl-btn--primary { + border-color: #7aa2f7; + color: #7aa2f7; + background: rgba(122,162,247,0.12); +} +.tnl-list-wrap { flex: 1; overflow-y: auto; padding: 12px 24px 24px; } +.tnl-empty { text-align: center; color: var(--text3); padding: 48px; } +.tnl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.tnl-row { + display: flex; + align-items: stretch; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg2); + overflow: hidden; +} +.tnl-row--unread { border-color: rgba(122,162,247,0.45); } +.tnl-row-main { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.tnl-row-main:hover { background: var(--bg3); } +.tnl-signal { + font-size: 11px; + font-weight: 700; + padding: 4px 8px; + border-radius: 4px; + flex-shrink: 0; +} +.tnl-signal--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; } +.tnl-signal--sell { background: rgba(239,83,80,0.2); color: #f7768e; } +.tnl-row-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.tnl-row-title { font-size: 14px; font-weight: 600; } +.tnl-row-price { + font-size: 15px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.tnl-row-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 14px; + margin-top: 4px; +} +.tnl-row-detail-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.tnl-row-detail-lbl { + font-size: 10px; + color: var(--text3); + font-weight: 500; +} +.tnl-row-detail-val { + font-size: 11px; + color: var(--text2); + line-height: 1.3; + word-break: break-word; +} +.tnl-row-meta { font-size: 12px; color: var(--text3); } +.tnl-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #7aa2f7; + flex-shrink: 0; +} +.tnl-row-chart { + padding: 0 16px; + border: none; + border-left: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + font-size: 12px; + cursor: pointer; +} +.tnl-row-chart:hover { background: var(--bg4); color: var(--text); } + +/* TopMenuBar 알림 버튼 */ +.tmb-notify-group { + display: inline-flex; + align-items: center; + gap: 4px; +} +.tmb-notify-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tmb-notify-btn:hover { background: var(--bg4); color: var(--text); } +.tmb-notify-dismiss-btn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tmb-notify-dismiss-btn:hover:not(:disabled) { + background: rgba(239, 83, 80, 0.12); + border-color: rgba(239, 83, 80, 0.45); + color: #ef5350; +} +.tmb-notify-dismiss-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.tmb-notify-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: #ef5350; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; +} +.tmb-notify-layout { + position: relative; + display: inline-flex; + align-items: center; +} +.tmb-notify-layout-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + cursor: pointer; +} +.tmb-notify-layout-trigger:hover, +.tmb-notify-layout-trigger--open { + background: var(--bg4); + color: var(--text); + border-color: var(--accent, #7aa2f7); +} +.tmb-notify-layout-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30000; + min-width: 220px; + max-height: min(70vh, 420px); + overflow-y: auto; + padding: 6px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg2, #1e2030); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); +} +.tmb-notify-layout-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text2); + font-size: 12px; + text-align: left; + cursor: pointer; +} +.tmb-notify-layout-option:hover { + background: var(--bg3); + color: var(--text); +} +.tmb-notify-layout-option--active { + background: rgba(122, 162, 247, 0.12); + color: var(--accent, #7aa2f7); +} +.tmb-notify-layout-option-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 22px; + color: inherit; +} +.tmb-notify-layout-option-label { + flex: 1; + line-height: 1.35; +} +.tmb-notify-layout-cols-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + padding: 8px 10px 4px; + border-top: 1px solid var(--border); +} +.tmb-notify-layout-cols-label { + font-size: 10px; + color: var(--text3, #6b7280); + white-space: nowrap; +} +.tmb-notify-layout-cols-btns { + display: flex; + gap: 4px; + margin-left: auto; +} +.tmb-notify-layout-col-btn { + padding: 3px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text2); + font-size: 10px; + cursor: pointer; +} +.tmb-notify-layout-col-btn--active, +.tmb-notify-layout-col-btn:hover { + border-color: var(--accent, #7aa2f7); + color: var(--accent, #7aa2f7); + background: rgba(122, 162, 247, 0.1); +} + +/* Toolbar 매매 알림 배지 */ +.tv-trade-notify-wrap { position: relative; display: inline-flex; } +.tv-trade-notify-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: #ef5350; + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + pointer-events: none; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + 공통 팝업 (gc-popup-*) — 그라데이션 타이틀바 + 드래그 + ═══════════════════════════════════════════════════════════════════════════ */ +.gc-popup-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 5000; +} +.gc-popup-panel { + display: flex; + flex-direction: column; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + max-width: 96vw; + max-height: 90vh; + overflow: hidden; +} +.gc-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + flex-shrink: 0; + cursor: grab; + user-select: none; + touch-action: none; + border-bottom: 1px solid var(--border); + background: linear-gradient( + 180deg, + rgba(77, 171, 247, 0.12) 0%, + rgba(77, 171, 247, 0.04) 28px, + var(--bg3) 100% + ); +} +.gc-popup-header:active { cursor: grabbing; } +.gc-popup-title { + flex: 1; + font-size: 14px; + font-weight: 700; + color: var(--down, #4dabf7); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.gc-popup-close { + background: none; + border: none; + color: var(--text3); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; + flex-shrink: 0; +} +.gc-popup-close:hover { color: var(--text); background: var(--bg4); } + +.mcs-dialog, +.ism-dialog, +.indicator-modal, +.obj-tree-panel, +.ibsm-dialog, +.sp-modal, +.brm-modal, +.plsp-popup, +.ind-panel { + position: fixed; +} + +/* ── 모의투자 페이지 ───────────────────────────────────────────────────── */ +.paper-page { + flex: 1; + min-height: 0; + overflow: auto; + padding: 20px 24px 32px; + background: var(--bg); +} +.paper-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} +.paper-title { font-size: 22px; font-weight: 700; margin: 0 0 6px; color: var(--text); } +.paper-subtitle { margin: 0; font-size: 13px; color: var(--text2); line-height: 1.5; } +.paper-header-actions { display: flex; gap: 8px; flex-shrink: 0; } +.paper-btn { + padding: 8px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.paper-btn:hover { background: var(--bg4); } +.paper-btn--danger { border-color: rgba(239, 83, 80, 0.5); color: #ef5350; } +.paper-banner { + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; +} +.paper-banner--warn { background: rgba(255, 193, 7, 0.12); border: 1px solid rgba(255, 193, 7, 0.35); color: #ffc107; } +.paper-banner--info { background: rgba(77, 171, 247, 0.1); border: 1px solid rgba(77, 171, 247, 0.35); color: var(--text); } +.paper-banner--error { background: rgba(239, 83, 80, 0.12); border: 1px solid rgba(239, 83, 80, 0.35); color: #ef5350; } +.paper-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.paper-card { + padding: 14px 16px; + border-radius: 8px; + background: var(--bg2); + border: 1px solid var(--border); +} +.paper-card-label { display: block; font-size: 11px; color: var(--text2); margin-bottom: 6px; } +.paper-card-value { font-size: 17px; font-weight: 700; color: var(--text); } +.paper-tabs { display: flex; gap: 4px; margin-bottom: 12px; } +.paper-tab { + padding: 8px 16px; + border: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: var(--text2); + cursor: pointer; + font-size: 13px; +} +.paper-tab.active { background: var(--bg2); color: var(--text); font-weight: 600; } +.paper-panel { + padding: 16px; + border-radius: 0 8px 8px 8px; + background: var(--bg2); + border: 1px solid var(--border); + min-height: 200px; + overflow: auto; +} +.paper-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.paper-table th, .paper-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); } +.paper-table th { color: var(--text2); font-weight: 600; font-size: 12px; } +.paper-sym { display: block; font-size: 11px; color: var(--text2); } +.paper-muted { color: var(--text2); text-align: center; padding: 24px; } +.paper-dl { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; font-size: 13px; } +.paper-dl dt { color: var(--text2); } +.paper-dl dd { margin: 0; color: var(--text); } +.paper-link { background: none; border: none; color: var(--accent, #4dabf7); cursor: pointer; font-size: 12px; } +.paper-time { font-size: 12px; color: var(--text2); white-space: nowrap; } +.stg-btn { + padding: 8px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.stg-btn--danger { border-color: rgba(239, 83, 80, 0.5); color: #ef5350; } +.stg-btn--danger:hover { background: rgba(239, 83, 80, 0.12); } + +/* ── Dashboard ── */ +.dashboard-page { padding: 24px 32px; max-width: 1200px; margin: 0 auto; } +.dashboard-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } +.dashboard-header h1 { font-size: 22px; font-weight: 600; margin: 0; color: var(--text); } +.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } +.dashboard-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } +.dashboard-card--wide { grid-column: 1 / -1; } +.dashboard-card h2 { font-size: 14px; margin: 0 0 12px; color: var(--text2); font-weight: 600; } +.dashboard-kv { list-style: none; margin: 0; padding: 0; } +.dashboard-kv li { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); } +.dashboard-kv li:last-child { border-bottom: none; } +.dashboard-kv--cols { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.dashboard-kv--cols li { border: none; flex-direction: column; gap: 4px; } +.dashboard-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.dashboard-table th, .dashboard-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); } +.dashboard-table .buy { color: #26a69a; } +.dashboard-table .sell { color: #ef5350; } +.dashboard-link { background: none; border: none; color: var(--accent); cursor: pointer; padding: 0; } +.dashboard-muted { color: var(--text2); font-size: 13px; } +.dashboard-error { color: #ef5350; font-size: 13px; } +.dashboard-header-actions { display: flex; align-items: center; gap: 12px; } +.dashboard-live { font-size: 12px; color: var(--text2); } +.dash-monitor-top { margin-bottom: 4px; } +.dash-jvm-row { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } +.dash-jvm-heap { flex: 1; min-width: 220px; } +.dash-jvm-label { font-size: 12px; color: var(--text2); margin-bottom: 8px; } +.dash-jvm-bar { height: 10px; background: var(--bg3); border-radius: 5px; overflow: hidden; } +.dash-jvm-fill { height: 100%; background: #26a69a; border-radius: 5px; transition: width 0.3s ease; } +.dash-jvm-fill--warn { background: #ef5350; } +.dash-traffic-kv { flex: 1; min-width: 280px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +.dash-traffic-kv li { border: none; } +.dash-proc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } +.dash-proc { border: 1px solid var(--border); border-radius: 8px; padding: 12px; background: var(--bg3); } +.dash-proc--ok { border-left: 3px solid #26a69a; } +.dash-proc--warn { border-left: 3px solid #ffb74d; } +.dash-proc--down { border-left: 3px solid #ef5350; } +.dash-proc--off { border-left: 3px solid var(--text3); opacity: 0.85; } +.dash-proc-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.dash-proc-layer { font-size: 10px; text-transform: uppercase; color: var(--text3); letter-spacing: 0.04em; } +.dash-proc-badge { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; } +.dash-proc--ok .dash-proc-badge { background: rgba(38, 166, 154, 0.2); color: #26a69a; } +.dash-proc--warn .dash-proc-badge { background: rgba(255, 183, 77, 0.2); color: #ffb74d; } +.dash-proc--down .dash-proc-badge { background: rgba(239, 83, 80, 0.2); color: #ef5350; } +.dash-proc--off .dash-proc-badge { background: var(--bg2); color: var(--text3); } +.dash-proc-name { font-size: 13px; font-weight: 600; margin: 0 0 8px; color: var(--text); } +.dash-proc-metrics { list-style: none; margin: 0; padding: 0; font-size: 11px; } +.dash-proc-metrics li { display: flex; justify-content: space-between; padding: 3px 0; color: var(--text2); } +.dash-proc-metrics strong { color: var(--text); font-weight: 500; } +.dashboard-table--compact { font-size: 12px; } +.dashboard-table--compact code { font-size: 11px; } +.dashboard-kv .warn { color: #ef5350; } +.dash-stale { border-color: rgba(255, 183, 77, 0.4); } + +/* ══════════════════════════════════════════════════════════════════ + LOGIN MODAL + ══════════════════════════════════════════════════════════════════ */ +.login-modal-backdrop { + position: fixed; + inset: 0; + z-index: 12000; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.login-modal { + width: 100%; + max-width: 380px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + padding: 20px 22px 22px; +} +.login-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.login-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text); +} +.login-modal-close { + background: transparent; + border: none; + color: var(--text3); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.login-modal-close:hover { color: var(--text); } +.login-modal-hint { + font-size: 12px; + line-height: 1.5; + color: var(--text3); + margin: 0 0 16px; +} +.login-modal-form { display: flex; flex-direction: column; gap: 12px; } +.login-modal-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--text2); +} +.login-modal-field input { + padding: 9px 11px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 14px; +} +.login-modal-field input:focus { + outline: none; + border-color: var(--accent); +} +.login-modal-error { + margin: 0; + font-size: 12px; + color: #ef5350; +} +.login-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} +.login-modal-btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); +} +.login-modal-btn--ghost { + background: transparent; + color: var(--text2); +} +.login-modal-btn--ghost:hover { background: var(--bg3); color: var(--text); } +.login-modal-btn--primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.login-modal-btn--primary:hover { background: var(--accent-h); } +.login-modal-btn:disabled { opacity: 0.55; cursor: not-allowed; } + +.tmb-user-role { + margin-left: 6px; + font-size: 10px; + font-weight: 600; + opacity: 0.65; + text-transform: uppercase; +} + +.tmb-user-role { + margin-left: 6px; + font-size: 10px; + font-weight: 600; + opacity: 0.65; + text-transform: uppercase; +} + +.tmb-user-badge { + font-size: 12px; + color: var(--accent); + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 6px; +} +.tmb-login-btn--out { + background: transparent; + border: 1px solid var(--border); + color: var(--text2); + padding: 5px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; +} +.tmb-login-btn--out:hover { + background: var(--bg3); + color: var(--text); +} + +/* ══════════════════════════════════════════════════════════════════ + MOBILE LAYOUT (≤768px, .app--mobile) + ══════════════════════════════════════════════════════════════════ */ +.app--mobile { + height: 100dvh; + max-height: 100dvh; + -webkit-tap-highlight-color: transparent; +} + +/* ── 글로벌 상단 메뉴: 가로 스크롤 + 아이콘 중심 ── */ +.app--mobile .top-menubar { + height: 44px; + padding: 0 8px; + padding-left: max(8px, env(safe-area-inset-left)); + padding-right: max(8px, env(safe-area-inset-right)); + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.app--mobile .top-menubar::-webkit-scrollbar { display: none; } +.app--mobile .tmb-logo-text { display: none; } +.app--mobile .tmb-nav { + flex: 1; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.app--mobile .tmb-nav::-webkit-scrollbar { display: none; } +.app--mobile .tmb-nav-btn { + flex-shrink: 0; + padding: 6px 10px; + font-size: 11px; +} +.app--mobile .tmb-nav-label { display: none; } +.app--mobile .tmb-theme-label, +.app--mobile .tmb-login-btn span { display: none; } +.app--mobile .tmb-right { flex-shrink: 0; } + +/* ── 차트 툴바: 가로 스크롤 ── */ +.app--mobile .tv-toolbar { + height: 40px; + min-height: 40px; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0 4px; + gap: 0; +} +.app--mobile .tv-toolbar::-webkit-scrollbar { display: none; } +.app--mobile .tv-toolbar-left, +.app--mobile .tv-toolbar-right { + flex-shrink: 0; +} +.app--mobile .tv-sym-input { width: 88px; min-width: 72px; } +.app--mobile .tv-sym-text { max-width: 56px; } +.app--mobile .upbit-bar { + height: 26px; + font-size: 11px; + padding: 0 8px; + overflow: hidden; +} +.app--mobile .upbit-hint { display: none; } +.app--mobile .upbit-market { max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── 메인: 차트 전체 너비, 사이드 패널 오버레이 ── */ +.app--mobile .tv-main { + position: relative; + flex: 1; + min-height: 0; +} + +.app--mobile .tv-drawing-sidebar, +.app--mobile .mp-panel-handle, +.app--mobile .rsp-handle { + display: none !important; +} + +/* 좌측 마켓 패널 — 슬라이드 오버레이 */ +.app--mobile .market-panel { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 400; + width: 0 !important; + max-width: min(92vw, 320px); + border-right: 1px solid var(--border); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.45); + transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.app--mobile .market-panel--open { + width: min(92vw, 320px) !important; +} +.app--mobile .mp-body { + width: min(92vw, 320px); + min-width: 0; + height: 100%; + padding-top: env(safe-area-inset-top, 0); + padding-bottom: calc(52px + env(safe-area-inset-bottom, 0)); +} + +/* 우측 매매·호가 — 전체 너비 슬라이드 */ +.app--mobile .rsp-wrap { + position: fixed; + right: 0; + top: 0; + bottom: 0; + z-index: 400; + width: auto; + pointer-events: none; +} +.app--mobile .rsp-wrap--open { + pointer-events: auto; +} +.app--mobile .rsp-panel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + height: 100% !important; + max-height: 100%; + border-left: 1px solid var(--border); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.45); + transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.app--mobile .rsp-wrap--open .rsp-panel { + width: min(100vw, 400px) !important; +} +.app--mobile .rsp-tabs, +.app--mobile .rsp-body { + min-width: 0; + width: 100%; +} +.app--mobile .rsp-body { + padding-bottom: calc(52px + env(safe-area-inset-bottom, 0)); +} + +/* 패널 뒤 어두운 배경 */ +.mobile-panel-backdrop { + position: fixed; + inset: 0; + z-index: 350; + border: none; + padding: 0; + margin: 0; + background: rgba(0, 0, 0, 0.52); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +/* 차트 영역 */ +.app--mobile .tv-chart-col, +.app--mobile .multi-chart-grid, +.app--mobile .multi-slot-wrap { + min-width: 0; + width: 100%; +} +.app--mobile .multi-chart-grid { + display: flex !important; + flex-direction: column !important; + grid-template-columns: none !important; + grid-template-rows: none !important; + grid-template-areas: none !important; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.app--mobile .multi-slot-wrap { + flex: 1 1 auto; + min-height: 220px; + grid-area: unset !important; +} + +/* 하단 기간 바 + 모바일 도크 */ +.app--mobile .tv-bottom-bar { + flex-shrink: 0; + margin-bottom: calc(48px + env(safe-area-inset-bottom, 0)); +} + +.mobile-chart-dock { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 500; + display: flex; + align-items: stretch; + justify-content: space-around; + height: calc(48px + env(safe-area-inset-bottom, 0)); + padding-bottom: env(safe-area-inset-bottom, 0); + background: var(--bg2); + border-top: 1px solid var(--border); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35); +} +.mcd-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 0; + padding: 4px 2px; + background: transparent; + border: none; + color: var(--text3); + font-size: 10px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.mcd-btn.active { + color: var(--accent); + background: rgba(122, 162, 247, 0.12); +} +.mcd-icon { font-size: 18px; line-height: 1; } +.mcd-label { font-size: 10px; font-weight: 500; letter-spacing: -0.2px; } + +/* 비차트 페이지: 전체 너비·패딩 */ +.app--mobile .dashboard-page, +.app--mobile .strategy-page, +.app--mobile .stg-page, +.app--mobile .paper-page, +.app--mobile .brd-page { + padding: 12px 12px max(16px, env(safe-area-inset-bottom)); + max-width: 100%; + overflow-x: hidden; +} +.app--mobile .dashboard-grid, +.app--mobile .brd-kpi-grid { + grid-template-columns: 1fr !important; +} + +/* 플로팅 패널: 모바일에서 거의 전체 너비 */ +.app--mobile .stats-panel, +.app--mobile .watch-panel, +.app--mobile .alert-panel, +.app--mobile .object-tree-panel { + left: 8px !important; + right: 8px !important; + width: auto !important; + max-width: none !important; +} + +/* 터치 타겟 확대 */ +.app--mobile .tv-icon-btn, +.app--mobile .tv-side-btn { + min-width: 36px; + min-height: 36px; +} + +/* 기존 600px 규칙 유지 (모바일 중첩) */ +@media (max-width: 600px) { + :root { --sidebar-w: 36px; } + .tv-sym-text { max-width: 70px; overflow: hidden; text-overflow: ellipsis; } + .tv-theme-label { display: none; } + .tv-indicators-btn span:first-of-type { display: none; } +} + +/* ── 관리자 설정 ── */ +.admin-gate { display: flex; align-items: center; justify-content: center; min-height: 280px; padding: 24px; } +.admin-gate-card { max-width: 360px; width: 100%; padding: 24px; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; } +.admin-gate-title { margin: 0 0 8px; font-size: 16px; } +.admin-gate-desc { margin: 0 0 16px; font-size: 13px; color: var(--text2); line-height: 1.5; } +.admin-gate-form { display: flex; flex-direction: column; gap: 12px; } +.admin-gate-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.admin-gate-field input { padding: 8px 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg2); color: var(--text); } +.admin-gate-error { color: var(--down); font-size: 12px; margin: 0; } +.admin-gate-btn { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; } +.admin-gate-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.admin-settings { display: flex; flex-direction: column; gap: 8px; } +.admin-msg { padding: 8px 12px; border-radius: 4px; font-size: 13px; margin: 0 0 8px; } +.admin-msg--ok { background: rgba(38, 166, 154, 0.15); color: var(--up); } +.admin-msg--err { background: rgba(239, 83, 80, 0.15); color: var(--down); } +.admin-toolbar { margin-bottom: 12px; } +.admin-table-wrap { overflow-x: auto; } +.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.admin-table th, .admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); text-align: left; } +.admin-table th { color: var(--text2); font-weight: 600; } +.admin-actions { white-space: nowrap; } +.admin-link { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; margin-right: 8px; } +.admin-link--danger { color: var(--down); } +.admin-role { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; } +.admin-role--admin { background: rgba(33, 150, 243, 0.2); color: #64b5f6; } +.admin-role--user { background: rgba(158, 158, 158, 0.2); color: var(--text2); } +.admin-role--guest { background: rgba(255, 193, 7, 0.15); color: #ffc107; } +.admin-form-section { border-top: 1px solid var(--border); padding-top: 16px; } +.admin-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } +.admin-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.admin-form-grid input, .admin-form-grid select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg2); color: var(--text); } +.admin-check { flex-direction: row !important; align-items: center; gap: 8px !important; } +.admin-form-actions { display: flex; gap: 8px; margin-top: 12px; } +.admin-perm-role-tabs { display: flex; gap: 4px; margin-bottom: 12px; } +.admin-perm-tab { padding: 6px 14px; border: 1px solid var(--border); background: var(--bg2); color: var(--text2); border-radius: 4px; cursor: pointer; font-size: 13px; } +.admin-perm-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.admin-perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px 12px; max-height: 360px; overflow-y: auto; padding: 8px; background: var(--bg3); border-radius: 6px; border: 1px solid var(--border); } +.admin-perm-item { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; } + +/* ══════════════════════════════════════════════════════════════════ + SPLASH (진입 화면) + ══════════════════════════════════════════════════════════════════ */ +.splash { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #0a0e14; + color: #e8eaed; + overflow: auto; + padding: 24px 16px 48px; +} +.splash-bg { + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 80% 50% at 50% -10%, rgba(33, 150, 243, 0.25), transparent), + radial-gradient(ellipse 60% 40% at 100% 80%, rgba(255, 213, 79, 0.08), transparent), + linear-gradient(180deg, #0d1117 0%, #0a0e14 100%); + pointer-events: none; +} +.splash-chart-deco { + position: absolute; + left: 0; + right: 0; + bottom: 18%; + height: 140px; + opacity: 0.55; + pointer-events: none; +} +.splash-chart-svg { width: 100%; height: 100%; } +.splash-line { + stroke-dasharray: 600; + animation: splash-draw 2.5s ease-out forwards; +} +@keyframes splash-draw { + from { stroke-dashoffset: 600; } + to { stroke-dashoffset: 0; } +} +.splash-inner { + position: relative; + z-index: 1; + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} +.splash-brand { text-align: center; } +.splash-logo { margin-bottom: 12px; filter: drop-shadow(0 4px 20px rgba(33, 150, 243, 0.4)); } +.splash-title { + margin: 0; + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #fff 0%, #90caf9 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.splash-tagline { + margin: 8px 0 0; + font-size: 14px; + color: #9aa0a6; + line-height: 1.5; +} +.splash-features { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + width: 100%; + font-size: 12px; + color: #b0b8c4; +} +.splash-features li { + display: flex; + align-items: center; + gap: 6px; +} +.splash-feat-icon { font-size: 14px; } +.splash-card { + width: 100%; + padding: 24px; + background: rgba(22, 28, 36, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(12px); +} +.splash-card-title { margin: 0 0 6px; font-size: 18px; font-weight: 600; } +.splash-card-desc { margin: 0 0 16px; font-size: 13px; color: #9aa0a6; line-height: 1.5; } +.splash-form { display: flex; flex-direction: column; gap: 12px; } +.splash-form-row { display: flex; flex-direction: column; gap: 10px; } +.splash-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #9aa0a6; } +.splash-field input { + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: #0d1117; + color: #e8eaed; + font-size: 14px; +} +.splash-field input:focus { + outline: none; + border-color: #2196f3; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.25); +} +.splash-error { margin: 0; font-size: 12px; color: #ef5350; } +.splash-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.splash-btn { + flex: 1; + min-width: 120px; + padding: 11px 18px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + transition: background 0.15s, transform 0.1s; +} +.splash-btn:disabled { opacity: 0.55; cursor: not-allowed; } +.splash-btn--primary { + background: #2196f3; + color: #fff; +} +.splash-btn--primary:hover:not(:disabled) { background: #42a5f5; } +.splash-btn--guest { + background: transparent; + color: #90caf9; + border: 1px solid rgba(33, 150, 243, 0.5); +} +.splash-btn--guest:hover:not(:disabled) { + background: rgba(33, 150, 243, 0.12); + border-color: #42a5f5; +} +.splash-footnote { + margin: 14px 0 0; + font-size: 11px; + color: #6b7280; + text-align: center; +} +.splash-footnote code { + padding: 1px 5px; + background: rgba(255, 255, 255, 0.06); + border-radius: 3px; + font-size: 10px; +} +.splash-footer { + position: relative; + z-index: 1; + margin-top: auto; + padding-top: 24px; + font-size: 11px; + color: #4b5563; +} +.tmb-guest-badge { + font-size: 12px; + font-weight: 600; + color: #ffc107; + padding: 0 8px; + border: 1px solid rgba(255, 193, 7, 0.35); + border-radius: 10px; + background: rgba(255, 193, 7, 0.1); +} +@media (min-width: 520px) { + .splash-form-row { flex-direction: row; } + .splash-form-row .splash-field { flex: 1; } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8e15c12 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,2212 @@ +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import DrawingToolbar from './components/DrawingToolbar'; +import BottomBar from './components/BottomBar'; +import TradingChart from './components/TradingChart'; +import StatsPanel from './components/StatsPanel'; +import WatchList from './components/WatchList'; +import AlertManager from './components/AlertManager'; +import ObjectTree from './components/ObjectTree'; +import IndicatorContextToolbar from './components/IndicatorContextToolbar'; +import IndicatorSettingsModal from './components/IndicatorSettingsModal'; +import IndicatorBulkSettingsModal from './components/IndicatorBulkSettingsModal'; +import MainChartSettingsModal from './components/MainChartSettingsModal'; +import DrawingContextToolbar from './components/DrawingContextToolbar'; +import DrawingSettingsModal from './components/DrawingSettingsModal'; +import FibTZSettingsModal from './components/FibTZSettingsModal'; +import ChartSlot, { type ChartSlotHandle } from './components/ChartSlot'; +import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from './utils/layoutTypes'; +import { generateBars, formatPrice, SYMBOLS } from './utils/dataGenerator'; +import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from './utils/timezone'; +import { calcStats, type PriceStats } from './utils/calculations'; +import { getKoreanName } from './utils/marketNameCache'; +import { saveState, loadState, DEFAULT_MAIN_CHART_STYLE } from './utils/storage'; +import { useWorkspacePersist } from './hooks/useWorkspacePersist'; +import type { WatchlistItem, HoldingsItem } from './utils/backendApi'; +import { + addWatchlistItem, removeWatchlistItem, upsertHoldingsItem, removeHoldingsItem, + loadWatchlist, +} from './utils/backendApi'; +import { enrichIndicatorConfig, getIndicatorDef } from './utils/indicatorRegistry'; +import { initializeIndicatorConfigForEditor } from './utils/indicatorSettingsEditor'; +import { + buildMainIndicatorConfig, + indicatorSettingsTemplateId, + indicatorTypeFromSettingsId, + isIndicatorSettingsTemplateId, +} from './utils/indicatorMainConfig'; +import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig'; +import { normalizeIchimokuConfig } from './utils/ichimokuConfig'; +import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi'; +import { getFavorites, saveFavorites, FAVORITES_CHANGED_EVENT } from './utils/marketStorage'; +import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData'; +import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types'; +import type { ChartManager } from './utils/ChartManager'; +import { useHistoryLoader, LOAD_MORE_TRIGGER } from './hooks/useHistoryLoader'; +import { useMarketTicker } from './hooks/useMarketTicker'; +import { useUpbitOrderbook } from './hooks/useUpbitOrderbook'; +import { useIndicatorSettings } from './hooks/useIndicatorSettings'; +import { + duplicateIndicatorInList, + mergeIndicators, + reorderIndicatorGroup, + splitIndicatorPane, +} from './utils/indicatorPaneMerge'; +import { useAppSettings } from './hooks/useAppSettings'; +import MarketPanel from './components/MarketPanel'; +import TopMenuBar, { type MenuPage } from './components/TopMenuBar'; +import StrategyPage from './components/StrategyPage'; +import BacktestPanel from './components/BacktestPanel'; +import { useBacktest } from './hooks/useBacktest'; +import LiveStrategyPanel from './components/LiveStrategyPanel'; +import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; +import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier'; +import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings'; +import { AppNotificationLayer } from './components/AppNotificationLayer'; +import TradeNotificationListPage from './components/TradeNotificationListPage'; +import BacktestSettingsModal from './components/BacktestSettingsModal'; +import { BacktestResultModal } from './components/BacktestResultModal'; +import { BacktestHistoryPage } from './components/BacktestHistoryPage'; +import SettingsPage from './components/SettingsPage'; +import PaperTradingPage from './components/PaperTradingPage'; +import DashboardPage from './components/DashboardPage'; +import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings } from './utils/backendApi'; +import ChartLegendBar from './components/ChartLegendBar'; +import RightSidePanel from './components/RightSidePanel'; +import { + type BacktestSettingsDto, + type BacktestAnalysis, + DEFAULT_BACKTEST_SETTINGS, + loadBacktestSettings, + loadStrategies as loadStrategiesForLive, + type LiveStrategySettingsDto, +} from './utils/backendApi'; +import { useIsMobile } from './hooks/useMediaQuery'; +import MobileChartDock from './components/MobileChartDock'; +import LoginModal from './components/LoginModal'; +import SplashScreen from './components/SplashScreen'; +import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth'; +import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry'; +import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions'; +import { firstAllowedTopMenu, normalizeRole } from './utils/permissions'; +import { clearAdminUnlock } from './utils/adminUnlock'; +import type { LoginResponse } from './utils/backendApi'; +import { invalidateAppSettingsCache } from './hooks/useAppSettings'; +import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; +import './App.css'; + +let _indCounter = 0; +function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } + +// ─── 연결 상태 배지 ──────────────────────────────────────────────────────── +const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => { + if (!isUpbit) return null; + const map: Record = { + connecting: { dot: 'dot-yellow', label: '연결 중...' }, + connected: { dot: 'dot-lime', label: '실시간' }, + disconnected: { dot: 'dot-red', label: '재연결 중' }, + error: { dot: 'dot-red', label: '오류' }, + }; + const { dot, label } = map[status]; + return {label}; +}; + +/** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */ +let _wsFlashTimer: ReturnType | null = null; +function flashWsDot() { + document.querySelectorAll('.ws-dot').forEach(el => el.classList.add('receiving')); + if (_wsFlashTimer) clearTimeout(_wsFlashTimer); + _wsFlashTimer = setTimeout(() => { + document.querySelectorAll('.ws-dot').forEach(el => el.classList.remove('receiving')); + _wsFlashTimer = null; + }, 600); +} + +// ─── App ─────────────────────────────────────────────────────────────────── +function App() { + const initial = loadState(); + + const [sessionKey, setSessionKey] = useState(0); + const [appEntered, setAppEnteredState] = useState(() => isAppEntered()); + const [authUser, setAuthUser] = useState(() => + isAppEntered() ? getAuthSession() : null, + ); + const [guestMode, setGuestMode] = useState(() => + isAppEntered() && !getAuthSession(), + ); + const [loginOpen, setLoginOpen] = useState(false); + + // 전역 지표 파라미터 + 시각 설정 DB 동기화 + const { + getParams: getIndicatorParams, + saveParams: saveIndicatorParams, + getVisualConfig: getIndicatorVisual, + saveVisual: saveIndicatorVisual, + } = useIndicatorSettings(sessionKey); + const getParams = getIndicatorParams; + const saveParams = saveIndicatorParams; + const getVisualConfig = getIndicatorVisual; + const saveVisual = saveIndicatorVisual; + + // 앱 전역 기본 설정 DB 동기화 (하드코딩 대체) + const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey); + const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey); + const displayTimezone = useDisplayTimezone(); + + // DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨 + const [symbol, setSymbol] = useState(initial.symbol); + const [timeframe, setTimeframe] = useState(initial.timeframe); + const [chartType, setChartType] = useState(initial.chartType); + const [theme, setTheme] = useState(initial.theme); + const [mode, setMode] = useState('chart'); + const [logScale, setLogScale] = useState(false); + const [indicators, setIndicators] = useState(initial.indicators ?? []); + const [drawingTool, setDrawingTool] = useState('cursor'); + const [drawings, setDrawings] = useState([]); + const [drawingsLocked, setDrawingsLocked] = useState(false); + const [drawingsVisible, setDrawingsVisible] = useState(true); + const [indicatorsVisible, setIndicatorsVisible] = useState(true); + const [magnetMode, setMagnetMode] = useState<'off'|'weak'|'strong'>('off'); + const [snapToIndicator, setSnapToIndicator] = useState(false); + const [keepLockedOnDelete,setKeepLockedOnDelete]= useState(false); + const [legend, setLegend] = useState(null); + const [showStats, setShowStats] = useState(false); + const [showWatch, setShowWatch] = useState(false); + const [showAlert, setShowAlert] = useState(false); + const [showObjectTree, setShowObjectTree] = useState(false); + const [percentScale, setPercentScale] = useState(false); + const [showGrid, setShowGrid] = useState(true); + const [magnifierActive, setMagnifierActive] = useState(false); + const [showMarketPanel, setShowMarketPanel] = useState(false); + const [mobileRightOpen, setMobileRightOpen] = useState(false); + const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade'); + const [menuPage, setMenuPage] = useState('chart'); + const isMobile = useIsMobile(); + + // ── 마켓 패널 시세 데이터 ───────────────────────────────────────────── + const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(); + + // ── 레이아웃 상태 ───────────────────────────────────────────────────── + const [layoutDef, setLayoutDef] = useState(() => { + const saved = localStorage.getItem('chartLayout'); + return LAYOUTS.find(l => l.id === saved) ?? LAYOUTS[0]; + }); + const [syncOptions, setSyncOptions] = useState(() => { + try { + const s = localStorage.getItem('chartLayoutSync'); + return s ? JSON.parse(s) : DEFAULT_SYNC; + } catch { return DEFAULT_SYNC; } + }); + const [activeSlot, setActiveSlot] = useState(0); + // 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용) + const [activeSlotIndicators, setActiveSlotIndicators] = useState([]); + /** + * 단일→다중 전환 시 슬롯 0 의 마운트 초기값으로 전달할 설정. + * React 18 배치 업데이트 덕분에 setLayoutDef 와 동일 렌더에 반영되어 + * ChartSlot 이 처음 마운트될 때 이 값이 useState 초기값으로 사용된다. + */ + const [slot0InitProps, setSlot0InitProps] = useState<{ + symbol: string; + timeframe: Timeframe; + indicators: IndicatorConfig[]; + } | null>(null); + /** 멀티슬롯용: 슬롯 1+ 초기 props (슬롯 인덱스 → 초기값 맵) */ + const [slotInitProps, setSlotInitProps] = useState>({}); + const [syncTime, setSyncTime] = useState(0); + const [syncVisibleRange, setSyncVisibleRange] = useState<{ from: number; to: number } | null>(null); + const [syncLogicalRange, setSyncLogicalRange] = useState<{ from: number; to: number } | null>(null); + // 멀티 레이아웃에서 활성 슬롯의 타임프레임을 BottomBar/툴바에 반영 + const [activeSlotTf, setActiveSlotTf] = useState(timeframe); + const [activeSlotSymbol, setActiveSlotSymbol] = useState(initial.symbol); + + const handleTimezoneChange = useCallback((tz: string) => { + const next = normalizeTimezone(tz); + setDisplayTimezone(next); + saveAppDef({ displayTimezone: next }); + const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; + managerRef.current?.setDisplayTimezone(next, tf); + }, [saveAppDef, layoutDef.count, activeSlotTf, timeframe]); + + const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol; + const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol); + const chartTicker = marketTickers.get(chartSymbol); + const orderbookPrevClose = chartTicker + ? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0) + : 0; + + const [tradeFill, setTradeFill] = useState(null); + + const applyTradeFill = useCallback(( + req: { market: string; price: number; side: 'buy' | 'sell' }, + slotIndex?: number, + ) => { + setTradeFill(prev => ({ + market: req.market, + price: req.price, + side: req.side, + seq: (prev?.seq ?? 0) + 1, + })); + if (layoutDef.count > 1) { + const idx = slotIndex ?? activeSlot; + setActiveSlot(idx); + slotRefs.current[idx]?.setSymbol(req.market); + setActiveSlotSymbol(req.market); + } else if (req.market !== symbol) { + setSymbol(req.market); + setLegend(null); + } + }, [layoutDef.count, activeSlot, symbol]); + + const handleOrderbookPick = useCallback((price: number, rowType: 'ask' | 'bid') => { + applyTradeFill({ + market: chartSymbol, + price, + // 매수 호가(bid) 클릭 → 매수 탭, 매도 호가(ask) 클릭 → 매도 탭 + side: rowType === 'bid' ? 'buy' : 'sell', + }); + }, [applyTradeFill, chartSymbol]); + + const handleTradeMarketSelect = useCallback((s: string) => { + if (layoutDef.count > 1) { + slotRefs.current[activeSlot]?.setSymbol(s); + setActiveSlotSymbol(s); + } else { + setSymbol(s); + setLegend(null); + } + }, [layoutDef.count, activeSlot]); + + // 활성 슬롯 변경 시 해당 슬롯의 타임프레임/심볼/지표를 읽어 표시 업데이트 + const handleSetActiveSlot = useCallback((idx: number) => { + setActiveSlot(idx); + // symbolRef는 initialSymbol로 초기화되어 있으므로 즉시 읽어도 정확한 값 반환 + const slotImmediate = slotRefs.current[idx]; + if (slotImmediate) { + const sym = slotImmediate.getSymbol(); + if (sym) setActiveSlotSymbol(sym); + const tf = slotImmediate.getTimeframe(); + if (tf) setActiveSlotTf(tf); + setActiveSlotIndicators(slotImmediate.getIndicators()); + } else { + // 슬롯이 아직 마운트되지 않은 경우 rAF로 재시도 + requestAnimationFrame(() => { + const slot = slotRefs.current[idx]; + if (!slot) return; + const tf = slot.getTimeframe(); + if (tf) setActiveSlotTf(tf); + const sym = slot.getSymbol(); + if (sym) setActiveSlotSymbol(sym); + setActiveSlotIndicators(slot.getIndicators()); + }); + } + }, []); + + // 멀티 모드에서 활성 슬롯 지표가 바뀌면 상태 갱신 (IndicatorModal 반영용) + const handleActiveSlotIndicatorsChange = useCallback((inds: IndicatorConfig[]) => { + setActiveSlotIndicators(inds); + }, []); + + // 멀티 모드에서 활성 슬롯 심볼이 바뀌면 상태 갱신 (호가창 심볼 동기화) + const handleActiveSlotSymbolChange = useCallback((sym: string) => { + setActiveSlotSymbol(sym); + }, []); + + // Toolbar 에 전달할 활성 지표 목록: + // 멀티 레이아웃이면 활성 슬롯 지표, 싱글이면 전역 indicators + const toolbarActiveIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators; + + /** + * 레이아웃 전환 핸들러 + * ─ 단일 → 다중: 현재 싱글 차트의 심볼/타임프레임/지표를 슬롯 0 의 초기값으로 기록 + * ─ 다중 → 단일: 활성 슬롯의 심볼/타임프레임/지표를 싱글 차트 상태에 즉시 복원 + * (ChartSlot 이 unmount 되기 전에 ref 에서 읽어야 함) + */ + const handleLayoutSelect = useCallback((def: LayoutDef) => { + if (def.count > 1 && layoutDef.count === 1) { + // 단일 → 다중: 싱글 차트 설정을 슬롯 0 초기값으로 저장 + // setLayoutDef 와 같은 렌더에 배치되어 ChartSlot 마운트 시 반영됨 + setSlot0InitProps({ + symbol, + timeframe, + indicators: indicators.map(i => ({ ...i })), + }); + setActiveSlot(0); + setActiveSlotIndicators(indicators.map(i => ({ ...i }))); + } else if (def.count === 1 && layoutDef.count > 1) { + // 다중 → 단일: 활성 슬롯 설정 읽기 (slot ref 는 이 시점에서만 유효) + const slot = slotRefs.current[activeSlot]; + if (slot) { + const slotSymbol = slot.getSymbol(); + const slotTf = slot.getTimeframe(); + const slotInds = slot.getIndicators().map(i => ({ ...i })); + setSymbol(slotSymbol); + setTimeframe(slotTf); + setIndicators(slotInds); + setLegend(null); + } + // 다음번 다중 전환 시 초기값이 남아있지 않도록 초기화 + setSlot0InitProps(null); + } + setLayoutDef(def); + // 레이아웃 변경 시 DB 에도 저장 + saveAppDef({ defaultLayoutId: def.id }); + }, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]); + + // 레이아웃/싱크 변경 시 localStorage 저장 + useEffect(() => { + localStorage.setItem('chartLayout', layoutDef.id); + }, [layoutDef]); + useEffect(() => { + localStorage.setItem('chartLayoutSync', JSON.stringify(syncOptions)); + }, [syncOptions]); + + // 다중 → 싱글 전환 시 activeSlotIndicators 초기화 + useEffect(() => { + if (layoutDef.count <= 1) setActiveSlotIndicators([]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layoutDef.count]); + + // ── 시리즈 클릭/호버 컨텍스트 툴바 ──────────────────────────────────── + const [ctxToolbar, setCtxToolbar] = useState<{ + entryId: string; + screenX: number; + screenY: number; + } | null>(null); + // 호버 hide 지연 타이머 (레이블 → 툴바 이동 시 깜빡임 방지) + const hoverHideTimer = useRef | null>(null); + + const clearHoverTimer = useCallback(() => { + if (hoverHideTimer.current) { clearTimeout(hoverHideTimer.current); hoverHideTimer.current = null; } + }, []); + + const handleHoverPaneLegend = useCallback((id: string, sx: number, sy: number) => { + clearHoverTimer(); + setCtxToolbar({ entryId: id, screenX: sx, screenY: sy }); + }, [clearHoverTimer]); + + const handleLeavePaneLegend = useCallback(() => { + clearHoverTimer(); + hoverHideTimer.current = setTimeout(() => { + setCtxToolbar(null); + hoverHideTimer.current = null; + }, 180); + }, [clearHoverTimer]); + + // ── 드로잉 컨텍스트 툴바 & 설정 모달 ──────────────────────────────────── + const [selectedDrawingId, setSelectedDrawingId] = useState(null); + const [drawingCtxToolbar, setDrawingCtxToolbar] = useState<{ + id: string; screenX: number; screenY: number; + } | null>(null); + const [drawingSettingsId, setDrawingSettingsId] = useState(null); + + const handleDrawingClick = useCallback((id: string | null, sx: number, sy: number) => { + if (!id) { + // 빈 공간 클릭 → 선택 해제 + setSelectedDrawingId(null); + setDrawingCtxToolbar(null); + return; + } + setSelectedDrawingId(id); + setDrawingCtxToolbar({ id, screenX: sx, screenY: sy }); + setCtxToolbar(null); // 인디케이터 툴바 닫기 + }, []); + + const handleDrawingDoubleClick = useCallback((id: string, sx: number, sy: number) => { + setSelectedDrawingId(id); + setDrawingCtxToolbar(null); + setDrawingSettingsId(id); + void sx; void sy; + }, []); + + // ── 인디케이터 설정 모달 ─────────────────────────────────────────────── + const [settingsModalId, setSettingsModalId] = useState(null); + const [showBulkIndSettings, setShowBulkIndSettings] = useState(false); + + // ── 메인 차트 설정 ───────────────────────────────────────────────────── + const [mainChartStyle, setMainChartStyle] = useState( + initial.mainChartStyle ?? DEFAULT_MAIN_CHART_STYLE + ); + const [showMainChartModal, setShowMainChartModal] = useState(false); + + const handleMainChartSave = useCallback((style: MainChartStyle) => { + setMainChartStyle(style); + saveState({ mainChartStyle: style }); + setShowMainChartModal(false); + // DB 에도 저장 → 다음 로드 시 하드코딩 기본값 대신 사용 + saveAppDef({ mainChartStyle: style as unknown as Record }); + }, [saveAppDef]); + + // ── 드로잉 Undo/Redo 스택 ──────────────────────────────────────────────── + const undoStack = useRef([]); + const redoStack = useRef([]); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + const pushDrawing = useCallback((newDrawings: Drawing[]) => { + undoStack.current.push(drawings); + redoStack.current = []; + setDrawings(newDrawings); + setCanUndo(true); + setCanRedo(false); + }, [drawings]); + + const handleUndo = useCallback(() => { + if (undoStack.current.length === 0) return; + redoStack.current.push(drawings); + const prev = undoStack.current.pop()!; + setDrawings(prev); + setCanUndo(undoStack.current.length > 0); + setCanRedo(true); + }, [drawings]); + + const handleRedo = useCallback(() => { + if (redoStack.current.length === 0) return; + undoStack.current.push(drawings); + const next = redoStack.current.pop()!; + setDrawings(next); + setCanUndo(true); + setCanRedo(redoStack.current.length > 0); + }, [drawings]); + const [stats, setStats] = useState(null); + const [watchlist, setWatchlist] = useState(() => { + const base = initial.watchlist?.length + ? initial.watchlist + : [...UPBIT_MARKETS.slice(0, 5), ...SYMBOLS.slice(0, 4)]; + return [...new Set([...base, ...getFavorites()])]; + }); + /** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */ + const [favoritesList, setFavoritesList] = useState(getFavorites); + // DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작) + const [dbLoaded, setDbLoaded] = useState(false); + + const enterMainApp = useCallback(() => { + setAppEntered(); + setAppEnteredState(true); + }, []); + + const handleLoginSuccess = useCallback((res: LoginResponse) => { + const session: AuthSession = { + userId: res.userId, + username: res.username, + displayName: res.displayName, + role: normalizeRole(res.role), + }; + setAuthSession(session); + setAuthUser(session); + setGuestMode(false); + setDbLoaded(false); + clearAdminUnlock(); + invalidateAppSettingsCache(); + invalidateIndicatorSettingsCache(); + invalidateMenuPermissionsCache(); + setSessionKey(k => k + 1); + enterMainApp(); + }, [enterMainApp]); + + const handleGuestEnter = useCallback(() => { + clearAuthSession(); + setAuthUser(null); + setGuestMode(true); + setDbLoaded(false); + clearAdminUnlock(); + invalidateAppSettingsCache(); + invalidateIndicatorSettingsCache(); + invalidateMenuPermissionsCache(); + setSessionKey(k => k + 1); + enterMainApp(); + }, [enterMainApp]); + + const handleLogout = useCallback(() => { + clearAuthSession(); + setAuthUser(null); + setGuestMode(false); + setDbLoaded(false); + clearAdminUnlock(); + invalidateAppSettingsCache(); + invalidateIndicatorSettingsCache(); + invalidateMenuPermissionsCache(); + setSessionKey(k => k + 1); + clearAppEntered(); + setAppEnteredState(false); + setLoginOpen(false); + }, []); + + const guardedSetMenuPage = useCallback((page: MenuPage) => { + if (page === 'notifications' && !canMenu('notifications')) return; + if (page !== 'notifications' && !canMenu(page)) { + setMenuPage(firstAllowedTopMenu(menuPermissions)); + return; + } + setMenuPage(page); + }, [canMenu, menuPermissions]); + + useEffect(() => { + if (!menuPermsLoaded) return; + if (menuPage === 'notifications') { + if (!canMenu('notifications')) setMenuPage(firstAllowedTopMenu(menuPermissions)); + return; + } + if (!canMenu(menuPage)) { + setMenuPage(firstAllowedTopMenu(menuPermissions)); + } + }, [menuPage, menuPermsLoaded, menuPermissions, canMenu]); + + // 앱 설정 DB 로드 완료 시: 워크스페이스 로드 전 localStorage 기본값 일부만 교체 + // layoutId 는 워크스페이스가 우선 — 로그인 직후 멀티 레이아웃으로 잠깐 전환되며 캔들이 비는 현상 방지 + useEffect(() => { + if (!appSettingsLoaded) return; + if (dbLoaded) return; + if (appDefaults.mainChartStyle) setMainChartStyle(appDefaults.mainChartStyle); + if (appDefaults.syncOptions) setSyncOptions(appDefaults.syncOptions); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appSettingsLoaded, dbLoaded]); + + const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true; + const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true; + const chartLegendOptions = appDefaults.chartLegendOptions; + const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; + const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; + const [paperCashBalance, setPaperCashBalance] = useState(0); + const [paperCoinQty, setPaperCoinQty] = useState(0); + const [paperRefreshKey, setPaperRefreshKey] = useState(0); + + const refreshPaperAccount = useCallback(async (forMarket?: string) => { + const summary = await loadPaperSummary(); + if (!summary) return; + setPaperCashBalance(summary.cashBalance); + const m = forMarket ?? symbol; + const pos = summary.positions?.find(p => p.symbol === m); + setPaperCoinQty(pos?.quantity ?? 0); + }, [symbol]); + + useEffect(() => { + if (appSettingsLoaded) refreshPaperAccount(); + }, [appSettingsLoaded, refreshPaperAccount]); + + useEffect(() => { + if (!appSettingsLoaded || !appDefaults.fcmPushEnabled) return; + import('./utils/fcmPush').then(({ initFcmPush }) => { + void initFcmPush((title, body) => { + console.info('[FCM]', title, body); + }); + }); + }, [appSettingsLoaded, appDefaults.fcmPushEnabled]); + + useEffect(() => { + if (appSettingsLoaded && paperTradingEnabled) refreshPaperAccount(symbol); + }, [symbol, appSettingsLoaded, paperTradingEnabled, refreshPaperAccount]); + + const [alerts, setAlerts] = useState>( + (initial.alertPrices ?? []).map(p => ({ price: p, label: '' })) + ); + + const managerRef = useRef(null); + /** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */ + const pendingRealtimeBarRef = useRef(null); + const useUpbit = isUpbitMarket(symbol); + + // ── 백테스팅 ─────────────────────────────────────────────────────────────── + const { signals: btSignals, stats: btStats, analysis: btAnalysis, running: btRunning, run: btRun, clear: btClear } = useBacktest(); + const [btSettings, setBtSettings] = useState(DEFAULT_BACKTEST_SETTINGS); + const [showBtSettings, setShowBtSettings] = useState(false); + const [showBtResult, setShowBtResult] = useState(false); + const [btStrategyName, setBtStrategyName] = useState('전략'); + + // ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ───────────────── + const [showLivePanel, setShowLivePanel] = useState(false); + const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]); + + const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({ + market: symbol, + strategyId: appDefaults.liveStrategyId ?? null, + isLiveCheck: appDefaults.liveStrategyCheck ?? false, + executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', + positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', + }), [symbol, appDefaults]); + + /** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */ + const monitoredMarkets = useMemo(() => { + if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; + const set = new Set(watchlist); + if (symbol) set.add(symbol); + return [...set]; + }, [watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]); + + const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]); + + /** STOMP 구독 목록 — API 로드 전에도 관심종목 기준으로 바로 구독 (연결 끊김 방지) */ + const liveStompSubscriptions = useMemo(() => { + if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; + if (marketSubscriptions.length > 0) return marketSubscriptions; + return monitoredMarkets.map(m => ({ market: m, candleType: '1m' as const })); + }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]); + + useEffect(() => { + if (!appDefaults.liveStrategyCheck) { + setMarketSubscriptions([]); + return; + } + loadActiveLiveStrategySettings() + .then(list => setMarketSubscriptions( + list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })), + )) + .catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ }); + }, [appDefaults.liveStrategyCheck, monitoredMarkets.join(',')]); + + const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => { + saveAppDef({ + liveStrategyCheck: saved.isLiveCheck, + liveStrategyId: saved.strategyId ?? undefined, + liveExecutionType: saved.executionType, + livePositionMode: saved.positionMode, + }); + }, [saveAppDef]); + + const prevFavoritesRef = useRef(getFavorites()); + + /** 별표 토글 → localStorage·워치리스트 동기화 */ + useEffect(() => { + const onFavoritesChanged = (e: Event) => { + const markets = (e as CustomEvent).detail ?? getFavorites(); + prevFavoritesRef.current = markets; + setFavoritesList(markets); + setWatchlist(markets); + }; + window.addEventListener(FAVORITES_CHANGED_EVENT, onFavoritesChanged); + return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onFavoritesChanged); + }, []); + + /** 기존 localStorage 관심종목 → DB 1회 이전 */ + useEffect(() => { + if (!dbLoaded) return; + let cancelled = false; + (async () => { + try { + const items = await loadWatchlist(); + if (cancelled) return; + const dbSyms = items.map(i => i.symbol); + if (dbSyms.length > 0) { + setWatchlist(dbSyms); + setFavoritesList(dbSyms); + saveFavorites(dbSyms, false); + return; + } + const local = getFavorites(); + if (local.length === 0) return; + for (const s of local) { + try { await addWatchlistItem({ symbol: s }); } catch { /* ignore */ } + } + const refreshed = await loadWatchlist(); + if (cancelled) return; + const syms = refreshed.map(i => i.symbol); + setWatchlist(syms); + setFavoritesList(syms); + saveFavorites(syms, false); + } catch { /* ignore */ } + })(); + return () => { cancelled = true; }; + }, [dbLoaded]); + + const liveStrategyName = useMemo( + () => liveStrategies.find(s => s.id === appDefaults.liveStrategyId)?.name ?? null, + [liveStrategies, appDefaults.liveStrategyId], + ); + + const goToMarketChart = useCallback((market: string) => { + if (layoutDef.count > 1) { + slotRefs.current[activeSlot]?.setSymbol(market); + if (syncOptions.symbol) { + slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setSymbol(market)); + } + } else { + setSymbol(market); + setLegend(null); + } + }, [layoutDef.count, activeSlot, syncOptions.symbol]); + + // 전략 목록 로드 (실시간 패널용) + // menuPage가 chart로 복귀하거나 실시간 패널이 열릴 때마다 재로드하여 + // 전략 설정 화면에서 추가/수정된 전략이 즉시 반영되도록 한다. + const refreshLiveStrategies = useCallback(() => { + loadStrategiesForLive() + .then(list => setLiveStrategies( + list + .filter(s => s.id != null) + .map(s => ({ id: s.id as number, name: s.name })) + )) + .catch(() => {}); + }, []); + + useEffect(() => { + refreshLiveStrategies(); + }, [menuPage, showLivePanel, refreshLiveStrategies]); + + const liveNotifierRef = useRef(null); + const clearLiveMarkers = useCallback(() => { + liveNotifierRef.current?.clearMarkers(); + managerRef.current?.clearLiveStrategyMarkers(); + }, []); + + useEffect(() => { + loadBacktestSettings().then(s => { if (s) setBtSettings(s); }); + }, []); + + // 단일차트 모드: 과거 데이터 추가 로드 + const { isLoadingMore: isSingleLoadingMore, loadMoreRef: singleLoadMoreRef } = useHistoryLoader({ + symbol, timeframe, isUpbit: useUpbit, managerRef, + }); + + // 개발 환경: 브라우저 콘솔에서 window.__chartMgr__.logIndicatorLastValues() 로 + // 각 지표의 실제 계산값을 확인 가능 (갱신 여부 진단용) + useEffect(() => { + if (import.meta.env.DEV) { + (window as unknown as Record)['__chartMgr__'] = managerRef; + } + }, []); + + // 멀티 레이아웃: 각 슬롯 ref 배열 (최대 8개) + const slotRefs = useRef<(ChartSlotHandle | null)[]>(Array(8).fill(null)); + + useEffect(() => { + if (!appSettingsLoaded) return; + const tf = layoutDef.count > 1 ? activeSlotTf : timeframe; + managerRef.current?.setDisplayTimezone(displayTimezone, tf); + }, [appSettingsLoaded, displayTimezone, timeframe, activeSlotTf, layoutDef.count]); + + useEffect(() => { + if (!appSettingsLoaded) return; + managerRef.current?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); + slotRefs.current.forEach(slot => slot?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels)); + }, [appSettingsLoaded, chartSeriesPriceLabels]); + + useEffect(() => { + if (!appSettingsLoaded) return; + const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; + const h = wrapper?.clientHeight; + managerRef.current?.setVolumeVisible(chartVolumeVisible, h); + slotRefs.current.forEach(slot => slot?.setVolumeVisible(chartVolumeVisible)); + }, [appSettingsLoaded, chartVolumeVisible]); + + // mainChartStyle 변경 → ChartManager 즉시 적용 + useEffect(() => { + managerRef.current?.updateMainSeriesStyle(mainChartStyle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainChartStyle]); + + // 백테스팅 시그널 → ChartManager 마커 업데이트 + useEffect(() => { + if (!managerRef.current) return; + if (btSignals.length > 0) { + managerRef.current.setBacktestMarkers(btSignals, appDefaults.btShowPrice); + } else { + managerRef.current.clearBacktestMarkers(); + } + }, [btSignals, appDefaults.btShowPrice]); + + // ── 시뮬레이션 데이터 (Upbit 아닐 때) ────────────────────────────────── + const [simBars, setSimBars] = useState(() => + !isUpbitMarket(initial.symbol) ? generateBars(initial.symbol, initial.timeframe) : [] + ); + useEffect(() => { + if (useUpbit) return; + const b = generateBars(symbol, timeframe); + setSimBars(b); + setStats(calcStats(b)); + setLegend(null); + }, [symbol, timeframe, useUpbit]); + + // ── Upbit 실시간 데이터 ───────────────────────────────────────────────── + // 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신 + const handleTickUpdate = useCallback((bar: OHLCVBar) => { + if (!managerRef.current) { + pendingRealtimeBarRef.current = bar; + flashWsDot(); + return; + } + managerRef.current.updateBar(bar); + flashWsDot(); // 수신 중 형광 glow (DOM 직접 조작, re-render 없음) + }, []); + + // 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신 + const handleNewCandle = useCallback((newBar: OHLCVBar) => { + if (!managerRef.current) { + pendingRealtimeBarRef.current = newBar; + flashWsDot(); + return; + } + managerRef.current.appendBar(newBar); + flashWsDot(); + }, []); + + const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP'; + const { bars: upbitBars, latestBar, wsStatus, isLoading, error } = useChartRealtimeData( + symbol, + timeframe, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: useUpbit, + source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', + }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), + ); + + // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) + const bars = useUpbit ? upbitBars : simBars; + + // Upbit 초기 로딩 완료 시 stats 업데이트 + useEffect(() => { + if (useUpbit && upbitBars.length > 0) { + setStats(calcStats(upbitBars)); + setLegend(null); + } + }, [useUpbit, upbitBars]); + + // ── Watchlist 가격 맵 ────────────────────────────────────────────────── + const priceMap = useMemo(() => { + const map: Record = {}; + for (const sym of watchlist) { + const b = isUpbitMarket(sym) ? [] : generateBars(sym, '1D'); + if (b.length >= 2) { + map[sym] = { price: b[b.length-1].close, changePct: (b[b.length-1].close - b[b.length-2].close) / b[b.length-2].close * 100 }; + } + } + return map; + }, [watchlist]); + + // ── 상태 저장 (localStorage) ─────────────────────────────────────────── + useEffect(() => { + saveState({ symbol, timeframe, chartType, theme, indicators, watchlist, alertPrices: alerts.map(a => a.price) }); + }, [symbol, timeframe, chartType, theme, indicators, watchlist, alerts]); + + // ── DB 워크스페이스 동기화 ───────────────────────────────────────────── + const workspaceState = useMemo(() => ({ + layoutId: layoutDef.id, + syncOptions, + slots: [{ + slotIndex: 0, + symbol, timeframe, chartType, theme, mode, logScale, + indicators, drawings, drawingsLocked, drawingsVisible, + mainChartStyle, + }], + }), [layoutDef.id, syncOptions, symbol, timeframe, chartType, theme, + mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle]); + + const { isLoaded: _wsLoaded } = useWorkspacePersist({ + sessionKey, + state: workspaceState, + onLoad: (data) => { + try { + if (data.layoutId) { + const def = LAYOUTS.find(l => l.id === data.layoutId); + if (def) setLayoutDef(def); + } + if (data.syncOptions) setSyncOptions(data.syncOptions as typeof syncOptions); + const slot0 = Array.isArray(data.slots) && data.slots.length > 0 + ? (data.slots[0] as Record) + : null; + if (slot0) { + if (slot0.symbol) setSymbol(slot0.symbol as string); + if (slot0.timeframe) setTimeframe(slot0.timeframe as Timeframe); + if (slot0.chartType) setChartType(slot0.chartType as ChartType); + if (slot0.theme) setTheme(slot0.theme as Theme); + if (slot0.mode) setMode(slot0.mode as ChartMode); + if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale)); + if (Array.isArray(slot0.indicators)) { + setIndicators((slot0.indicators as IndicatorConfig[]).map(ind => { + if (ind.type === 'SMA') return normalizeSmaConfig(ind); + if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); + return ind; + })); + } + if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]); + if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked)); + if (slot0.drawingsVisible != null) setDrawingsVisible(Boolean(slot0.drawingsVisible)); + if (slot0.mainChartStyle) setMainChartStyle(slot0.mainChartStyle as MainChartStyle); + } + // 슬롯 1+ 초기 props 수집 (멀티슬롯 전환 시 각 ChartSlot 에 전달) + if (Array.isArray(data.slots) && data.slots.length > 1) { + const extra: typeof slotInitProps = {}; + for (let i = 1; i < data.slots.length; i++) { + const s = data.slots[i] as Record; + extra[i] = { + symbol: s.symbol as string | undefined, + timeframe: s.timeframe as Timeframe | undefined, + indicators: Array.isArray(s.indicators) + ? (s.indicators as IndicatorConfig[]).map(ind => { + if (ind.type === 'SMA') return normalizeSmaConfig(ind); + if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); + return ind; + }) + : undefined, + mainChartStyle: s.mainChartStyle as MainChartStyle | undefined, + }; + } + setSlotInitProps(extra); + } + } catch (e) { console.warn('[DB load] parse error', e); } + setDbLoaded(true); + }, + onWatchlistLoad: (items: WatchlistItem[]) => { + const syms = items.map(i => i.symbol); + if (syms.length > 0) { + setWatchlist(syms); + setFavoritesList(syms); + saveFavorites(syms, false); + } + }, + noAutoSave: !dbLoaded, + }); + + /** 로그인·로그아웃 시에만 차트 재마운트 (boot/ready·심볼 변경은 TradingChart effect로 처리) */ + const chartMountKey = useMemo(() => String(sessionKey), [sessionKey]); + + // DB에 워크스페이스 없음(204) → appDefaults 레이아웃 적용 후 저장 허용 + useEffect(() => { + if (!_wsLoaded || dbLoaded) return; + setDbLoaded(true); + const def = LAYOUTS.find(l => l.id === appDefaults.layoutId); + if (def) setLayoutDef(def); + }, [_wsLoaded, dbLoaded, appDefaults.layoutId]); + + // ── 키보드 단축키 ───────────────────────────────────────────────────── + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.key === 'Escape') { + setDrawingTool('cursor'); + setSelectedDrawingId(null); + setDrawingCtxToolbar(null); + } + if (e.key === 'f' || e.key === 'F') managerRef.current?.fitContent(); + if (e.key === 'l' || e.key === 'L') setLogScale(v => !v); + if (e.altKey && e.key === 's') handleScreenshot(); + if (mode === 'trading') { + if (e.key === 'h' || e.key === 'H') setDrawingTool('hline'); + if (e.key === 'v' || e.key === 'V') setDrawingTool('vline'); + if (e.key === 't' || e.key === 'T') setDrawingTool('trendline'); + if (e.key === 'm' || e.key === 'M') setDrawingTool('measure'); + if (e.key === 'r' || e.key === 'R') setDrawingTool('rect'); + if (e.key === 'z' && !e.ctrlKey) setDrawingTool('fibtz'); + if (e.ctrlKey && e.key === 'z') { e.preventDefault(); handleUndo(); } + if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); handleRedo(); } + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + useEffect(() => { + if (mode === 'chart' && drawingTool !== 'zoom' && drawingTool !== 'magnifier') { + setDrawingTool('cursor'); + } + }, [mode]); + + useEffect(() => { + if (menuPage !== 'chart') { + setShowMarketPanel(false); + setMobileRightOpen(false); + } + }, [menuPage]); + + useEffect(() => { + if (!isMobile) setMobileRightOpen(false); + }, [isMobile]); + + // percentScale / showGrid 변경 시 ChartManager 에 즉시 반영 + useEffect(() => { managerRef.current?.setPercentScale(percentScale); }, [percentScale]); + useEffect(() => { managerRef.current?.setGrid(showGrid); }, [showGrid]); + + // ── 핸들러 ──────────────────────────────────────────────────────────── + + /** Time sync: 한 슬롯의 가시 시간 범위가 바뀌면 나머지에 전파 */ + const handleVisibleRangeChange = useCallback((r: { from: number; to: number }) => { + if (syncOptions.time) setSyncVisibleRange(r); + }, [syncOptions.time]); + + /** Date range sync: 한 슬롯의 가시 논리 범위가 바뀌면 나머지에 전파 */ + const handleLogicalRangeChange = useCallback((r: { from: number; to: number }) => { + if (syncOptions.dateRange) setSyncLogicalRange(r); + }, [syncOptions.dateRange]); + + /** + * 멀티차트 슬롯 전체 보기: + * 해당 슬롯의 심볼/타임프레임/지표를 싱글 차트에 적용하고 싱글 레이아웃으로 전환한다. + */ + const handleSlotFullView = useCallback((slotSymbol: string, slotIdx: number) => { + const slot = slotRefs.current[slotIdx]; + if (slot) { + setSymbol(slot.getSymbol()); + setTimeframe(slot.getTimeframe()); + setIndicators(slot.getIndicators().map(i => ({ ...i }))); + } else { + setSymbol(slotSymbol); + } + setLegend(null); + setSlot0InitProps(null); + setLayoutDef(LAYOUTS[0]); // 싱글 레이아웃(id: '1') + }, []); + + // ── 보조지표 단독 전체화면 모드 ──────────────────────────────────────────── + const [focusedIndicatorId, setFocusedIndicatorId] = useState(null); + + useEffect(() => { + setFocusedIndicatorId(null); + }, [sessionKey]); + + /** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) */ + const effectiveIndicators = useMemo(() => { + if (!focusedIndicatorId || layoutDef.count > 1) return indicators; + return indicators.filter(ind => { + const def = getIndicatorDef(ind.type); + return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId; + }); + }, [focusedIndicatorId, indicators, layoutDef.count]); + + const handleExpandIndicator = useCallback((id: string) => { + setFocusedIndicatorId(id); + }, []); + + const handleRestoreIndicators = useCallback(() => { + setFocusedIndicatorId(null); + }, []); + + /** + * 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동. + * overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨. + */ + const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => { + setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev)); + }, []); + + const handleMergeIndicators = useCallback((fromId: string, intoId: string) => { + setIndicators(prev => mergeIndicators(fromId, intoId, prev)); + }, []); + + const handleSplitIndicatorPane = useCallback((hostId: string) => { + setIndicators(prev => splitIndicatorPane(hostId, prev)); + }, []); + + /** + * 지표 추가: + * - 싱글 레이아웃: 메인 차트에 추가 + * - 멀티 레이아웃: 활성 슬롯(ChartSlot ref)에 추가 + */ + const applyChartIndicators = useCallback((chartIndicators: IndicatorConfig[]) => { + if (layoutDef.count > 1) { + slotRefs.current[activeSlot]?.setIndicators(chartIndicators); + setActiveSlotIndicators(chartIndicators.map(i => ({ ...i }))); + } else { + setIndicators(chartIndicators); + } + }, [layoutDef.count, activeSlot]); + + const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => { + if (layoutDef.count > 1) { + const slotRef = slotRefs.current[activeSlot]; + if (slotRef) { slotRef.addIndicator(type, fromConfig); return; } + } + setIndicators(prev => { + if (prev.some(i => i.type === type)) return prev; + const def = getIndicatorDef(type); + if (!def) return prev; + let cfg: IndicatorConfig; + if (fromConfig) { + cfg = { ...fromConfig, id: newIndId(), type: def.type }; + } else { + const params = getParams(type, def.defaultParams); + const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + cfg = { + id: newIndId(), + type: def.type, + params, + plots, + hlines, + ...(cloudColors ? { cloudColors } : {}), + }; + } + if (type === 'SMA') { + cfg = normalizeSmaConfig({ + ...cfg, + plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(), + }); + } else if (type === 'IchimokuCloud') { + cfg = normalizeIchimokuConfig(cfg); + } + return [...prev, cfg]; + }); + }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); + + /** + * 지표 제거 (type 기준): + * - 싱글: 메인 차트에서 제거 + * - 멀티: 활성 슬롯에서 제거 + */ + const handleRemoveByType = useCallback((type: string) => { + if (layoutDef.count > 1) { + const slotRef = slotRefs.current[activeSlot]; + if (slotRef) { slotRef.removeIndicatorByType(type); return; } + } + setIndicators(prev => prev.filter(i => i.type !== type)); + }, [layoutDef.count, activeSlot]); + + /** + * 타임프레임 변경: + * - 싱글: 메인 차트 변경 + * - 멀티: 활성 슬롯 변경 (Interval Sync 시 전체 슬롯) + */ + // magnetMode 변경 → 싱글차트 + 멀티슬롯 전체 크로스헤어 모드 즉시 반영 + useEffect(() => { + const enabled = magnetMode !== 'off'; + // 싱글 레이아웃 메인 차트 + managerRef.current?.setCrosshairMagnet(enabled); + // 멀티 레이아웃 슬롯 전체 + slotRefs.current.forEach(slot => slot?.setCrosshairMagnet(enabled)); + }, [magnetMode]); + + // 테마 순환: dark → blue → light → dark, DB 에 저장 + const handleThemeToggle = useCallback(() => { + setTheme(t => { + const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark'; + saveAppDef({ defaultTheme: next }); + return next; + }); + }, [saveAppDef]); + + const handleTimeframe = useCallback((tf: Timeframe) => { + if (layoutDef.count > 1) { + slotRefs.current[activeSlot]?.setTimeframe(tf); + setActiveSlotTf(tf); + if (syncOptions.interval) { + slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setTimeframe(tf)); + } + } else { + setTimeframe(tf); + } + // 타임프레임 변경 → DB 기본값 갱신 + saveAppDef({ defaultTimeframe: tf }); + }, [layoutDef.count, activeSlot, syncOptions.interval, saveAppDef]); + + const chartIndicatorsForSettings = layoutDef.count > 1 ? activeSlotIndicators : indicators; + + /** 지표 추가 팝업 — 항목별 설정 모달 */ + const handleOpenIndicatorSettings = useCallback((type: string) => { + const onChart = chartIndicatorsForSettings.find(i => i.type === type); + setSettingsModalId(onChart ? onChart.id : indicatorSettingsTemplateId(type)); + setCtxToolbar(null); + }, [chartIndicatorsForSettings]); + + /** 지표 설정 저장 (params/plots/plotVisibility 변경) → 자동으로 재렌더링 */ + const handleIndicatorSave = useCallback((updated: IndicatorConfig) => { + const fromTemplate = + isIndicatorSettingsTemplateId(settingsModalId ?? '') + || updated.id.startsWith('template_'); + saveParams(updated.type, updated.params); + saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors); + + const applyUpdate = (prev: IndicatorConfig[]): IndicatorConfig[] => { + if (fromTemplate) { + const merged = enrichIndicatorConfig(updated); + return prev.map(ind => + ind.type === updated.type + ? { ...merged, id: ind.id, hidden: ind.hidden } + : ind, + ); + } + return prev.map(ind => (ind.id === updated.id ? updated : ind)); + }; + + if (layoutDef.count > 1) { + applyChartIndicators(applyUpdate(activeSlotIndicators)); + } else { + setIndicators(applyUpdate); + } + setSettingsModalId(null); + setCtxToolbar(null); + }, [ + saveParams, + saveVisual, + settingsModalId, + layoutDef.count, + activeSlotIndicators, + applyChartIndicators, + ]); + + const bulkSettingsIndicators = layoutDef.count > 1 ? activeSlotIndicators : indicators; + + /** 보조지표 일괄 설정 적용 — 전체 지표 DB + 차트 인스턴스 병합 */ + const handleBulkIndicatorApply = useCallback((result: { + chartIndicators: IndicatorConfig[]; + allConfigs: IndicatorConfig[]; + }) => { + const { chartIndicators, allConfigs } = result; + applyChartIndicators(chartIndicators); + allConfigs.forEach(ind => { + saveParams(ind.type, ind.params); + saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors); + }); + setShowBulkIndSettings(false); + }, [applyChartIndicators, saveParams, saveVisual]); + + /** 지표 숨김/표시 토글 */ + const handleToggleIndicatorHidden = useCallback((id: string) => { + setIndicators(prev => prev.map(ind => + ind.id === id ? { ...ind, hidden: !ind.hidden } : ind + )); + }, []); + + /** 지표 ID로 삭제 */ + const handleDuplicateIndicator = useCallback((sourceId: string) => { + if (layoutDef.count > 1) { + slotRefs.current[activeSlot]?.duplicateIndicator(sourceId); + return; + } + setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); + }, [layoutDef.count, activeSlot]); + + const handleRemoveIndicatorById = useCallback((id: string) => { + setIndicators(prev => prev.filter(i => i.id !== id)); + setFocusedIndicatorId(prev => prev === id ? null : prev); + setCtxToolbar(null); + setSettingsModalId(null); + }, []); + + const handleScreenshot = () => { + const url = managerRef.current?.screenshot(); + if (!url) return; + const a = document.createElement('a'); + a.href = url; + a.download = `chart_${symbol}_${timeframe}_${Date.now()}.png`; + a.click(); + }; + + const handleFullscreen = () => { + if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); + else document.exitFullscreen().catch(() => {}); + }; + + // 실시간 모드: latestBar 우선, 없으면 마지막 bars 사용 + const lastKnownBar = useUpbit + ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) + : (bars.length > 0 ? bars[bars.length - 1] : null); + const currentBar = legend ?? lastKnownBar; + const isUp = currentBar ? currentBar.close >= currentBar.open : true; + const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0; + const dailyChange = currentBar ? currentBar.close - prevClose : 0; + const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; + + const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]); + + if (!appEntered) { + return ( + + ); + } + + return ( + +
+ 0} + popupEnabled={appDefaults.tradeAlertPopup ?? true} + strategyId={appDefaults.liveStrategyId ?? undefined} + executionType={appDefaults.liveExecutionType ?? 'CANDLE_CLOSE'} + strategyName={liveStrategyName} + chartMarkersActive={menuPage === 'chart'} + onMarkersChange={markers => { + managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); + }} + /> + + {/* ── 최상단 글로벌 메뉴바 ─────────────────────────────────────── */} + setLoginOpen(true)} + onLogout={handleLogout} + tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} + tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} + tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} + onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} + onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} + onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} + /> + + setLoginOpen(false)} + onSuccess={handleLoginSuccess} + /> + + {/* ── 투자전략 화면 ──────────────────────────────────────────────── */} + {menuPage === 'dashboard' && ( + { goToMarketChart(m); setMenuPage('chart'); }} /> + )} + + {menuPage === 'strategy' && ( + + )} + + {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} + {menuPage === 'backtest' && ( + + )} + + {menuPage === 'paper' && ( + { + goToMarketChart(m); + setMenuPage('chart'); + }} + /> + )} + + {/* ── 설정 화면 ──────────────────────────────────────────────────── */} + {menuPage === 'notifications' && ( + { + goToMarketChart(market); + setMenuPage('chart'); + }} + /> + )} + + {menuPage === 'settings' && canMenu('settings') && ( + setMagnetMode(m)} + btAutoPopup={appDefaults.btAutoPopup} + onBtAutoPopup={v => saveAppDef({ btAutoPopup: v })} + btShowPrice={appDefaults.btShowPrice} + onBtShowPrice={v => saveAppDef({ btShowPrice: v })} + liveMarket={symbol} + liveStrategies={liveStrategies} + onClearLiveMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }} + tradeAlertPopup={appDefaults.tradeAlertPopup} + onTradeAlertPopup={v => saveAppDef({ tradeAlertPopup: v })} + tradeAlertSoundEnabled={appDefaults.tradeAlertSoundEnabled} + onTradeAlertSoundEnabled={v => saveAppDef({ tradeAlertSoundEnabled: v })} + tradeAlertSound={appDefaults.tradeAlertSound} + onTradeAlertSound={v => saveAppDef({ tradeAlertSound: v })} + tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition} + onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })} + tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout} + onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })} + tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols} + onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })} + liveSettings={liveStrategySettings} + onLiveSettingsChange={handleLiveSettingsChange} + watchlistCount={watchlist.length} + monitoredMarkets={monitoredMarkets} + getIndicatorParams={getIndicatorParams} + saveIndicatorParams={saveIndicatorParams} + getIndicatorVisual={getIndicatorVisual} + saveIndicatorVisual={saveIndicatorVisual} + chartIndicators={bulkSettingsIndicators} + onAddIndicatorToChart={handleAddIndicator} + onRemoveIndicatorFromChart={handleRemoveByType} + chartSeriesPriceLabels={chartSeriesPriceLabels} + onChartSeriesPriceLabels={v => { + saveAppDef({ chartSeriesPriceLabels: v }); + managerRef.current?.setSeriesPriceLabelsEnabled(v); + slotRefs.current.forEach(slot => slot?.setSeriesPriceLabelsEnabled(v)); + }} + chartVolumeVisible={chartVolumeVisible} + onChartVolumeVisible={v => { + saveAppDef({ chartVolumeVisible: v }); + const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; + managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight); + slotRefs.current.forEach(slot => slot?.setVolumeVisible(v)); + }} + chartLegendOptions={chartLegendOptions} + onChartLegendOptionsChange={patch => { + saveAppDef({ + chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record, + }); + }} + paperTradingEnabled={paperTradingEnabled} + onPaperTradingEnabled={v => saveAppDef({ paperTradingEnabled: v })} + paperInitialCapital={appDefaults.paperInitialCapital} + onPaperInitialCapital={v => saveAppDef({ paperInitialCapital: v })} + paperFeeRatePct={appDefaults.paperFeeRatePct} + onPaperFeeRatePct={v => saveAppDef({ paperFeeRatePct: v })} + paperSlippagePct={appDefaults.paperSlippagePct} + onPaperSlippagePct={v => saveAppDef({ paperSlippagePct: v })} + paperMinOrderKrw={appDefaults.paperMinOrderKrw} + onPaperMinOrderKrw={v => saveAppDef({ paperMinOrderKrw: v })} + paperAutoTradeEnabled={appDefaults.paperAutoTradeEnabled} + onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} + paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} + onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })} + onPaperAccountReset={async () => { + await resetPaperAccount(); + await refreshPaperAccount(); + }} + tradingMode={appDefaults.tradingMode} + onTradingMode={v => saveAppDef({ tradingMode: v })} + liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled} + onLiveAutoTradeEnabled={v => saveAppDef({ liveAutoTradeEnabled: v })} + hasUpbitKeys={appDefaults.hasUpbitKeys} + upbitAccessKeyMasked={appDefaults.upbitAccessKeyMasked} + onUpbitKeys={(access, secret) => { + const patch: Parameters[0] = {}; + const a = access.trim(); + const s = secret.trim(); + const isMasked = (v: string) => v.startsWith('····') || v.startsWith('****'); + if (a && !isMasked(a)) patch.upbitAccessKey = a; + if (s && !isMasked(s)) patch.upbitSecretKey = s; + if (Object.keys(patch).length) saveAppDef(patch); + }} + chartRealtimeSource={appDefaults.chartRealtimeSource} + onChartRealtimeSource={v => saveAppDef({ chartRealtimeSource: v })} + liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct} + onLiveAutoTradeBudgetPct={v => saveAppDef({ liveAutoTradeBudgetPct: v })} + fcmPushEnabled={appDefaults.fcmPushEnabled} + onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })} + displayTimezone={displayTimezone} + onDisplayTimezoneChange={handleTimezoneChange} + onFcmTest={async () => { + const { sendFcmTest } = await import('./utils/backendApi'); + const r = await sendFcmTest(); + window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.'); + }} + /> + )} + + {/* ── 실시간 차트 화면 ────────────────────────────────────────────── */} +
+ + 1 ? activeSlotTf : timeframe} chartType={chartType} + theme={theme} mode={mode} logScale={logScale} showGrid={showGrid} + activeIndicators={toolbarActiveIndicators} + onSymbol={s => { + if (layoutDef.count > 1) { + // 멀티 레이아웃: 활성 슬롯의 심볼 변경 + slotRefs.current[activeSlot]?.setSymbol(s); + // symbol sync 활성화된 경우 모든 슬롯 일괄 변경 + if (syncOptions.symbol) { + slotRefs.current.slice(0, layoutDef.count).forEach(r => r?.setSymbol(s)); + } + } else { + setSymbol(s); setLegend(null); + } + }} + onTimeframe={handleTimeframe} + onChartType={setChartType} + onTheme={handleThemeToggle} + onMode={() => setMode(m => m === 'chart' ? 'trading' : 'chart')} + onLogScale={() => setLogScale(v => !v)} + onToggleGrid={() => setShowGrid(v => !v)} + onFitContent={() => managerRef.current?.fitContent()} + onScrollToNow={() => managerRef.current?.scrollToRealTime()} + onAddIndicator={handleAddIndicator} + onRemoveByType={handleRemoveByType} + onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)} + onOpenIndicatorSettings={handleOpenIndicatorSettings} + onAutoFib={() => managerRef.current?.drawAutoFib(80)} + onClearFib={() => managerRef.current?.clearFib()} + onScreenshot={handleScreenshot} + onToggleStats={() => setShowStats(v => !v)} + onToggleWatch={() => setShowWatch(v => !v)} + onToggleAlert={() => setShowAlert(v => !v)} + magnetMode={magnetMode} + onToggleMagnet={() => setMagnetMode(m => m === 'off' ? 'strong' : 'off')} + onFullscreen={handleFullscreen} + onUndo={handleUndo} + onRedo={handleRedo} + canUndo={canUndo} + canRedo={canRedo} + onClearDrawings={() => { + // 모든 드로잉 삭제 + 피보나치 삭제 + undo/redo 스택 초기화 + undoStack.current = []; + redoStack.current = []; + setCanUndo(false); + setCanRedo(false); + setDrawings([]); + managerRef.current?.clearFib(); + managerRef.current?.clearDrawings(); + }} + layoutId={layoutDef.id} + syncOptions={syncOptions} + onLayoutSelect={handleLayoutSelect} + onSyncChange={(key, val) => { + setSyncOptions(prev => { + const next = { ...prev, [key]: val }; + saveAppDef({ syncOptions: next as unknown as Record }); + return next; + }); + }} + magnifierActive={magnifierActive} + onToggleMagnifier={() => setMagnifierActive(v => !v)} + marketPanelActive={showMarketPanel} + onToggleMarketPanel={() => { + setShowMarketPanel(v => { + const next = !v; + if (isMobile && next) setMobileRightOpen(false); + return next; + }); + }} + backtestActive={btSignals.length > 0} + backtestRunning={btRunning} + backtestRunningId={undefined} + onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 + ? async (opts) => { + if (opts.strategyName) setBtStrategyName(opts.strategyName); + await btRun({ ...opts, bars, timeframe, settings: btSettings, symbol }); + if (appDefaults.btAutoPopup) setShowBtResult(true); + } + : undefined} + onOpenBtSettings={() => setShowBtSettings(true)} + onOpenBtResults={() => setShowBtResult(true)} + hasBtResult={btSignals.length > 0} + onClearBtMarkers={() => { btClear(); managerRef.current?.clearBacktestMarkers(); }} + onToggleLiveStrategy={() => setShowLivePanel(v => !v)} + liveStrategyActive={showLivePanel || monitoredMarkets.length > 0} + /> + + {/* 업비트 연결 상태 바 */} + {useUpbit && ( +
+ + {getKoreanName(symbol)} {symbol} + {isLoading && 데이터 로딩 중...} + {error && ⚠ {error}} + {!isLoading && !error && currentBar && ( + <> + + {currentBar.close.toLocaleString()} KRW + + + {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% + + + )} + 💡 업비트 마켓: KRW-BTC, KRW-ETH, KRW-XRP ... +
+ )} + + {isMobile && (showMarketPanel || mobileRightOpen) && ( + + + { setDrawings([]); managerRef.current?.clearDrawings(); }} + onUndo={() => setDrawings(prev => prev.slice(0, -1))} + locked={drawingsLocked} + visible={drawingsVisible} + onToggleLock={() => setDrawingsLocked(v => !v)} + onToggleEye={() => setDrawingsVisible(v => !v)} + indicatorsVisible={indicatorsVisible} + magnetMode={magnetMode} + snapToIndicator={snapToIndicator} + drawingCount={drawings.length} + indicatorCount={indicators.length} + onMagnetMode={setMagnetMode} + onSnapToIndicator={setSnapToIndicator} + onHideDrawings={() => setDrawingsVisible(v => !v)} + onHideIndicators={() => setIndicatorsVisible(v => !v)} + onHideAll={() => { setDrawingsVisible(false); setIndicatorsVisible(false); }} + onClearIndicators={() => setIndicators([])} + keepLockedOnDelete={keepLockedOnDelete} + onToggleKeepLocked={() => setKeepLockedOnDelete(v => !v)} + /> + + {/* ── 멀티 레이아웃: 슬롯 2개 이상일 때 별도 그리드 렌더 ─────────── */} + {layoutDef.count > 1 && ( +
+ {/* 슬롯 0 = 메인 차트 영역 (a) */} +
+ { slotRefs.current[0] = el; }} + slotIndex={0} + theme={theme} + active={activeSlot === 0} + onActivate={() => handleSetActiveSlot(0)} + initialSymbol={slot0InitProps?.symbol} + initialTimeframe={slot0InitProps?.timeframe} + initialIndicators={slot0InitProps?.indicators} + forcedSymbol={syncOptions.symbol ? symbol : undefined} + forcedTf={syncOptions.interval ? timeframe : undefined} + syncTime={syncOptions.crosshair ? syncTime : undefined} + onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }} + syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined} + onVisibleRangeChange={handleVisibleRangeChange} + syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined} + onLogicalRangeChange={handleLogicalRangeChange} + onFullView={sym => handleSlotFullView(sym, 0)} + magnifierEnabled={magnifierActive && activeSlot === 0} + onMagnifierClose={() => setMagnifierActive(false)} + onIndicatorsChange={activeSlot === 0 ? handleActiveSlotIndicatorsChange : undefined} + onSymbolChange={activeSlot === 0 ? handleActiveSlotSymbolChange : undefined} + magnetMode={magnetMode} + onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} + chartSeriesPriceLabels={chartSeriesPriceLabels} + chartVolumeVisible={chartVolumeVisible} + chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} + displayTimezone={displayTimezone} + /> +
+ {/* 슬롯 1~7 */} + {(['b','c','d','e','f','g','h'] as const) + .slice(0, layoutDef.count - 1) + .map((area, i) => ( +
+ { slotRefs.current[i + 1] = el; }} + slotIndex={i + 1} + theme={theme} + active={activeSlot === i + 1} + onActivate={() => handleSetActiveSlot(i + 1)} + initialSymbol={slotInitProps[i + 1]?.symbol} + initialTimeframe={slotInitProps[i + 1]?.timeframe} + initialIndicators={slotInitProps[i + 1]?.indicators} + forcedSymbol={syncOptions.symbol ? symbol : undefined} + forcedTf={syncOptions.interval ? timeframe : undefined} + syncTime={syncOptions.crosshair ? syncTime : undefined} + onCrosshairTime={t => { if (syncOptions.crosshair) setSyncTime(t); }} + syncVisibleRange={syncOptions.time ? syncVisibleRange : undefined} + onVisibleRangeChange={handleVisibleRangeChange} + syncLogicalRange={syncOptions.dateRange ? syncLogicalRange : undefined} + onLogicalRangeChange={handleLogicalRangeChange} + onFullView={sym => handleSlotFullView(sym, i + 1)} + magnifierEnabled={magnifierActive && activeSlot === i + 1} + onMagnifierClose={() => setMagnifierActive(false)} + onIndicatorsChange={activeSlot === i + 1 ? handleActiveSlotIndicatorsChange : undefined} + onSymbolChange={activeSlot === i + 1 ? handleActiveSlotSymbolChange : undefined} + magnetMode={magnetMode} + onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} + chartSeriesPriceLabels={chartSeriesPriceLabels} + chartVolumeVisible={chartVolumeVisible} + chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} + displayTimezone={displayTimezone} + /> +
+ ))} +
+ )} + + {/* ── 싱글 레이아웃 (기존) ─────────────────────────────────────── */} +
1 ? 'none' : undefined }}> + + + {/* 로딩 오버레이 */} + {useUpbit && isLoading && ( +
+
+ 업비트 데이터 로딩 중... +
+ )} + {isSingleLoadingMore && ( +
+
+ 과거 데이터 로딩... +
+ )} + + { + managerRef.current = mgr; + const pending = pendingRealtimeBarRef.current; + if (pending) { + pendingRealtimeBarRef.current = null; + const last = bars.length > 0 ? bars[bars.length - 1] : null; + if (last && pending.time === last.time) mgr.updateBar(pending); + else if (!last || pending.time > last.time) void mgr.appendBar(pending); + else mgr.updateBar({ ...pending, time: last.time }); + } + mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); + const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; + mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); + mgr.setDisplayTimezone(displayTimezone, timeframe); + // 백테스팅 마커가 이미 있으면 즉시 적용 + if (btSignals.length > 0) mgr.setBacktestMarkers(btSignals, appDefaults.btShowPrice); + // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 + mgr.subscribeVisibleLogicalRange(r => { + if (r && r.from < LOAD_MORE_TRIGGER) { + singleLoadMoreRef.current(); + } + }); + }} + onAddDrawing={d => pushDrawing([...drawings, d])} + onSeriesClick={(entryId, point) => { + if (!entryId) { setCtxToolbar(null); return; } + setCtxToolbar({ entryId, screenX: point.x, screenY: point.y }); + setDrawingCtxToolbar(null); // 드로잉 툴바 닫기 + }} + onSeriesDoubleClick={(entryId) => { + setCtxToolbar(null); + if (!entryId) return; + if (entryId === '__main__') { + setShowMainChartModal(true); + } else { + setSettingsModalId(entryId); + } + }} + onHoverPaneLegend={handleHoverPaneLegend} + onLeavePaneLegend={handleLeavePaneLegend} + selectedDrawingId={selectedDrawingId} + onDrawingClick={handleDrawingClick} + onDrawingDoubleClick={handleDrawingDoubleClick} + magnifierEnabled={magnifierActive} + onMagnifierClose={() => setMagnifierActive(false)} + onReorderIndicators={handleReorderIndicators} + onMergeIndicators={handleMergeIndicators} + onSplitIndicatorPane={handleSplitIndicatorPane} + onExpandIndicator={handleExpandIndicator} + onRemoveIndicator={handleRemoveIndicatorById} + onDuplicateIndicator={handleDuplicateIndicator} + focusedIndicatorId={focusedIndicatorId} + onRestoreIndicators={handleRestoreIndicators} + /> + + {/* 백테스팅 결과 통계 배지 */} + {btSignals.length > 0 && layoutDef.count === 1 && ( + { btClear(); managerRef.current?.clearBacktestMarkers(); }} + /> + )} + + {/* 실시간 전략 체크 패널 */} + {showLivePanel && menuPage === 'chart' && layoutDef.count === 1 && ( +
+ { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }} + onSettingsChange={handleLiveSettingsChange} + onClose={() => setShowLivePanel(false)} + /> +
+ )} + + {/* 백테스팅 설정 모달 */} + {showBtSettings && ( + setShowBtSettings(false)} + onSettingsChange={s => { setBtSettings(s); setShowBtSettings(false); }} + /> + )} + + {/* 백테스팅 결과 모달 */} + {showBtResult && ( + setShowBtResult(false)} + /> + )} + + {/* 시리즈 클릭/호버 컨텍스트 툴바 */} + {ctxToolbar && (() => { + const cfg = ctxToolbar.entryId !== '__main__' + ? indicators.find(i => i.id === ctxToolbar.entryId) ?? null + : null; + return ( + { + const id = ctxToolbar.entryId; + setCtxToolbar(null); + if (id === '__main__') { + setShowMainChartModal(true); + } else { + setSettingsModalId(id); + } + }} + onToggleHidden={() => { + handleToggleIndicatorHidden(ctxToolbar.entryId); + }} + onRemove={() => handleRemoveIndicatorById(ctxToolbar.entryId)} + onClose={() => setCtxToolbar(null)} + /> + ); + })()} + + {/* 드로잉 컨텍스트 툴바 */} + {drawingCtxToolbar && (() => { + const d = drawings.find(dr => dr.id === drawingCtxToolbar.id); + if (!d) return null; + return ( + { + setDrawingCtxToolbar(null); + setDrawingSettingsId(d.id); + }} + onToggleVisible={() => { + setDrawings(prev => + prev.map(dr => + dr.id === d.id + ? { ...dr, visible: dr.visible !== false ? false : true } + : dr + ) + ); + setDrawingCtxToolbar(null); + }} + onRemove={() => { + pushDrawing(drawings.filter(dr => dr.id !== d.id)); + setDrawingCtxToolbar(null); + setSelectedDrawingId(null); + }} + onClose={() => { + setDrawingCtxToolbar(null); + setSelectedDrawingId(null); + }} + /> + ); + })()} + + {/* 드로잉 설정 모달 */} + {drawingSettingsId && (() => { + const d = drawings.find(dr => dr.id === drawingSettingsId); + if (!d) return null; + const handleSaveDrawing = (updated: typeof d) => { + setDrawings(prev => prev.map(dr => dr.id === updated!.id ? updated! : dr)); + setDrawingSettingsId(null); + setSelectedDrawingId(updated!.id); + }; + const handleCancelDrawing = () => { setDrawingSettingsId(null); }; + if (d.type === 'fibtz') { + return ( + + ); + } + return ( + { + setDrawings(prev => prev.map(dr => dr.id === updated.id ? updated : dr)); + setDrawingSettingsId(null); + setSelectedDrawingId(updated.id); + }} + onCancel={() => { + setDrawingSettingsId(null); + }} + /> + ); + })()} + + {/* 메인 차트 설정 모달 */} + {showMainChartModal && ( + setShowMainChartModal(false)} + /> + )} + + {/* 보조지표 일괄 설정 */} + {showBulkIndSettings && ( + setShowBulkIndSettings(false)} + /> + )} + + {/* 인디케이터 설정 모달 */} + {settingsModalId && settingsModalId !== '__main__' && (() => { + let cfg: IndicatorConfig | null = null; + if (isIndicatorSettingsTemplateId(settingsModalId)) { + const type = indicatorTypeFromSettingsId(settingsModalId); + if (!type) return null; + cfg = buildMainIndicatorConfig( + type, + chartIndicatorsForSettings, + getParams, + getVisualConfig, + ); + } else { + const onChart = chartIndicatorsForSettings.find(i => i.id === settingsModalId); + if (onChart) { + cfg = initializeIndicatorConfigForEditor(onChart, getParams, getVisualConfig); + } + } + if (!cfg) return null; + return ( + setSettingsModalId(null)} + /> + ); + })()} + +
+ + {/* ── 우측 슬라이드 패널 (매수/매도 + 호가) ───────────────────── */} + { + refreshPaperAccount(symbol); + setPaperRefreshKey(k => k + 1); + }} + /> + +
+ + {/* ── 플로팅 패널: position:fixed로 항상 표시 (싱글/멀티 모드 공통) ─── */} + {showObjectTree && ( + setDrawings(prev => + prev.map(d => d.id === id ? { ...d, visible: d.visible !== false ? false : true } : d) + )} + onRemove={id => setDrawings(prev => prev.filter(d => d.id !== id))} + onClose={() => setShowObjectTree(false)} + /> + )} + {showStats && setShowStats(false)} />} + {showWatch && ( + { setSymbol(s); setShowWatch(false); }} + onAdd={s => { + setWatchlist(prev => [...new Set([...prev, s])]); + addWatchlistItem({ symbol: s }).catch(() => {}); + }} + onRemove={s => { + setWatchlist(prev => prev.filter(x => x !== s)); + removeWatchlistItem(s).catch(() => {}); + }} + onClose={() => setShowWatch(false)} + /> + )} + {showAlert && ( + { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }} + onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }} + onClose={() => setShowAlert(false)} + /> + )} + + {isMobile && ( + { + setShowMarketPanel(v => { + const next = !v; + if (next) setMobileRightOpen(false); + return next; + }); + }} + onOpenTrade={() => { + setShowMarketPanel(false); + setMobileRightTab('trade'); + setMobileRightOpen(true); + }} + onOpenOrderbook={() => { + setShowMarketPanel(false); + setMobileRightTab('orderbook'); + setMobileRightOpen(true); + }} + onClosePanels={() => { + setShowMarketPanel(false); + setMobileRightOpen(false); + }} + onTool={setDrawingTool} + /> + )} + + 1 ? activeSlotTf : timeframe} + logScale={logScale} + percentScale={percentScale} showGrid={showGrid} + lastTime={bars[bars.length - 1]?.time ?? 0} + displayTimezone={displayTimezone} + onTimezoneChange={handleTimezoneChange} + onTimeframe={handleTimeframe} + onLogScale={() => setLogScale(v => !v)} + onPercentScale={() => setPercentScale(v => !v)} + onAutoScale={() => managerRef.current?.autoScale()} + onFitContent={() => managerRef.current?.fitContent()} + onToggleGrid={() => setShowGrid(v => !v)} + /> + +
{/* chart-screen */} + + { + refreshPaperAccount(symbol); + setPaperRefreshKey(k => k + 1); + }} + /> + +
+ + ); +} + +export default App; diff --git a/frontend/src/components/AdminPasswordGate.tsx b/frontend/src/components/AdminPasswordGate.tsx new file mode 100644 index 0000000..8aa14aa --- /dev/null +++ b/frontend/src/components/AdminPasswordGate.tsx @@ -0,0 +1,67 @@ +/** + * 관리자 설정 패널 — 비밀번호 확인 후 콘텐츠 표시 + */ +import React, { useState } from 'react'; +import { verifyAdminPassword } from '../utils/backendApi'; +import { isAdminUnlocked, setAdminUnlocked } from '../utils/adminUnlock'; + +interface Props { + children: React.ReactNode; +} + +const AdminPasswordGate: React.FC = ({ children }) => { + const [unlocked, setUnlocked] = useState(isAdminUnlocked); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + if (unlocked) return <>{children}; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await verifyAdminPassword(password); + setAdminUnlocked(); + setUnlocked(true); + setPassword(''); + } catch (err) { + setError(err instanceof Error ? err.message : '비밀번호 확인에 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

관리자 인증

+

+ 관리자 설정을 보려면 로그인한 관리자 계정의 비밀번호를 입력하세요. + (기본: admin / admin) +

+
+ + {error &&

{error}

} + +
+
+
+ ); +}; + +export default AdminPasswordGate; diff --git a/frontend/src/components/AdminSettingsPanel.tsx b/frontend/src/components/AdminSettingsPanel.tsx new file mode 100644 index 0000000..2296d04 --- /dev/null +++ b/frontend/src/components/AdminSettingsPanel.tsx @@ -0,0 +1,257 @@ +/** + * 관리자 설정 — 사용자 CRUD · 역할별 메뉴 권한 + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { + createAdminUser, + deleteAdminUser, + fetchRolePermissions, + listAdminUsers, + saveRolePermissions, + updateAdminUser, + type UserDto, +} from '../utils/backendApi'; +import { + ALL_MENU_IDS, + MENU_LABELS, + ROLE_LABELS, + type UserRole, +} from '../utils/permissions'; + +const ROLES: UserRole[] = ['ADMIN', 'USER', 'GUEST']; + +const AdminSettingsPanel: React.FC = () => { + const [users, setUsers] = useState([]); + const [rolePerms, setRolePerms] = useState>>({}); + const [permRole, setPermRole] = useState('USER'); + const [loading, setLoading] = useState(true); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + const [formMode, setFormMode] = useState<'create' | 'edit' | null>(null); + const [editId, setEditId] = useState(null); + const [fUsername, setFUsername] = useState(''); + const [fPassword, setFPassword] = useState(''); + const [fDisplayName, setFDisplayName] = useState(''); + const [fRole, setFRole] = useState('USER'); + const [fEnabled, setFEnabled] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + setErr(null); + try { + const [u, p] = await Promise.all([listAdminUsers(), fetchRolePermissions()]); + setUsers(u); + setRolePerms(p); + } catch (e) { + setErr(e instanceof Error ? e.message : '데이터 로드 실패'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void refresh(); }, [refresh]); + + const openCreate = () => { + setFormMode('create'); + setEditId(null); + setFUsername(''); + setFPassword(''); + setFDisplayName(''); + setFRole('USER'); + setFEnabled(true); + }; + + const openEdit = (u: UserDto) => { + setFormMode('edit'); + setEditId(u.id); + setFUsername(u.username); + setFPassword(''); + setFDisplayName(u.displayName); + setFRole((u.role as UserRole) || 'USER'); + setFEnabled(u.enabled); + }; + + const submitUser = async () => { + setErr(null); + setMsg(null); + try { + if (formMode === 'create') { + await createAdminUser({ + username: fUsername.trim(), + password: fPassword, + displayName: fDisplayName.trim() || fUsername.trim(), + role: fRole, + enabled: fEnabled, + }); + setMsg('사용자가 등록되었습니다.'); + } else if (formMode === 'edit' && editId != null) { + await updateAdminUser(editId, { + displayName: fDisplayName.trim(), + role: fRole, + enabled: fEnabled, + ...(fPassword ? { password: fPassword } : {}), + }); + setMsg('사용자 정보가 저장되었습니다.'); + } + setFormMode(null); + await refresh(); + } catch (e) { + setErr(e instanceof Error ? e.message : '저장 실패'); + } + }; + + const removeUser = async (id: number) => { + if (!window.confirm('이 사용자를 삭제하시겠습니까?')) return; + setErr(null); + try { + await deleteAdminUser(id); + setMsg('삭제되었습니다.'); + await refresh(); + } catch (e) { + setErr(e instanceof Error ? e.message : '삭제 실패'); + } + }; + + const savePerms = async () => { + const perms = rolePerms[permRole]; + if (!perms) return; + setErr(null); + try { + await saveRolePermissions(permRole, perms); + setMsg(`${ROLE_LABELS[permRole]} 역할 권한이 저장되었습니다.`); + await refresh(); + } catch (e) { + setErr(e instanceof Error ? e.message : '권한 저장 실패'); + } + }; + + const togglePerm = (menuId: string) => { + setRolePerms(prev => ({ + ...prev, + [permRole]: { + ...(prev[permRole] ?? {}), + [menuId]: !(prev[permRole]?.[menuId]), + }, + })); + }; + + if (loading && users.length === 0) { + return

관리자 데이터 로딩 중…

; + } + + return ( +
+ {msg &&

{msg}

} + {err &&

{err}

} + +
+

사용자 관리

+
+ +
+
+ + + + + + + + + + + {users.map(u => ( + + + + + + + + ))} + +
아이디표시명역할상태 +
{u.username}{u.displayName}{ROLE_LABELS[u.role as UserRole] ?? u.role}{u.enabled ? '활성' : '비활성'} + + +
+
+
+ + {formMode && ( +
+

{formMode === 'create' ? '사용자 등록' : '사용자 수정'}

+
+ {formMode === 'create' && ( + + )} + + + + +
+
+ + +
+
+ )} + +
+

역할별 메뉴 접근 권한

+

+ 역할마다 상단 메뉴·설정 하위 메뉴·알림 접근을 제어합니다. 비로그인 사용자는 게스트(GUEST) 권한이 적용됩니다. +

+
+ {ROLES.map(r => ( + + ))} +
+
+ {ALL_MENU_IDS.map(menuId => ( + + ))} +
+ +
+
+ ); +}; + +export default AdminSettingsPanel; diff --git a/frontend/src/components/AdvancedColorPicker.tsx b/frontend/src/components/AdvancedColorPicker.tsx new file mode 100644 index 0000000..541566e --- /dev/null +++ b/frontend/src/components/AdvancedColorPicker.tsx @@ -0,0 +1,238 @@ +/** + * 채도·명도 영역 + Hue 슬라이더 + 스포이드 + RGB 입력 (TradingView 스타일) + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + hexToHsv, + hexToRgb, + hsvToHex, + hsvToRgb, + normalizeHex6, + rgbToHex, + rgbToHsv, + svPanelBackground, + HUE_SLIDER_BG, + type Hsv, +} from '../utils/colorSpaceUtils'; + +export interface AdvancedColorPickerProps { + hex6: string; + onChange: (hex6: string) => void; + className?: string; +} + +function clamp01(n: number): number { + return Math.max(0, Math.min(1, n)); +} + +function useHueDrag(onHue: (h: number) => void) { + const dragging = useRef(false); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + dragging.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + const rect = e.currentTarget.getBoundingClientRect(); + const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0; + onHue(clamp01(t) * 360); + }, [onHue]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragging.current) return; + const rect = e.currentTarget.getBoundingClientRect(); + const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0; + onHue(clamp01(t) * 360); + }, [onHue]); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + dragging.current = false; + try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* noop */ } + }, []); + + return { onPointerDown, onPointerMove, onPointerUp }; +} + +const AdvancedColorPicker: React.FC = ({ + hex6, + onChange, + className = '', +}) => { + const [hsv, setHsv] = useState(() => hexToHsv(normalizeHex6(hex6))); + const [rgb, setRgb] = useState(() => hexToRgb(normalizeHex6(hex6))); + const [colorMode, setColorMode] = useState<'rgb' | 'hex'>('rgb'); + const skipSync = useRef(false); + + useEffect(() => { + if (skipSync.current) { + skipSync.current = false; + return; + } + const h = hexToHsv(normalizeHex6(hex6)); + setHsv(h); + setRgb(hexToRgb(normalizeHex6(hex6))); + }, [hex6]); + + const emitHsv = useCallback((next: Hsv) => { + const h = { h: next.h, s: clamp01(next.s), v: clamp01(next.v) }; + setHsv(h); + const r = hsvToRgb(h); + setRgb(r); + skipSync.current = true; + onChange(hsvToHex(h)); + }, [onChange]); + + const emitRgb = useCallback((r: number, g: number, b: number) => { + const next = { r, g, b }; + setRgb(next); + setHsv(rgbToHsv(next)); + skipSync.current = true; + onChange(rgbToHex(r, g, b)); + }, [onChange]); + + const hueDrag = useHueDrag(h => emitHsv({ ...hsv, h })); + + const handleSvPointerDown = (e: React.PointerEvent) => { + const el = e.currentTarget; + const update = (cx: number, cy: number) => { + const rect = el.getBoundingClientRect(); + const s = clamp01(rect.width > 0 ? (cx - rect.left) / rect.width : 0); + const v = clamp01(rect.height > 0 ? 1 - (cy - rect.top) / rect.height : 0); + emitHsv({ ...hsv, s, v }); + }; + update(e.clientX, e.clientY); + const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY); + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }; + + const pickEyedropper = async () => { + const EyeDropperCtor = (window as Window & { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper; + if (!EyeDropperCtor) return; + try { + const result = await new EyeDropperCtor().open(); + if (result?.sRGBHex) { + const hex = normalizeHex6(result.sRGBHex); + emitHsv(hexToHsv(hex)); + } + } catch { + /* 사용자 취소 */ + } + }; + + const pointerX = `${hsv.s * 100}%`; + const pointerY = `${(1 - hsv.v) * 100}%`; + const hueLeft = `${(hsv.h / 360) * 100}%`; + + return ( +
+
+ +
+ +
+ + +
+ +
+
+ +
+ {colorMode === 'rgb' ? ( + <> + {(['r', 'g', 'b'] as const).map(ch => ( + + ))} + + ) : ( + + )} + +
+
+ ); +}; + +export default AdvancedColorPicker; diff --git a/frontend/src/components/AlertManager.tsx b/frontend/src/components/AlertManager.tsx new file mode 100644 index 0000000..8a655a6 --- /dev/null +++ b/frontend/src/components/AlertManager.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; + +interface AlertManagerProps { + alerts: Array<{ price: number; label: string }>; + onAdd: (price: number, label: string) => void; + onRemove: (price: number) => void; + onClose: () => void; + currentPrice: number; +} + +const AlertManager: React.FC = ({ alerts, onAdd, onRemove, onClose, currentPrice }) => { + const [priceInput, setPriceInput] = useState(currentPrice.toFixed(2)); + const [labelInput, setLabelInput] = useState(''); + + const handleAdd = () => { + const price = parseFloat(priceInput); + if (!isNaN(price) && price > 0) { + onAdd(price, labelInput.trim()); + setLabelInput(''); + } + }; + + return ( +
+
+ 🔔 가격 알림 + +
+
+ setPriceInput(e.target.value)} + placeholder="가격" + /> + setLabelInput(e.target.value)} + placeholder="메모 (선택)" + /> + +
+
+ {alerts.length === 0 &&
설정된 알림 없음
} + {alerts.map((a, i) => ( +
+ {a.price.toFixed(2)} + {a.label && {a.label}} + +
+ ))} +
+
+ ); +}; + +export default AlertManager; diff --git a/frontend/src/components/AppNotificationLayer.tsx b/frontend/src/components/AppNotificationLayer.tsx new file mode 100644 index 0000000..dd06b17 --- /dev/null +++ b/frontend/src/components/AppNotificationLayer.tsx @@ -0,0 +1,83 @@ +/** + * 전역 알림 UI — 모든 화면 위에 표시 (body 포털) + */ +import React from 'react'; +import { createPortal } from 'react-dom'; +import TradeSignalSnackbar from './TradeSignalSnackbar'; +import TradeAlertModal from './TradeAlertModal'; +import { useTradeNotification } from '../contexts/TradeNotificationContext'; +import { + normalizeTradeAlertGridCols, + normalizeTradeAlertPopupLayout, + normalizeTradeAlertPopupPosition, +} from '../utils/tradeAlertPopupLayout'; +import type { MenuPage } from './TopMenuBar'; + +interface Props { + menuPage: MenuPage; + onPage: (page: MenuPage) => void; + onGoToChart: (market: string) => void; + tradingMode?: string; + hasUpbitKeys?: boolean; + paperTradingEnabled?: boolean; + liveAutoTradeBudgetPct?: number; + paperAutoTradeBudgetPct?: number; + tradeAlertPopupPosition?: string; + tradeAlertPopupLayout?: string; + tradeAlertPopupGridCols?: number; + onOrderDone?: () => void; +} + +export const AppNotificationLayer: React.FC = ({ + menuPage, + onPage, + onGoToChart, + tradingMode, + hasUpbitKeys, + paperTradingEnabled, + liveAutoTradeBudgetPct, + paperAutoTradeBudgetPct, + tradeAlertPopupPosition = 'right', + tradeAlertPopupLayout = 'stack', + tradeAlertPopupGridCols = 2, + onOrderDone, +}) => { + const { detailSignal, detailCentered, closeDetail } = useTradeNotification(); + const popupPosition = normalizeTradeAlertPopupPosition(tradeAlertPopupPosition); + const popupLayout = normalizeTradeAlertPopupLayout(tradeAlertPopupLayout); + const gridCols = normalizeTradeAlertGridCols(tradeAlertPopupGridCols); + + const content = ( +
+ { + onGoToChart(market); + onPage('chart'); + }} + onOpenList={() => onPage('notifications')} + popupPosition={popupPosition} + popupLayout={popupLayout} + gridCols={gridCols} + /> + {detailSignal && ( + + )} +
+ ); + + if (typeof document === 'undefined') return null; + return createPortal(content, document.body); +}; + +export default AppNotificationLayer; diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx new file mode 100644 index 0000000..657c03e --- /dev/null +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -0,0 +1,289 @@ +/** + * BacktestHistoryPage + * ───────────────────────────────────────────────────── + * [좌측] 체크박스 선택 + 삭제 가능한 이력 목록 + * [우측] 카드 기반 대시보드 결과 뷰 + */ +import React, { useEffect, useState, useCallback } from 'react'; +import { + loadBacktestResults, + deleteBacktestResult, + type BacktestResultRecord, + type BacktestAnalysis, + type BacktestSignal, +} from '../utils/backendApi'; +import { BacktestDashboard } from './BacktestResultModal'; + +// ── 유틸 ────────────────────────────────────────────────────────────────── + +const pct = (v: number) => + isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '–'; + +const fmtDateTime = (iso: string) => { + const d = new Date(iso); + return { + date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`, + time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, + }; +}; + +// ── 아이콘 ──────────────────────────────────────────────────────────────── + +const IcTrash = () => ( + + + + + + +); + +const IcTrashAll = () => ( + + + + + + +); + +const IcRefresh = () => ( + + + + +); + +// ── 목록 아이템 ──────────────────────────────────────────────────────────── + +interface ListItemProps { + r: BacktestResultRecord; + checked: boolean; + active: boolean; + onToggle: () => void; + onClick: () => void; +} + +function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) { + const ret = r.totalReturn ?? 0; + const dt = fmtDateTime(r.createdAt); + return ( +
+
{ e.stopPropagation(); onToggle(); }}> +
+ {checked && } +
+
+ +
+
+ {r.strategyName || '전략 없음'} + 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)} +
+
+ {r.symbol} + · + {r.timeframe} + · + {r.totalTrades}회 +
+
+ {dt.date} + {dt.time} + {(r.winRate ?? 0) > 0 && ( + 승률 {((r.winRate ?? 0) * 100).toFixed(0)}% + )} +
+
+
+ ); +} + +// ── 메인 페이지 ─────────────────────────────────────────────────────────── + +export function BacktestHistoryPage() { + const [records, setRecords] = useState([]); + const [selected, setSelected] = useState(null); + const [checked, setChecked] = useState>(new Set()); + const [loading, setLoading] = useState(true); + + // ── 로드 ──────────────────────────────────────────────────────────────── + const fetchList = useCallback(async () => { + setLoading(true); + const list = await loadBacktestResults(); + setRecords(list); + if (list.length > 0) setSelected(prev => prev ?? list[0]); + setLoading(false); + }, []); + + useEffect(() => { fetchList(); }, [fetchList]); + + // ── 체크박스 ────────────────────────────────────────────────────────── + const toggleCheck = useCallback((id: number) => { + setChecked(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + setChecked(prev => + prev.size === records.length + ? new Set() + : new Set(records.map(r => r.id)) + ); + }, [records]); + + const allChecked = records.length > 0 && checked.size === records.length; + const someChecked = checked.size > 0 && !allChecked; + + // ── 삭제 ───────────────────────────────────────────────────────────── + const deleteSelected = useCallback(async () => { + if (checked.size === 0) return; + if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return; + await Promise.all([...checked].map(id => deleteBacktestResult(id))); + setRecords(prev => { + const next = prev.filter(r => !checked.has(r.id)); + if (selected && checked.has(selected.id)) setSelected(next[0] ?? null); + return next; + }); + setChecked(new Set()); + }, [checked, selected]); + + const deleteAll = useCallback(async () => { + if (records.length === 0) return; + if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return; + await Promise.all(records.map(r => deleteBacktestResult(r.id))); + setRecords([]); + setSelected(null); + setChecked(new Set()); + }, [records]); + + // ── 상세 데이터 파싱 ────────────────────────────────────────────────── + const getDetail = (r: BacktestResultRecord) => { + let analysis: BacktestAnalysis | null = null; + let signals: BacktestSignal[] = []; + try { + if (r.analysisJson) analysis = JSON.parse(r.analysisJson); + if (r.signalsJson) signals = JSON.parse(r.signalsJson); + } catch (_) {} + return { analysis, signals }; + }; + + // ── 렌더 ───────────────────────────────────────────────────────────── + return ( +
+ + {/* ── 좌측 목록 ── */} + + + {/* ── 우측 대시보드 ── */} +
+ {selected ? ( + (() => { + const { analysis, signals } = getDetail(selected); + return ( +
+ +
+ ); + })() + ) : ( +
+
📈
+

좌측 목록에서 백테스팅 결과를 선택하세요.

+
+ )} +
+
+ ); +} + +export default BacktestHistoryPage; diff --git a/frontend/src/components/BacktestPanel.tsx b/frontend/src/components/BacktestPanel.tsx new file mode 100644 index 0000000..dca6c8b --- /dev/null +++ b/frontend/src/components/BacktestPanel.tsx @@ -0,0 +1,59 @@ +/** + * 백테스팅 결과 통계 배지 — 차트 위에 오버레이로 표시. + * 전략 선택 / 실행은 Toolbar의 드롭다운에서 담당. + */ +import React from 'react'; +import type { BacktestStats } from '../utils/backendApi'; + +interface BacktestPanelProps { + stats: BacktestStats | null; + signalCount: number; + onClear: () => void; +} + +const BacktestPanel: React.FC = ({ stats, signalCount, onClear }) => { + const fmt = (n: number) => (n * 100).toFixed(2) + '%'; + const fmtWon = (n: number) => n >= 1000 ? (n / 10000).toFixed(0) + '만원' : n.toFixed(0) + '원'; + const retColor = stats && stats.totalReturn > 0 ? '#26A69A' : stats && stats.totalReturn < 0 ? '#EF5350' : 'var(--text2)'; + + return ( +
+
+ 시그널 + {signalCount}개 +
+ {stats && ( + <> +
+
+ 거래 + {stats.totalTrades}회 +
+
+ 승률 + {fmt(stats.winRate)} +
+
+ 수익 + + {stats.totalReturn >= 0 ? '+' : ''}{fmt(stats.totalReturn)} + +
+
+ 낙폭 + {fmt(stats.maxDrawdown)} +
+ {stats.finalEquity > 0 && ( +
+ 최종자산 + {fmtWon(stats.finalEquity)} +
+ )} + + )} + +
+ ); +}; + +export default BacktestPanel; diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx new file mode 100644 index 0000000..d83a1c6 --- /dev/null +++ b/frontend/src/components/BacktestResultModal.tsx @@ -0,0 +1,504 @@ +/** + * BacktestResultModal / BacktestDashboard + * ───────────────────────────────────────────────────────────────────── + * BacktestDashboard — 카드 기반 대시보드 (재사용 가능) + * BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼 + */ +import React, { useState } from 'react'; +import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +// ── 포맷 유틸 ──────────────────────────────────────────────────────────────── + +const pct = (v: number, dec = 2) => + isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '–'; +const pctAbs = (v: number, dec = 1) => + isFinite(v) && v !== 0 ? `${(v * 100).toFixed(dec)}%` : '–'; +const num = (v: number, dec = 2) => + isFinite(v) && v !== 0 ? v.toFixed(dec) : '–'; +const wonFmt = (v: number) => { + if (!isFinite(v) || v === 0) return '–'; + const abs = Math.abs(Math.round(v)); + const s = abs >= 100_000_000 ? `${(abs/100_000_000).toFixed(2)}억` + : abs >= 10_000 ? `${(abs/10_000).toFixed(1)}만` + : abs.toLocaleString(); + return (v >= 0 ? '+' : '-') + s + '원'; +}; +const fmtDate = (ts: number) => { + const d = new Date(ts * 1000); + return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`; +}; +const fmtDateStr = (iso: string) => { + const d = new Date(iso); + return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; +}; + +const colorCls = (v: number) => v > 0 ? 'brd-pos' : v < 0 ? 'brd-neg' : ''; +const colorStyle = (v: number): React.CSSProperties => ({ + color: v > 0 ? 'var(--brd-pos)' : v < 0 ? 'var(--brd-neg)' : undefined, +}); + +// ── Props ───────────────────────────────────────────────────────────────── + +export interface BacktestResultModalProps { + analysis?: BacktestAnalysis | null; + stats?: BacktestStats | null; + signals?: BacktestSignal[]; + strategyName: string; + symbol: string; + timeframe: string; + barCount: number; + onClose: () => void; + embedded?: boolean; +} + +export interface BacktestDashboardProps { + analysis: BacktestAnalysis | null; + signals: BacktestSignal[]; + strategyName: string; + symbol: string; + timeframe: string; + barCount: number; + createdAt?: string; +} + +// ── 섹션 제목 ───────────────────────────────────────────────────────────── + +function SectionTitle({ icon, title, right }: { icon: string; title: string; right?: React.ReactNode }) { + return ( +
+ {icon} + {title} + {right && {right}} +
+ ); +} + +// ── KPI 카드 ────────────────────────────────────────────────────────────── + +function KpiCard({ icon, label, value, sub, valueColor, accent }: + { icon: string; label: string; value: string; sub: string; valueColor?: string; accent?: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
{sub}
+
+ ); +} + +// ── 미니 지표 행 (테이블 스타일) ───────────────────────────────────────── + +function MetricRow({ label, value, cls, note }: { label: string; value: string; cls?: string; note?: string }) { + return ( +
+ + {label} + {note && {note}} + + {value} +
+ ); +} + +// ── 승률 도넛 (CSS 원) ──────────────────────────────────────────────────── + +function WinRateCircle({ winRate, winning, total }: { winRate: number; winning: number; total: number }) { + const r = 28; + const circ = 2 * Math.PI * r; + const pct2 = Math.max(0, Math.min(1, winRate)); + const dash = pct2 * circ; + return ( +
+ + + = 0.5 ? 'var(--brd-pos)' : 'var(--brd-neg)'} + strokeWidth="8" + strokeDasharray={`${dash} ${circ}`} + strokeLinecap="round" + strokeDashoffset={circ * 0.25} + style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }} + /> + {pctAbs(winRate)} + 승률 + +
+ ▲ {winning} + ▼ {total - winning} +
+
+ ); +} + +// ── 수평 비교 바 ───────────────────────────────────────────────────────── + +function CompareBar({ labelA, valA, labelB, valB, colorA, colorB }: + { labelA: string; valA: number; labelB: string; valB: number; colorA: string; colorB: string }) { + const max = Math.max(Math.abs(valA), Math.abs(valB), 0.01); + const pctA2 = Math.min(100, Math.abs(valA) / max * 100); + const pctB2 = Math.min(100, Math.abs(valB) / max * 100); + return ( +
+
+ {labelA} +
+
+
+ {pct(valA)} +
+
+ {labelB} +
+
+
+ {pct(valB)} +
+
+ ); +} + +// ── 시그널 타입 ─────────────────────────────────────────────────────────── + +const SIG_LABEL: Record = { + BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도', +}; +const SIG_COLOR: Record = { + BUY:'brd-pos', SELL:'brd-neg', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn', +}; + +// ══════════════════════════════════════════════════════════════════════════ +// BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능) +// ══════════════════════════════════════════════════════════════════════════ + +export function BacktestDashboard({ + analysis, signals, strategyName, symbol, timeframe, barCount, createdAt, +}: BacktestDashboardProps) { + const [sigExpanded, setSigExpanded] = useState(false); + + const a = analysis; + if (!a) { + return ( +
+
📊
+

결과 데이터가 없습니다.

+
+ ); + } + + const sigLimit = sigExpanded ? signals.length : 8; + const periodStr = signals.length >= 2 + ? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}` + : `${barCount}봉`; + + const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡'; + + return ( +
+ + {/* ── 대시보드 헤더 ── */} +
+
+
{strategyName}
+
+ {symbol} + {timeframe} + {periodStr} + {createdAt && 실행 {fmtDateStr(createdAt)}} +
+
+
+
+
{pct(a.totalReturnPct)}
+
총 수익률
+
+
+
+ {wonFmt(a.finalEquity)} +
+
최종 자산
+
+
+
+ + {/* ── Row 1: KPI 카드 6개 ── */} +
+
+ + + + + + +
+
+ + {/* ── Row 2: 수익성 + 거래 통계 + 승률 도넛 ── */} +
+ + {/* 수익성 지표 카드 */} +
+ +
+ + + + + + + +
+
+ + {/* 리스크 지표 카드 */} +
+ +
+ + + + + + +
+
+ + {/* 승률 + 자본 카드 */} +
+ +
+ +
+ + + + + +
+
+
+
+ + {/* ── Row 3: 벤치마크 비교 + 자본 분석 ── */} +
+ + {/* 벤치마크 비교 */} +
+ + {isFinite(a.vsBuyAndHold) && a.vsBuyAndHold !== 0 + ? `${a.vsBuyAndHold >= 1 ? '전략 우위 ' : ''}${a.vsBuyAndHold.toFixed(2)}×` + : '–' + } + + }/> +
+ = 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'} + colorB="var(--text3)" + /> +
+
+ 전략 + {pct(a.totalReturnPct)} +
+
+
+ 바이앤홀드 + {pct(a.buyAndHoldReturnPct)} +
+
+
+ 초과 수익 + + {pct(a.totalReturnPct - a.buyAndHoldReturnPct)} + +
+
+
+
+ + {/* 수익/손실 상세 */} +
+ +
+
+
+ 총 이익 +
+
0 && Math.abs(a.grossLoss) > 0 + ? Math.min(100, a.grossProfit / (a.grossProfit + Math.abs(a.grossLoss)) * 100) + : a.grossProfit > 0 ? 100 : 0}%` + }}/> +
+ {wonFmt(a.grossProfit)} +
+
+ 총 손실 +
+
0 && a.grossProfit > 0 + ? Math.min(100, Math.abs(a.grossLoss) / (a.grossProfit + Math.abs(a.grossLoss)) * 100) + : Math.abs(a.grossLoss) > 0 ? 100 : 0}%` + }}/> +
+ {wonFmt(a.grossLoss)} +
+
+
+ 순 손익 + {wonFmt(a.totalProfitLoss)} +
+ + +
+
+
+ + {/* ── Row 4: 거래 시그널 목록 ── */} +
+
+ {signals.length}건 + }/> + {signals.length === 0 ? ( +
시그널이 없습니다.
+ ) : ( + <> +
+ + + + + + + + + + + + {signals.slice(0, sigLimit).map((s, i) => ( + + + + + + + + ))} + +
#날짜구분가격봉#
{i+1}{fmtDate(s.time)} + + {SIG_LABEL[s.type] ?? s.type} + + + {Math.round(s.price).toLocaleString()} + {s.barIndex}
+
+ {signals.length > 8 && ( + + )} + + )} +
+
+ +
+ ); +} + +// ══════════════════════════════════════════════════════════════════════════ +// BacktestResultModal — 팝업 래퍼 +// ══════════════════════════════════════════════════════════════════════════ + +export function BacktestResultModal({ + analysis, stats, signals = [], strategyName, symbol, timeframe, barCount, onClose, embedded = false, +}: BacktestResultModalProps) { + + const { + panelRef: modalRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: !embedded }); + + const a = analysis ?? (stats ? buildAnalysisFromStats(stats) : null); + if (!a && !stats) return null; + + const inner = ( +
e.stopPropagation()} + > +
+
+ 백테스팅 결과 +
+ {!embedded && ( + + )} +
+
+ +
+
+ ); + + if (embedded) return inner; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> + {inner} +
+ ); +} + +function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis { + return { + initialCapital: 10_000_000, + finalEquity: s.finalEquity ?? 0, + totalReturnPct: s.totalReturn ?? 0, + totalProfitLoss: (s.finalEquity ?? 0) - 10_000_000, + grossProfit: 0, grossLoss: 0, + avgReturnPct: s.avgReturn ?? 0, + profitLossRatio: 0, + numberOfPositions: s.totalTrades ?? 0, + numberOfWinning: s.winTrades ?? 0, + numberOfLosing: (s.totalTrades ?? 0) - (s.winTrades ?? 0), + numberOfBreakEven: 0, + winRate: s.winRate ?? 0, + maxDrawdownPct: s.maxDrawdown ?? 0, + maxRunupPct: 0, sharpeRatio: 0, sortinoRatio: 0, calmarRatio: 0, + valueAtRisk95: 0, expectedShortfall: 0, + buyAndHoldReturnPct: 0, vsBuyAndHold: 0, + }; +} + +export default BacktestResultModal; diff --git a/frontend/src/components/BacktestSettingsModal.tsx b/frontend/src/components/BacktestSettingsModal.tsx new file mode 100644 index 0000000..4262bff --- /dev/null +++ b/frontend/src/components/BacktestSettingsModal.tsx @@ -0,0 +1,428 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; +import { + BacktestSettingsDto, + DEFAULT_BACKTEST_SETTINGS, + loadBacktestSettings, + saveBacktestSettings, +} from '../utils/backendApi'; + +// ── 설명 메타데이터 ───────────────────────────────────────────────────────── + +interface FieldMeta { + label: string; + description: string; +} + +const FIELD_META: Partial> = { + initialCapital: { + label: '초기 자본', + description: + '백테스팅 시뮬레이션의 시작 자본금(원)입니다. 수익/손실 금액과 최종 자산을 이 금액 기준으로 계산합니다.\n예) 1,000만원 설정 → 총 수익률 +15% 달성 시 최종 자산 = 1,150만원으로 표시됩니다.', + }, + commissionType: { + label: '수수료 유형', + description: + '매매 수수료 계산 방식을 선택합니다.\n• LINEAR: 거래금액 × 수수료율로 계산합니다. 국내 거래소 현물거래에 적합합니다.\n• ZERO: 수수료 없이 계산합니다. 순수 전략 성과만 검증할 때 사용합니다.', + }, + commissionRate: { + label: '수수료율', + description: + '매수·매도 각 1회당 부과되는 수수료율입니다.\n예) 0.15% = 0.0015, 거래금액 100만원일 때 수수료 1,500원\n• 업비트 현물: 약 0.05% (0.0005)\n• 빗썸 현물: 약 0.25% (0.0025)\n왕복(매수+매도) 합산 수수료가 손익에서 차감됩니다.', + }, + slippageRate: { + label: '슬리피지', + description: + '주문을 낼 때 의도한 가격과 실제 체결 가격의 차이 비율입니다.\n• 매수: 목표가보다 슬리피지만큼 높게 체결 (불리)\n• 매도: 목표가보다 슬리피지만큼 낮게 체결 (불리)\n예) 슬리피지 0.05%, 100만원 매수 → 실제 체결 1,000,500원\n거래량이 적거나 변동성이 높을수록 슬리피지가 커집니다.', + }, + entryPriceType: { + label: '진입 가격 기준', + description: + '매수 신호 발생 시 어느 가격에 진입하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 진입. 구현이 단순하지만 후행성(look-ahead bias) 우려가 있습니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 진입. 실제 트레이딩과 가장 유사한 현실적 시뮬레이션입니다.', + }, + exitPriceType: { + label: '청산 가격 기준', + description: + '매도 신호 발생 시 어느 가격에 청산하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 청산합니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 청산. 야간 갭 하락 등 실제 리스크를 반영합니다.', + }, + positionDirection: { + label: '포지션 방향', + description: + '어떤 방향의 거래를 시뮬레이션할지 선택합니다.\n• LONG(매수만): 매수 후 가격 상승을 기대하는 전략입니다. 가장 일반적인 방식입니다.\n• SHORT(공매도): 매도 후 가격 하락을 기대합니다. 선물/마진 거래 전략 검증에 사용합니다.\n• BOTH(양방향): 매수 신호는 롱 진입, 매도 신호는 숏 진입으로 처리합니다.', + }, + tradeSizeType: { + label: '거래 규모 유형', + description: + '매 거래마다 사용할 자금 규모 방식을 선택합니다.\n• CAPITAL_PCT(자본 비율): 현재 보유 자본의 N%를 거래에 사용합니다. 복리 효과가 반영됩니다.\n• FIXED_AMOUNT(고정 금액): 매 거래마다 동일한 금액(원)을 사용합니다. 단리 방식과 유사합니다.', + }, + tradeSizeValue: { + label: '거래 규모 값', + description: + '자본 비율 선택 시: 매 거래에 사용할 자본 비율(%)입니다.\n예) 100 = 전체 자본 사용, 50 = 절반만 사용\n\n고정 금액 선택 시: 매 거래마다 사용할 원화 금액입니다.\n예) 1,000,000 = 매 거래당 100만원 고정 투자', + }, + stopLossEnabled: { + label: '손절 활성화', + description: + '진입가 대비 N% 하락 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopLossRule).\n전략의 매도 조건보다 손절이 먼저 발동될 경우 손절로 청산됩니다.\n예) 손절 비율 2% 설정, 100만원 매수 → 가격이 98만원으로 하락 시 자동 손절', + }, + stopLossPct: { + label: '손절 비율 (%)', + description: + '진입가 대비 이 비율만큼 가격이 하락하면 즉시 포지션을 청산합니다.\n예) 2.0 설정 → 진입가 100,000원일 때 98,000원 이하로 하락 시 손절\n낮은 값: 손실 최소화 but 잦은 손절로 기회 상실 가능\n높은 값: 큰 손실 허용 but 반전 기회 포착 가능', + }, + takeProfitEnabled: { + label: '익절 활성화', + description: + '진입가 대비 N% 상승 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopGainRule).\n목표 수익률에 도달하면 확실히 이익을 실현합니다.\n예) 익절 비율 5% 설정, 100만원 매수 → 가격이 105만원으로 상승 시 자동 익절', + }, + takeProfitPct: { + label: '익절 비율 (%)', + description: + '진입가 대비 이 비율만큼 가격이 상승하면 즉시 포지션을 청산합니다.\n예) 5.0 설정 → 진입가 100,000원일 때 105,000원 이상 상승 시 익절\n낮은 값: 자주 이익 실현 but 대세 상승장 추세 이탈 가능\n높은 값: 큰 수익 추구 but 수익 반납 위험 증가', + }, + trailingStopEnabled: { + label: '트레일링 스탑 활성화', + description: + '포지션 보유 중 도달한 최고가 대비 N% 하락 시 자동 청산하는 기능입니다 (Ta4j TrailingStopLossRule).\n고정 손절과 달리 추세를 따라 기준점이 올라가므로 상승 추세를 최대한 활용하면서 수익을 보호합니다.\n예) 100만원 매수, 트레일링 2% → 가격이 120만원까지 상승 후 2% 하락한 117.6만원에서 청산', + }, + trailingStopPct: { + label: '트레일링 비율 (%)', + description: + '포지션 진입 후 기록된 최고가 대비 이 비율만큼 하락하면 청산합니다.\n예) 2.0 설정, 최고가 120,000원 → 117,600원 (= 120,000 × 0.98) 이하 하락 시 청산\n낮은 값: 소폭 하락에도 청산, 수익 보호에 유리\n높은 값: 큰 조정을 허용, 장기 추세 추종에 유리', + }, + reentryWaitBars: { + label: '재진입 대기 봉 수', + description: + '포지션 청산(매도) 후 다음 매수 신호를 받을 때까지 최소 대기해야 하는 봉(캔들) 수입니다.\n연속 매매를 방지하고 시장이 안정되길 기다리는 효과가 있습니다.\n예) 3 설정 → 매도 후 3봉 동안은 매수 신호 무시\n0: 즉시 재진입 허용 (기본값)', + }, + maxOpenTrades: { + label: '최대 동시 포지션', + description: + '동시에 보유할 수 있는 최대 포지션(거래) 수입니다.\n현재 구현에서는 단일 포지션(1)만 완전히 지원됩니다.\n1로 설정 시: 이미 포지션을 보유 중이면 새 매수 신호를 무시합니다.', + }, + partialExitEnabled: { + label: '분할 청산 활성화', + description: + '첫 매도 신호 발생 시 포지션 전부가 아닌 일부만 청산하는 기능입니다.\n리스크를 줄이면서도 나머지 포지션으로 추가 상승을 노릴 수 있습니다.\n예) 분할 청산 50% 설정 → 첫 매도 신호에 50% 청산, 이후 다음 신호 또는 손절/익절 시 나머지 50% 청산', + }, + partialExitPct: { + label: '1차 청산 비율 (%)', + description: + '첫 번째 매도 신호 발생 시 현재 포지션의 몇 퍼센트를 청산할지 설정합니다.\n예) 50 설정 → 보유량의 절반을 1차 청산, 나머지는 계속 보유\n예) 30 설정 → 보유량의 30%만 1차 청산 (더 공격적 운용)', + }, +}; + +// ── 섹션 정의 ───────────────────────────────────────────────────────────── + +interface SettingField { + key: keyof BacktestSettingsDto; + type: 'number' | 'select' | 'toggle' | 'percent'; + opts?: { value: string; label: string }[]; + min?: number; max?: number; step?: number; + /** 이 필드가 보이려면 이 조건이 true여야 함 */ + visibleWhen?: (cfg: BacktestSettingsDto) => boolean; +} + +interface SettingSection { + title: string; + fields: SettingField[]; +} + +const SECTIONS: SettingSection[] = [ + { + title: '💰 자본 설정', + fields: [ + { key: 'initialCapital', type: 'number', min: 100000, step: 1000000 }, + ], + }, + { + title: '💸 비용 모델', + fields: [ + { + key: 'commissionType', type: 'select', + opts: [{ value:'LINEAR', label:'LINEAR (비율 수수료)' }, { value:'ZERO', label:'ZERO (수수료 없음)' }], + }, + { key: 'commissionRate', type: 'percent', min: 0, max: 5, step: 0.01, + visibleWhen: c => c.commissionType === 'LINEAR' }, + { key: 'slippageRate', type: 'percent', min: 0, max: 5, step: 0.01 }, + ], + }, + { + title: '📈 거래 가격', + fields: [ + { + key: 'entryPriceType', type: 'select', + opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }], + }, + { + key: 'exitPriceType', type: 'select', + opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }], + }, + { + key: 'positionDirection', type: 'select', + opts: [ + { value:'LONG', label:'LONG (매수만)' }, + { value:'SHORT', label:'SHORT (공매도)' }, + { value:'BOTH', label:'BOTH (양방향)' }, + ], + }, + ], + }, + { + title: '📦 거래 규모', + fields: [ + { + key: 'tradeSizeType', type: 'select', + opts: [{ value:'CAPITAL_PCT', label:'자본 비율 (%)' }, { value:'FIXED_AMOUNT', label:'고정 금액 (원)' }], + }, + { key: 'tradeSizeValue', type: 'number', min: 1, step: 1 }, + ], + }, + { + title: '🛑 손절', + fields: [ + { key: 'stopLossEnabled', type: 'toggle' }, + { key: 'stopLossPct', type: 'number', min: 0.1, max: 50, step: 0.1, + visibleWhen: c => !!c.stopLossEnabled }, + ], + }, + { + title: '🎯 익절', + fields: [ + { key: 'takeProfitEnabled', type: 'toggle' }, + { key: 'takeProfitPct', type: 'number', min: 0.1, max: 200, step: 0.1, + visibleWhen: c => !!c.takeProfitEnabled }, + ], + }, + { + title: '📉 트레일링 스탑', + fields: [ + { key: 'trailingStopEnabled', type: 'toggle' }, + { key: 'trailingStopPct', type: 'number', min: 0.1, max: 50, step: 0.1, + visibleWhen: c => !!c.trailingStopEnabled }, + ], + }, + { + title: '🔁 재진입 제어', + fields: [ + { key: 'reentryWaitBars', type: 'number', min: 0, max: 100, step: 1 }, + { key: 'maxOpenTrades', type: 'number', min: 1, max: 10, step: 1 }, + ], + }, + { + title: '✂️ 분할 청산', + fields: [ + { key: 'partialExitEnabled', type: 'toggle' }, + { key: 'partialExitPct', type: 'number', min: 10, max: 90, step: 5, + visibleWhen: c => !!c.partialExitEnabled }, + ], + }, +]; + +// ── 컴포넌트 ──────────────────────────────────────────────────────────────── + +interface Props { + onClose: () => void; + onSettingsChange: (s: BacktestSettingsDto) => void; +} + +export default function BacktestSettingsModal({ onClose, onSettingsChange }: Props) { + const [cfg, setCfg] = useState(DEFAULT_BACKTEST_SETTINGS); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [focused, setFocused] = useState('initialCapital'); + + const { + panelRef: modalRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + // 설정 로드 + useEffect(() => { + loadBacktestSettings().then(s => { if (s) setCfg(s); }); + }, []); + + const set = (k: K, v: BacktestSettingsDto[K]) => + setCfg(prev => ({ ...prev, [k]: v })); + + const handleSave = async () => { + setSaving(true); + const result = await saveBacktestSettings(cfg); + if (result) { + onSettingsChange({ ...cfg, ...result }); + setSaved(true); + setTimeout(() => setSaved(false), 1500); + } + setSaving(false); + }; + + const focusMeta = focused ? FIELD_META[focused] : null; + + // 모든 섹션의 보이는 필드를 1차원 배열로 수집 (2열 그리드용) + const allFields: Array<{ section: string; field: SettingField }> = []; + for (const sec of SECTIONS) { + for (const f of sec.fields) { + if (!f.visibleWhen || f.visibleWhen(cfg)) { + allFields.push({ section: sec.title, field: f }); + } + } + } + + return ( +
+ {/* ── 타이틀바 (드래그 핸들) ── */} +
+ + + + + 백테스팅 설정 + +
+ + {/* ── 2열 설정 그리드 ── */} +
+ {SECTIONS.map(sec => { + const visibleFields = sec.fields.filter(f => !f.visibleWhen || f.visibleWhen(cfg)); + if (visibleFields.length === 0) return null; + return ( +
+
{sec.title}
+
+ {visibleFields.map(f => ( + + ))} +
+
+ ); + })} +
+ + {/* ── 하단 설명 패널 ── */} +
+ {focusMeta ? ( + <> + {focusMeta.label} + + {focusMeta.description} + + ) : ( + 설정 항목을 클릭하면 상세 설명이 표시됩니다. + )} +
+ + {/* ── 푸터 ── */} +
+ +
+ + +
+
+ ); +} + +// ── FieldRow ───────────────────────────────────────────────────────────────── + +function FieldRow({ + field, cfg, focused, onFocus, onChange, +}: { + field: SettingField; + cfg: BacktestSettingsDto; + focused: keyof BacktestSettingsDto | null; + onFocus: (k: keyof BacktestSettingsDto) => void; + onChange: (k: K, v: BacktestSettingsDto[K]) => void; +}) { + const meta = FIELD_META[field.key]; + const label = meta?.label ?? field.key; + const isFocused = focused === field.key; + + const renderCtrl = () => { + const val = cfg[field.key]; + switch (field.type) { + case 'toggle': + return ( + + ); + case 'select': + return ( + + ); + case 'percent': + return ( +
+ onChange(field.key, Number(e.target.value) / 100 as BacktestSettingsDto[typeof field.key])} + onClick={e => e.stopPropagation()} + /> + % +
+ ); + default: + return ( + onChange(field.key, Number(e.target.value) as BacktestSettingsDto[typeof field.key])} + onClick={e => e.stopPropagation()} + /> + ); + } + }; + + return ( +
onFocus(field.key)} + > + {label} + {renderCtrl()} +
+ ); +} diff --git a/frontend/src/components/BacktestSettingsPanel.tsx b/frontend/src/components/BacktestSettingsPanel.tsx new file mode 100644 index 0000000..deac132 --- /dev/null +++ b/frontend/src/components/BacktestSettingsPanel.tsx @@ -0,0 +1,335 @@ +/** + * 설정 화면 — 백테스팅 설정 패널 (2열 카드 레이아웃) + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + type BacktestSettingsDto, + DEFAULT_BACKTEST_SETTINGS, + loadBacktestSettings, + saveBacktestSettings, +} from '../utils/backendApi'; + +// ── 백테스팅 전용 UI 컴포넌트 ─────────────────────────────────────────────── + +const BtSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+

{title}

+
{children}
+
+); + +const BtGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +const BtField: React.FC<{ + label: string; + desc?: string; + full?: boolean; + children: React.ReactNode; +}> = ({ label, desc, full, children }) => ( +
+
+ {label} + {desc && {desc}} +
+
{children}
+
+); + +export interface BacktestSettingsPanelProps { + btAutoPopup?: boolean; + onBtAutoPopup?: (v: boolean) => void; + btShowPrice?: boolean; + onBtShowPrice?: (v: boolean) => void; +} + +export const BacktestSettingsPanel: React.FC = ({ + btAutoPopup = true, + onBtAutoPopup, + btShowPrice = true, + onBtShowPrice, +}) => { + const [cfg, setCfg] = useState(DEFAULT_BACKTEST_SETTINGS); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + loadBacktestSettings().then(s => { if (s) setCfg(s); }); + }, []); + + const update = useCallback(async (patch: Partial) => { + const next = { ...cfg, ...patch }; + setCfg(next); + setSaving(true); + setSaved(false); + try { + await saveBacktestSettings(next); + setSaved(true); + setTimeout(() => setSaved(false), 1500); + } finally { + setSaving(false); + } + }, [cfg]); + + const field = (key: K, value: BacktestSettingsDto[K]) => + update({ [key]: value } as Partial); + + return ( +
+ {(saving || saved) && ( +
+ {saving ? '저장 중…' : '✓ 저장됨'} +
+ )} + + + + + + + + + + + + + + + + field('initialCapital', Number(e.target.value))} + /> + + + + + + field('tradeSizeValue', Number(e.target.value))} + /> + + + + + + + + + + {cfg.commissionType === 'LINEAR' ? ( + + field('commissionRate', Number(e.target.value) / 100)} + /> + + ) : ( +
+ )} + + field('slippageRate', Number(e.target.value) / 100)} + /> + + + + + + + + + + + + + + + + + + + + + + + + {cfg.stopLossEnabled ? ( + + field('stopLossPct', Number(e.target.value))} /> + + ) :
} + + + + + {cfg.takeProfitEnabled ? ( + + field('takeProfitPct', Number(e.target.value))} /> + + ) :
} + + + + + {cfg.trailingStopEnabled ? ( + + field('trailingStopPct', Number(e.target.value))} /> + + ) :
} + + + + + + + field('reentryWaitBars', Number(e.target.value))} /> + + + field('maxOpenTrades', Number(e.target.value))} /> + + + + + {cfg.partialExitEnabled ? ( + + field('partialExitPct', Number(e.target.value))} /> + + ) :
} + + + + + +
+ + +
+
+ {cfg.positionMode === 'SIGNAL_ONLY' && ( +
+ Signal-Only 모드: 매도 시그널이 이전 매수 없이도 발생합니다 +
+ )} +
+
+ ); +}; + +export default BacktestSettingsPanel; diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx new file mode 100644 index 0000000..6f84bf5 --- /dev/null +++ b/frontend/src/components/BottomBar.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import type { Timeframe } from '../types'; +import TimezonePicker from './TimezonePicker'; + +interface BottomBarProps { + timeframe: Timeframe; + logScale: boolean; + percentScale: boolean; + showGrid: boolean; + lastTime: number; + displayTimezone: string; + onTimezoneChange: (tz: string) => void; + onTimeframe: (t: Timeframe) => void; + onLogScale: () => void; + onPercentScale: () => void; + onAutoScale: () => void; + onFitContent: () => void; + onToggleGrid: () => void; +} + +const PERIODS: { label: string; tf: Timeframe }[] = [ + { label: '1M', tf: '1M' }, + { label: '1W', tf: '1W' }, + { label: '1D', tf: '1D' }, + { label: '4h', tf: '4h' }, + { label: '1h', tf: '1h' }, + { label: '30m',tf: '30m' }, + { label: '5m', tf: '5m' }, + { label: '3m', tf: '3m' }, + { label: '1m', tf: '1m' }, +]; + +const IcCalendar = () => ( + + + + + + +); + +const IcGrid = () => ( + + + + + + + +); + +const BottomBar: React.FC = ({ + timeframe, logScale, percentScale, showGrid, lastTime, + displayTimezone, onTimezoneChange, + onTimeframe, onLogScale, onPercentScale, onAutoScale, onFitContent, onToggleGrid, +}) => { + return ( +
+
+ {PERIODS.map(p => ( + + ))} +
+ + + +
+ + + +
+ + + + +
+
+ ); +}; + +export default BottomBar; diff --git a/frontend/src/components/CandlePaneControls.tsx b/frontend/src/components/CandlePaneControls.tsx new file mode 100644 index 0000000..27367ee --- /dev/null +++ b/frontend/src/components/CandlePaneControls.tsx @@ -0,0 +1,94 @@ +/** + * 캔들 pane 좌측 하단 — 캔들 확대 보기 / 기본 보기 복원 버튼 + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { ChartManager } from '../utils/ChartManager'; + +const IconExpand = () => ( + + + + + + +); + +const IconRestore = () => ( + + + + + + +); + +interface CandlePaneControlsProps { + manager: ChartManager; + containerEl: HTMLElement; + candleOnly: boolean; + onExpand: () => void; + onRestore: () => void; +} + +const CandlePaneControls: React.FC = ({ + manager, + containerEl, + candleOnly, + onExpand, + onRestore, +}) => { + const [pos, setPos] = useState<{ left: number; top: number } | null>(null); + + const updatePos = useCallback(() => { + const main = manager.getPaneLayouts().find(l => l.paneIndex === 0); + if (!main) { setPos(null); return; } + const rect = containerEl.getBoundingClientRect(); + const paneBottomY = rect.top + main.topY + main.height; + setPos({ + left: rect.left + 4, + top: paneBottomY - 30, + }); + }, [manager, containerEl]); + + useEffect(() => { + updatePos(); + const ro = new ResizeObserver(() => updatePos()); + ro.observe(containerEl); + const id = setInterval(updatePos, 400); + return () => { ro.disconnect(); clearInterval(id); }; + }, [containerEl, updatePos, candleOnly]); + + if (!pos) return null; + + return ( +
e.stopPropagation()} + > + {candleOnly ? ( + + ) : ( + + )} +
+ ); +}; + +export default CandlePaneControls; diff --git a/frontend/src/components/ChartContextMenu.tsx b/frontend/src/components/ChartContextMenu.tsx new file mode 100644 index 0000000..61cb25c --- /dev/null +++ b/frontend/src/components/ChartContextMenu.tsx @@ -0,0 +1,64 @@ +/** + * 차트 우클릭 컨텍스트 메뉴 (매수 / 매도) + */ +import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +export interface ChartContextMenuProps { + x: number; + y: number; + marketLabel: string; + priceLabel: string; + onBuy: () => void; + onSell: () => void; + onClose: () => void; +} + +const ChartContextMenu: React.FC = ({ + x, y, marketLabel, priceLabel, onBuy, onSell, onClose, +}) => { + const ref = useRef(null); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [onClose]); + + const menuW = 168; + const menuH = 88; + const left = Math.min(x, window.innerWidth - menuW - 8); + const top = Math.min(y, window.innerHeight - menuH - 8); + + return createPortal( +
+
+ {marketLabel} + {priceLabel} +
+ + +
, + document.body, + ); +}; + +export default ChartContextMenu; diff --git a/frontend/src/components/ChartHoverToolbar.tsx b/frontend/src/components/ChartHoverToolbar.tsx new file mode 100644 index 0000000..0245ac5 --- /dev/null +++ b/frontend/src/components/ChartHoverToolbar.tsx @@ -0,0 +1,60 @@ +/** + * ChartHoverToolbar + * 차트 영역에 마우스 오버 시 하단 중앙에 표시되는 플로팅 툴바. + * - 마우스가 차트 영역을 벗어나면 사라진다. + * - 버튼: 줌아웃(−), 줌인(+), 전체보기(⌐┘), 왼쪽(‹), 오른쪽(›) + */ +import React from 'react'; + +interface ChartHoverToolbarProps { + onZoomOut: () => void; + onZoomIn: () => void; + onFit: () => void; + onScrollLeft: () => void; + onScrollRight: () => void; + /** 멀티차트 모드에서 이 슬롯을 단일 전체화면으로 확장. 없으면 기본 fitContent() 사용 */ + onFullView?: () => void; +} + +const ChartHoverToolbar: React.FC = ({ + onZoomOut, onZoomIn, onFit, onScrollLeft, onScrollRight, onFullView, +}) => ( +
e.stopPropagation()}> + + + + + {/* 멀티차트: 단일 전체화면 전환 / 단일차트: 전체 데이터 핏 */} + + + + + +
+); + +export default ChartHoverToolbar; diff --git a/frontend/src/components/ChartLegendBar.tsx b/frontend/src/components/ChartLegendBar.tsx new file mode 100644 index 0000000..b8beb8b --- /dev/null +++ b/frontend/src/components/ChartLegendBar.tsx @@ -0,0 +1,127 @@ +/** + * 캔들차트 상단 OHLCV·지표 범례 (tv-legend) + */ +import React from 'react'; +import type { WsStatus } from '../hooks/useUpbitData'; +import type { ChartMode, IndicatorConfig, LegendData } from '../types'; +import type { ChartLegendVisibility } from '../types/chartLegend'; +import { formatPrice } from '../utils/dataGenerator'; +import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry'; +import { formatBbLegendLabel } from '../utils/bollingerConfig'; + +const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => { + if (!isUpbit) return null; + const map: Record = { + connecting: { dot: 'dot-yellow', label: '연결 중...' }, + connected: { dot: 'dot-lime', label: '실시간' }, + disconnected: { dot: 'dot-red', label: '재연결 중' }, + error: { dot: 'dot-red', label: '오류' }, + }; + const { dot, label } = map[status]; + return {label}; +}; + +export interface ChartLegendBarProps { + visibility: ChartLegendVisibility; + symbol: string; + timeframe: string; + useUpbit: boolean; + wsStatus: WsStatus; + mode: ChartMode; + currentBar: LegendData | null; + isUp: boolean; + dailyChange: number; + dailyChangePct: number; + indicators: IndicatorConfig[]; + legend: LegendData | null; +} + +export const ChartLegendBar: React.FC = ({ + visibility: v, + symbol, + timeframe, + useUpbit, + wsStatus, + mode, + currentBar, + isUp, + dailyChange, + dailyChangePct, + indicators, + legend, +}) => { + const showOhlc = currentBar && (v.open || v.high || v.low || v.close || v.change); + const showAny = + v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge) + || showOhlc || (v.indicators && indicators.length > 0); + + if (!showAny) return null; + + return ( +
+ {v.symbol && ( + + + {symbol} + + )} + {v.timeframe && · {timeframe}} + {useUpbit && v.liveStatus && } + {mode === 'trading' && v.tradingBadge && } + {showOhlc && ( + + {v.open && ( + O {formatPrice(currentBar!.open)} + )} + {v.high && ( + H {formatPrice(currentBar!.high)} + )} + {v.low && ( + L {formatPrice(currentBar!.low)} + )} + {v.close && ( + C {formatPrice(currentBar!.close)} + )} + {v.change && ( + + {isUp ? '▲' : '▼'} {formatPrice(Math.abs(dailyChange))} ({Math.abs(dailyChangePct).toFixed(2)}%) + + )} + + )} + {v.indicators && indicators.map(ind => { + const def = getIndicatorDef(ind.type); + const vals = legend?.indicatorValues?.[ind.type]; + const plots = ind.plots ?? def?.plots ?? []; + const label = ind.type === 'BollingerBands' + ? formatBbLegendLabel(ind.params ?? {}) + : getIndicatorChartTitle(ind.type); + const fmtVal = (n: number) => + Math.abs(n) >= 1000 + ? n.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) + : n.toFixed(2); + return def ? ( + + {label} + {v.indicatorValues && vals && vals.length > 0 && ( + + {vals.map((n, i) => + Number.isFinite(n) ? ( + + {fmtVal(n)} + + ) : null, + )} + + )} + + ) : null; + })} +
+ ); +}; + +export default ChartLegendBar; diff --git a/frontend/src/components/ChartMagnifier.tsx b/frontend/src/components/ChartMagnifier.tsx new file mode 100644 index 0000000..80c2230 --- /dev/null +++ b/frontend/src/components/ChartMagnifier.tsx @@ -0,0 +1,358 @@ +/** + * ChartMagnifier + * + * 차트 위에 떠 있는 돋보기(루페) 오버레이. + * - 헤더 드래그로 이동 + * - 8방향 핸들로 리사이즈 + * - 내부 canvas 에 차트 픽셀을 확대 복사 (rAF 루프) + * - X/Y 동시 줌 배율 조절 + * - Y축 전용 줌(Y-Zoom): 수직 방향만 추가 확대 → 미세한 Y값 변화도 파악 가능 + * - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸 + */ +import React, { useRef, useEffect, useCallback, useState } from 'react'; + +interface ChartMagnifierProps { + /** LWC canvas 들이 들어있는 chart-container div */ + containerRef: React.RefObject; + /** position:relative 부모 (tv-chart-wrap) — 위치 기준점 */ + wrapperRef: React.RefObject; + enabled: boolean; + onClose: () => void; +} + +const HEADER_H = 30; +const MIN_W = 180; +const MIN_H = 120 + HEADER_H; +const INIT_W = 320; +const INIT_H = 240 + HEADER_H; + +const XY_STEPS: number[] = [1.5, 2, 2.5, 3, 4, 5, 6, 8]; +const Y_STEPS: number[] = [1, 1.5, 2, 3, 5, 8, 12]; + +type ResizeDir = 'nw'|'n'|'ne'|'e'|'se'|'s'|'sw'|'w'; + +const HANDLES: { id: ResizeDir; cursor: string; style: React.CSSProperties }[] = [ + { id:'nw', cursor:'nw-resize', style:{ top:-5, left:-5 } }, + { id:'n', cursor:'n-resize', style:{ top:-5, left:'50%', transform:'translateX(-50%)' } }, + { id:'ne', cursor:'ne-resize', style:{ top:-5, right:-5 } }, + { id:'e', cursor:'e-resize', style:{ top:'50%', right:-5, transform:'translateY(-50%)' } }, + { id:'se', cursor:'se-resize', style:{ bottom:-5, right:-5 } }, + { id:'s', cursor:'s-resize', style:{ bottom:-5, left:'50%',transform:'translateX(-50%)' } }, + { id:'sw', cursor:'sw-resize', style:{ bottom:-5, left:-5 } }, + { id:'w', cursor:'w-resize', style:{ top:'50%', left:-5, transform:'translateY(-50%)' } }, +]; + +const ChartMagnifier: React.FC = ({ + containerRef, wrapperRef, enabled, onClose, +}) => { + const canvasRef = useRef(null); + const tempCvRef = useRef(null); // Y-Zoom 용 임시 캔버스 + const rafRef = useRef(0); + + // ── position / size / xy-zoom / y-zoom ──────────────────────────────────── + // position: fixed 기준 — 뷰포트 좌표 (wrapper 밖으로도 이동 가능) + const initPos = useCallback(() => { + const wr = wrapperRef.current?.getBoundingClientRect(); + return wr ? { x: wr.left + 40, y: wr.top + 40 } : { x: 80, y: 80 }; + }, [wrapperRef]); + + const [pos, setPos] = useState(() => ({ x: 80, y: 80 })); + const [size, setSize] = useState({ w: INIT_W, h: INIT_H }); + const [xyZoom, setXyZoom] = useState(2.5); + const [yZoom, setYZoom] = useState(1); + /** LIVE 지시등: true = 직전 프레임 대비 픽셀 변화 있음 */ + const [isLive, setIsLive] = useState(false); + + const posRef = useRef({ x: 80, y: 80 }); + const sizeRef = useRef({ w: INIT_W, h: INIT_H }); + const xyZoomRef = useRef(2.5); + const yZoomRef = useRef(1); + + // 픽셀 변화 감지용: 이전 프레임의 중앙 수직 라인 픽셀 데이터 + const prevRowRef = useRef(null); + const liveTimerRef = useRef | null>(null); + + // ── rAF: 차트 캔버스 픽셀을 확대하여 magnifier canvas 에 복사 ────────────── + const renderFrame = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + const wrapper = wrapperRef.current; + if (!canvas || !container || !wrapper) return; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return; + + const { x, y } = posRef.current; + const { w, h } = sizeRef.current; + const zm = xyZoomRef.current; + const yz = yZoomRef.current; + const canvasH = h - HEADER_H; + const dpr = window.devicePixelRatio || 1; + + // canvas 해상도 유지 + const tW = Math.round(w * dpr); + const tH = Math.round(canvasH * dpr); + if (canvas.width !== tW || canvas.height !== tH) { + canvas.width = tW; + canvas.height = tH; + canvas.style.width = w + 'px'; + canvas.style.height = canvasH + 'px'; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // 샘플링 중심 — position:fixed 이므로 pos가 이미 뷰포트 절대 좌표 + const centerX = x + w / 2; + const centerY = y + HEADER_H + canvasH / 2; + + // 원본 영역 (xy-zoom 적용) + const srcW = w / zm; + const srcH = canvasH / zm; + const srcL = centerX - srcW / 2; + const srcT = centerY - srcH / 2; + + // position:fixed → pos 자체가 뷰포트 좌표이므로 wrapper 오프셋 불필요 + const ssrcL = srcL; + const ssrcT = srcT; + + // LWC 캔버스 레이어 순서대로 복사 + const layers = Array.from(container.querySelectorAll('canvas')); + for (const lc of layers) { + const cRect = lc.getBoundingClientRect(); + const csX = (ssrcL - cRect.left) * dpr; + const csY = (ssrcT - cRect.top ) * dpr; + const csW = srcW * dpr; + const csH = srcH * dpr; + const x0 = Math.max(0, csX); + const y0 = Math.max(0, csY); + const x1 = Math.min(lc.width, csX + csW); + const y1 = Math.min(lc.height, csY + csH); + if (x1 <= x0 || y1 <= y0) continue; + const dstX = ((x0 - csX) / csW) * canvas.width; + const dstY = ((y0 - csY) / csH) * canvas.height; + const dstW = ((x1 - x0) / csW) * canvas.width; + const dstH = ((y1 - y0) / csH) * canvas.height; + try { ctx.drawImage(lc, x0, y0, x1-x0, y1-y0, dstX, dstY, dstW, dstH); } + catch { /* cross-origin 무시 */ } + } + + // ── Y-Zoom: 수직 방향 추가 확대 ─────────────────────────────────────────── + // Y축의 미세한 움직임을 파악할 수 있도록 중앙 기준으로 수직만 재확대 + if (yz > 1) { + // 임시 캔버스 초기화 (한 번만 생성) + let tmp = tempCvRef.current; + if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) { + tmp = document.createElement('canvas'); + tmp.width = canvas.width; + tmp.height = canvas.height; + tempCvRef.current = tmp; + } + const tCtx = tmp.getContext('2d')!; + tCtx.clearRect(0, 0, tmp.width, tmp.height); + tCtx.drawImage(canvas, 0, 0); + + // 중앙 ±(1/yz) 영역만 잘라 전체 높이로 늘림 + const srcYH = Math.round(canvas.height / yz); + const srcYT = Math.round((canvas.height - srcYH) / 2); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(tmp, 0, srcYT, canvas.width, srcYH, 0, 0, canvas.width, canvas.height); + + // Y-Zoom 배율 워터마크 + ctx.fillStyle = 'rgba(255,200,50,0.6)'; + ctx.font = `bold ${Math.round(10 * dpr)}px monospace`; + ctx.fillText(`Y×${yz}`, 4 * dpr, (canvasH - 4) * dpr); + } + + // ── 테두리 ───────────────────────────────────────────────────────────────── + ctx.strokeStyle = 'rgba(255, 200, 50, 0.35)'; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1); + + // ── LIVE 감지: 중앙 세로 라인 픽셀 샘플링으로 변화 감지 ───────────────── + try { + const midX = Math.floor(canvas.width / 2); + const sample = ctx.getImageData(midX, 0, 1, canvas.height).data; + const prev = prevRowRef.current; + if (prev && prev.length === sample.length) { + let diff = 0; + for (let i = 0; i < sample.length; i += 4) { + diff += Math.abs(sample[i] - prev[i]); + diff += Math.abs(sample[i+1] - prev[i+1]); + diff += Math.abs(sample[i+2] - prev[i+2]); + } + if (diff > 30) { // 임계값: 미세 잡음 무시 + setIsLive(true); + if (liveTimerRef.current) clearTimeout(liveTimerRef.current); + liveTimerRef.current = setTimeout(() => setIsLive(false), 600); + } + } + prevRowRef.current = new Uint8ClampedArray(sample); + } catch { /* getImageData 실패 무시 */ } + }, [containerRef, wrapperRef]); + + // enabled 시 wrapper 위치 기준으로 초기 위치 설정 + useEffect(() => { + if (!enabled) return; + const p = initPos(); + posRef.current = p; + setPos(p); + }, [enabled, initPos]); + + // rAF 루프 + useEffect(() => { + if (!enabled) { cancelAnimationFrame(rafRef.current); return; } + const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); }; + rafRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(rafRef.current); + }, [enabled, renderFrame]); + + // ── 드래그 (헤더) ───────────────────────────────────────────────────────── + const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null); + + const onHeaderDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + dragStart.current = { mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, []); + + const onHeaderMove = useCallback((e: React.PointerEvent) => { + if (!dragStart.current) return; + const dx = e.clientX - dragStart.current.mx; + const dy = e.clientY - dragStart.current.my; + const vw = window.innerWidth; + const vh = window.innerHeight; + const { w, h } = sizeRef.current; + // 헤더가 항상 화면 안에 있도록 최소 마진만 유지 (위아래 자유 이동) + const np = { + x: Math.max(-(w - 60), Math.min(vw - 60, dragStart.current.px + dx)), + y: Math.max(-(h - HEADER_H), Math.min(vh - HEADER_H, dragStart.current.py + dy)), + }; + posRef.current = np; + setPos(np); + }, []); + + const onHeaderUp = useCallback(() => { dragStart.current = null; }, []); + + // ── 리사이즈 핸들 ────────────────────────────────────────────────────────── + const resizeStart = useRef<{ + dir: ResizeDir; mx: number; my: number; + px: number; py: number; pw: number; ph: number; + } | null>(null); + + const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => { + e.preventDefault(); e.stopPropagation(); + resizeStart.current = { + dir, mx: e.clientX, my: e.clientY, + px: posRef.current.x, py: posRef.current.y, + pw: sizeRef.current.w, ph: sizeRef.current.h, + }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, []); + + const onHandleMove = useCallback((e: React.PointerEvent) => { + const rs = resizeStart.current; if (!rs) return; + const { dir, mx, my, px, py, pw, ph } = rs; + const dx = e.clientX - mx; const dy = e.clientY - my; + let nx = px, ny = py, nw = pw, nh = ph; + if (dir.includes('e')) { nw = Math.max(MIN_W, pw + dx); } + if (dir.includes('w')) { nw = Math.max(MIN_W, pw - dx); nx = px + pw - nw; } + if (dir.includes('s')) { nh = Math.max(MIN_H, ph + dy); } + if (dir.includes('n')) { nh = Math.max(MIN_H, ph - dy); ny = py + ph - nh; } + // 리사이즈 시 뷰포트 경계 안에서만 클램프 (헤더 마진 확보) + nx = Math.max(-(nw - 60), Math.min(window.innerWidth - 60, nx)); + ny = Math.max(-(nh - HEADER_H), Math.min(window.innerHeight - HEADER_H, ny)); + posRef.current = { x: nx, y: ny }; + sizeRef.current = { w: nw, h: nh }; + setPos ({ x: nx, y: ny }); + setSize({ w: nw, h: nh }); + }, [wrapperRef]); + + const onHandleUp = useCallback(() => { resizeStart.current = null; }, []); + + // ── XY-Zoom 조절 ────────────────────────────────────────────────────────── + const adjustXyZoom = useCallback((delta: number) => { + const cur = xyZoomRef.current; + const idx = XY_STEPS.findIndex(s => s >= cur - 0.01); + const next = XY_STEPS[Math.max(0, Math.min(XY_STEPS.length - 1, idx + delta))]; + xyZoomRef.current = next; setXyZoom(next); + }, []); + + // ── Y-Zoom 조절 ─────────────────────────────────────────────────────────── + const adjustYZoom = useCallback((delta: number) => { + const cur = yZoomRef.current; + const idx = Y_STEPS.findIndex(s => s >= cur - 0.01); + const next = Y_STEPS[Math.max(0, Math.min(Y_STEPS.length - 1, idx + delta))]; + yZoomRef.current = next; setYZoom(next); + }, []); + + if (!enabled) return null; + + return ( +
+ {/* ── 헤더 ──────────────────────────────────────────────── */} +
+ {/* 아이콘 + 제목 */} + + + + + + + 확대경 + + {/* LIVE 지시등 */} + LIVE + + {/* XY Zoom 컨트롤 */} +
+ + {xyZoom}× + +
+ + {/* Y-Zoom 컨트롤 */} +
+ + + Y{yZoom}× + + +
+ + {/* 닫기 */} + +
+ + {/* ── 확대 canvas ───────────────────────────────────────── */} + + + {/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */} + {HANDLES.map(h => ( +
onHandleDown(e, h.id)} + onPointerMove={onHandleMove} + onPointerUp={onHandleUp} + /> + ))} +
+ ); +}; + +export default ChartMagnifier; diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx new file mode 100644 index 0000000..b34fb93 --- /dev/null +++ b/frontend/src/components/ChartSlot.tsx @@ -0,0 +1,783 @@ +/** + * ChartSlot + * 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다. + */ +import React, { + useState, useEffect, useCallback, useRef, useMemo, + useImperativeHandle, forwardRef, +} from 'react'; +import ReactDOM from 'react-dom'; +import TradingChart from './TradingChart'; +import IndicatorContextToolbar from './IndicatorContextToolbar'; +import IndicatorSettingsModal from './IndicatorSettingsModal'; +import MainChartSettingsModal from './MainChartSettingsModal'; +import { MarketSearchPanel } from './MarketSearchPanel'; +import { generateBars, formatPrice } from '../utils/dataGenerator'; +import { getKoreanName } from '../utils/marketNameCache'; +import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage'; +import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry'; +import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig'; +import { normalizeIchimokuConfig } from '../utils/ichimokuConfig'; +import { isUpbitMarket } from '../utils/upbitApi'; +import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData'; +import { invalidateMarketCache } from '../utils/requestCache'; +import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader'; +import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; +import { + duplicateIndicatorInList, + mergeIndicators, + reorderIndicatorGroup, + splitIndicatorPane, +} from '../utils/indicatorPaneMerge'; +import { saveSlot } from '../utils/backendApi'; +import type { + OHLCVBar, ChartType, Theme, Timeframe, + IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, +} from '../types'; +import type { ChartManager } from '../utils/ChartManager'; + +// ── 타임프레임 옵션 ──────────────────────────────────────────────────────── +const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M']; + +// ── Ws 배지 ─────────────────────────────────────────────────────────────── +const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => { + if (!isUpbit) return null; + const map: Record = { + connecting: { dot: 'dot-yellow', label: '연결 중...' }, + connected: { dot: 'dot-lime', label: '실시간' }, + disconnected: { dot: 'dot-red', label: '재연결' }, + error: { dot: 'dot-red', label: '오류' }, + }; + const { dot, label } = map[status]; + return ( + + {label} + + ); +}; + +let _slotIndCounter = 0; +function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; } + +// ── Ref 핸들 (외부에서 슬롯 조작용) ───────────────────────────────────────── +export interface ChartSlotHandle { + /** 지표 추가 */ + addIndicator: (type: string, fromConfig?: IndicatorConfig) => void; + /** 지표 제거 (type 기준) */ + removeIndicatorByType: (type: string) => void; + /** 보조지표 pane 복사 */ + duplicateIndicator: (sourceId: string) => void; + /** 현재 활성 심볼 */ + getSymbol: () => string; + /** 현재 활성 타임프레임 */ + getTimeframe: () => Timeframe; + /** 현재 활성 지표 목록 */ + getIndicators: () => IndicatorConfig[]; + /** 심볼 변경 */ + setSymbol: (s: string) => void; + /** 타임프레임 변경 */ + setTimeframe: (tf: Timeframe) => void; + /** 지표 목록 교체 (모드 전환 시 이전 설정 복원용) */ + setIndicators: (inds: IndicatorConfig[]) => void; + /** 크로스헤어 자석모드 설정 */ + setCrosshairMagnet: (enabled: boolean) => void; + /** 가격축 라벨·설명 on/off */ + setSeriesPriceLabelsEnabled: (enabled: boolean) => void; + /** 거래량 pane on/off */ + setVolumeVisible: (visible: boolean) => void; +} + +// ── Props ───────────────────────────────────────────────────────────────── +export interface ChartSlotProps { + slotIndex: number; + theme: Theme; + /** 슬롯이 활성(포커스) 상태인지 */ + active: boolean; + onActivate: () => void; + /** 외부에서 강제 심볼 동기화 */ + forcedSymbol?: string; + /** 외부에서 강제 타임프레임 동기화 */ + forcedTf?: Timeframe; + /** 마운트 시 1회만 적용되는 초기 심볼 (단일↔다중 모드 전환 시 설정 이전용) */ + initialSymbol?: string; + /** 마운트 시 1회만 적용되는 초기 타임프레임 */ + initialTimeframe?: Timeframe; + /** 마운트 시 1회만 적용되는 초기 지표 목록 */ + initialIndicators?: IndicatorConfig[]; + /** Crosshair sync: 동기화 시각 (unix seconds) */ + syncTime?: number; + onCrosshairTime?: (t: number) => void; + /** Time sync: 동기화 가시 시간 범위 */ + syncVisibleRange?: { from: number; to: number } | null; + onVisibleRangeChange?: (r: { from: number; to: number }) => void; + /** Date range sync: 동기화 가시 논리 범위 */ + syncLogicalRange?: { from: number; to: number } | null; + onLogicalRangeChange?: (r: { from: number; to: number }) => void; + /** 돋보기 활성 여부 (App 에서 전달) */ + magnifierEnabled?: boolean; + /** 돋보기 닫기 콜백 */ + onMagnifierClose?: () => void; + /** 멀티차트 전체 보기: 이 슬롯을 단일 차트로 확장 */ + onFullView?: (symbol: string) => void; + /** + * 슬롯 내 지표 목록이 바뀔 때마다 호출. + * App 에서 활성 슬롯의 지표 목록을 IndicatorModal 에 반영하기 위해 사용. + */ + onIndicatorsChange?: (indicators: IndicatorConfig[]) => void; + /** + * 슬롯 내 심볼이 바뀔 때마다 호출. + * App 에서 activeSlotSymbol(호가창 심볼)을 동기화하기 위해 사용. + */ + onSymbolChange?: (symbol: string) => void; + /** 자석모드 상태 (크로스헤어 스냅) */ + magnetMode?: 'off' | 'weak' | 'strong'; + /** 차트 우클릭 → 매수·매도 패널 자동 입력 */ + onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex: number) => void; + /** 보조지표 우측 가격축 라벨·설명 표시 */ + chartSeriesPriceLabels?: boolean; + /** 차트 하단 거래량 바 표시 */ + chartVolumeVisible?: boolean; + chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT'; + displayTimezone?: string; +} + +const ChartSlot = forwardRef(function ChartSlot({ + slotIndex, theme, active, onActivate, + forcedSymbol, forcedTf, + initialSymbol, initialTimeframe, initialIndicators, + syncTime, onCrosshairTime, + syncVisibleRange, onVisibleRangeChange, + syncLogicalRange, onLogicalRangeChange, + onFullView, + magnifierEnabled = false, + onMagnifierClose, + onIndicatorsChange, + onSymbolChange, + magnetMode = 'off', + onTradeOrderRequest, + chartSeriesPriceLabels = true, + chartVolumeVisible = true, + chartRealtimeSource = 'BACKEND_STOMP', + displayTimezone, +}, ref) { + // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── + const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); + + // ── 자체 상태 ───────────────────────────────────────────────────────── + // initial* 값이 있으면 우선 적용 (단일↔다중 모드 전환 시 설정 이전용) + const [symbol, setSymbol] = useState(initialSymbol ?? forcedSymbol ?? 'KRW-BTC'); + const [timeframe, setTimeframe] = useState(initialTimeframe ?? forcedTf ?? '1D'); + const [chartType] = useState('candlestick'); + const [indicators, setIndicators] = useState(initialIndicators ?? []); + const [drawings] = useState([]); + const [legend, setLegend] = useState(null); + const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number } | null>(null); + const [settingsModalId, setSettingsModalId] = useState(null); + const [showMainChartModal, setShowMainChartModal] = useState(false); + const [mainChartStyle, setMainChartStyle] = useState(DEFAULT_MAIN_CHART_STYLE); + const [showMarket, setShowMarket] = useState(false); + // 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용 + const [slotAnchorRect, setSlotAnchorRect] = useState(null); + + const managerRef = useRef(null); + // initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장 + const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC'); + const timeframeRef = useRef(forcedTf ?? '1D'); + + // symbol / timeframe 변경 시 ref 동기화 (외부 접근용) + useEffect(() => { symbolRef.current = symbol; }, [symbol]); + useEffect(() => { timeframeRef.current = timeframe; }, [timeframe]); + + // 심볼 변경 시 부모(App)에 알림 → 호가창 activeSlotSymbol 동기화 + const onSymbolChangeRef = useRef(onSymbolChange); + onSymbolChangeRef.current = onSymbolChange; + useEffect(() => { + onSymbolChangeRef.current?.(symbol); + }, [symbol]); + + // 지표 목록 변경 시 부모(App)에 알림 → IndicatorModal 의 activeIndicators 갱신 + const onIndicatorsChangeRef = useRef(onIndicatorsChange); + onIndicatorsChangeRef.current = onIndicatorsChange; + useEffect(() => { + onIndicatorsChangeRef.current?.(indicators); + }, [indicators]); + + // indicators 를 ref 로도 유지 (useImperativeHandle 의 최신 값 접근용) + const indicatorsRef = useRef(indicators); + useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); + + // ── 외부 핸들 노출 ───────────────────────────────────────────────────── + useImperativeHandle(ref, () => ({ + addIndicator: (type: string, fromConfig?: IndicatorConfig) => { + setIndicators(prev => { + if (prev.some(i => i.type === type)) return prev; + const def = getIndicatorDef(type); + if (!def) return prev; + let cfg: IndicatorConfig; + if (fromConfig) { + cfg = { ...fromConfig, id: newIndId(), type: def.type }; + } else { + const params = getParams(type, def.defaultParams); + const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines); + cfg = { + id: newIndId(), + type: def.type, + params, + plots, + hlines, + ...(cloudColors ? { cloudColors } : {}), + }; + } + if (type === 'SMA') { + cfg = normalizeSmaConfig({ + ...cfg, + plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(), + }); + } else if (type === 'IchimokuCloud') { + cfg = normalizeIchimokuConfig(cfg); + } + return [...prev, cfg]; + }); + }, + removeIndicatorByType: (type: string) => { + setIndicators(prev => prev.filter(i => i.type !== type)); + }, + duplicateIndicator: (sourceId: string) => { + setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); + }, + getSymbol: () => symbolRef.current, + getTimeframe: () => timeframeRef.current, + getIndicators: () => indicatorsRef.current, + setSymbol: (s: string) => { + // 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함 + if (isUpbitMarket(s)) invalidateMarketCache(s); + setSymbol(s); + setLegend(null); + }, + setTimeframe: (tf: Timeframe) => { setTimeframe(tf); }, + setIndicators: (inds: IndicatorConfig[]) => { setIndicators(inds); }, + setCrosshairMagnet: (enabled: boolean) => { + managerRef.current?.setCrosshairMagnet(enabled); + }, + setSeriesPriceLabelsEnabled: (enabled: boolean) => { + managerRef.current?.setSeriesPriceLabelsEnabled(enabled); + }, + setVolumeVisible: (visible: boolean) => { + managerRef.current?.setVolumeVisible(visible); + }, + }), []); + + // 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영 + useEffect(() => { + managerRef.current?.setCrosshairMagnet(magnetMode !== 'off'); + }, [magnetMode]); + + useEffect(() => { + managerRef.current?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); + }, [chartSeriesPriceLabels]); + + useEffect(() => { + managerRef.current?.setVolumeVisible(chartVolumeVisible); + }, [chartVolumeVisible]); + + // 외부 심볼 동기화 + useEffect(() => { + if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [forcedSymbol]); + + // 외부 타임프레임 동기화 + useEffect(() => { + if (forcedTf && forcedTf !== timeframe) setTimeframe(forcedTf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [forcedTf]); + + // mainChartStyle → ChartManager + useEffect(() => { + managerRef.current?.updateMainSeriesStyle(mainChartStyle); + }, [mainChartStyle]); + + // ── 슬롯 상태 변경 시 DB 자동 저장 (디바운스 2s) ───────────────────────── + // 멀티슬롯 모드에서 각 슬롯 독립 저장 보장 (App.tsx workspaceState는 슬롯 0만 포함) + const saveTimerRef = useRef | null>(null); + useEffect(() => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + saveSlot(slotIndex, { + slotIndex, + symbol, + timeframe, + chartType: 'candlestick', + theme, + mode: 'normal', + logScale: false, + drawingsLocked: false, + drawingsVisible: true, + indicators, + drawings: [], + paneLayout: null, + mainChartStyle, + }).catch(err => console.error('[ChartSlot] saveSlot failed', err)); + }, 2000); + return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slotIndex, symbol, timeframe, theme, indicators, mainChartStyle]); + + // ── 동기화 콜백 refs (항상 최신 값을 참조하도록) ───────────────────────── + const onVisibleRangeChangeRef = useRef(onVisibleRangeChange); + const onLogicalRangeChangeRef = useRef(onLogicalRangeChange); + onVisibleRangeChangeRef.current = onVisibleRangeChange; + onLogicalRangeChangeRef.current = onLogicalRangeChange; + + // 외부에서 sync 값을 적용하는 중임을 표시 (feedback loop 방지) + const isSyncingTimeRef = useRef(false); + const isSyncingLogicalRef = useRef(false); + + // 데이터 로드 전 수신한 sync range 를 저장해두는 pending ref + // → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결) + const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null); + const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null); + + // ── Crosshair sync: syncTime → setCrosshairAtTime ──────────────────── + useEffect(() => { + if (!syncTime) { managerRef.current?.clearCrosshairPosition(); return; } + managerRef.current?.setCrosshairAtTime(syncTime); + }, [syncTime]); + + // ── Time sync: syncVisibleRange 외부 적용 ──────────────────────────── + useEffect(() => { + if (!syncVisibleRange) return; + const mgr = managerRef.current; + if (!mgr || mgr.getRawBarsLength() === 0) { + // 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용 + pendingSyncVisibleRef.current = syncVisibleRange; + return; + } + isSyncingTimeRef.current = true; + mgr.applyVisibleTimeRange(syncVisibleRange.from, syncVisibleRange.to); + requestAnimationFrame(() => { isSyncingTimeRef.current = false; }); + }, [syncVisibleRange]); + + // ── Date range sync: syncLogicalRange 외부 적용 ────────────────────── + useEffect(() => { + if (!syncLogicalRange) return; + const mgr = managerRef.current; + if (!mgr || mgr.getRawBarsLength() === 0) { + pendingSyncLogicalRef.current = syncLogicalRange; + return; + } + isSyncingLogicalRef.current = true; + mgr.applyVisibleLogicalRange(syncLogicalRange.from, syncLogicalRange.to); + requestAnimationFrame(() => { isSyncingLogicalRef.current = false; }); + }, [syncLogicalRange]); + + // 멀티 레이아웃 새로고침 시 차트 빈 화면 방지: + // CSS Grid 높이 확정 지연에 대비해 여러 시점에 pane 높이를 재적용한다. + // TradingChart 자체도 applyPaneLayout 자동 재시도를 하지만, 슬롯 레벨에서도 보강. + // ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등 + // 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다. + const slotContainerRef = useRef(null); + // 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard) + const [reloadTick, setReloadTick] = useState(0); + const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회 + useEffect(() => { + const checks = [200, 500, 1000, 2000].map(delay => + setTimeout(() => { + const el = slotContainerRef.current; + const mgr = managerRef.current; + if (!el || !mgr) return; + const h = el.clientHeight; + if (h <= 0) return; + if (mgr.hasMainSeries()) { + // 차트 데이터 있음 → pane 높이만 재적용 + mgr.resetPaneHeights(h); + } else if (!reloadTriggeredRef.current) { + // 차트 비어 있고 아직 재시도 안 했음 → 1회만 재마운트 + // (bars 로딩 지연일 수 있으므로, 늦은 타이머에서만 트리거) + if (delay >= 1000) { + reloadTriggeredRef.current = true; + setReloadTick(t => t + 1); + } + } + }, delay) + ); + return () => checks.forEach(clearTimeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── 데이터 ──────────────────────────────────────────────────────────── + const useUpbit = isUpbitMarket(symbol); + + // 과거 데이터 추가 로드 (차트를 왼쪽으로 스크롤할 때) + const { isLoadingMore, loadMoreRef } = useHistoryLoader({ + symbol, timeframe, isUpbit: useUpbit, managerRef, + }); + + const [simBars, setSimBars] = useState(() => + !isUpbitMarket(symbol) ? generateBars(symbol, timeframe) : [] + ); + useEffect(() => { + if (useUpbit) return; + setSimBars(generateBars(symbol, timeframe)); + setLegend(null); + }, [symbol, timeframe, useUpbit]); + + const pendingRealtimeBarRef = useRef(null); + + const handleTickUpdate = useCallback((bar: OHLCVBar) => { + if (!managerRef.current) { + pendingRealtimeBarRef.current = bar; + return; + } + managerRef.current.updateBar(bar); + }, []); + const handleNewCandle = useCallback((bar: OHLCVBar) => { + if (!managerRef.current) { + pendingRealtimeBarRef.current = bar; + return; + } + managerRef.current.appendBar(bar); + }, []); + + const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData( + symbol, timeframe, + useMemo(() => ({ + onTickUpdate: handleTickUpdate, + onNewCandle: handleNewCandle, + enabled: useUpbit, + source: chartRealtimeSource, + }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), + ); + + const bars = useUpbit ? upbitBars : simBars; + + const lastKnownBar = useUpbit + ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) + : (bars.length > 0 ? bars[bars.length - 1] : null); + const currentBar = legend ?? lastKnownBar; + const isUp = currentBar ? currentBar.close >= currentBar.open : true; + const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0; + const dailyChange = currentBar ? currentBar.close - prevClose : 0; + const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; + + // ── 인디케이터 핸들러 ───────────────────────────────────────────────── + const handleIndicatorSave = useCallback((updated: IndicatorConfig) => { + setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i)); + setSettingsModalId(null); + setCtxToolbar(null); + // 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영 + saveParams(updated.type, updated.params); + // 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용 + saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors); + }, [saveParams, saveVisual]); + + const handleToggleHidden = useCallback((id: string) => { + setIndicators(prev => prev.map(i => i.id === id ? { ...i, hidden: !i.hidden } : i)); + }, []); + + const handleRemoveById = useCallback((id: string) => { + setIndicators(prev => prev.filter(i => i.id !== id)); + setCtxToolbar(null); + setSettingsModalId(null); + }, []); + + const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => { + setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev)); + }, []); + + const handleMergeIndicators = useCallback((fromId: string, intoId: string) => { + setIndicators(prev => mergeIndicators(fromId, intoId, prev)); + }, []); + + const handleSplitIndicatorPane = useCallback((hostId: string) => { + setIndicators(prev => splitIndicatorPane(hostId, prev)); + }, []); + + const [focusedIndicatorId, setFocusedIndicatorId] = useState(null); + + const handleExpandIndicator = useCallback((id: string) => { + setFocusedIndicatorId(id); + }, []); + + const handleRestoreIndicators = useCallback(() => { + setFocusedIndicatorId(null); + }, []); + + const handleDuplicateIndicator = useCallback((sourceId: string) => { + setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev); + }, []); + + const handleRemoveByIdSlot = useCallback((id: string) => { + setIndicators(prev => prev.filter(i => i.id !== id)); + setFocusedIndicatorId(prev => prev === id ? null : prev); + setCtxToolbar(null); + setSettingsModalId(null); + }, []); + + /** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) + 타임프레임 필터 */ + const effectiveIndicators = useMemo(() => { + // 타임프레임 필터: timeframeVisibility[currentTf] === false 이면 제외 + const tfFiltered = indicators.filter(ind => + ind.timeframeVisibility?.[timeframe] !== false + ); + if (!focusedIndicatorId) return tfFiltered; + return tfFiltered.filter(ind => { + const def = getIndicatorDef(ind.type); + return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId; + }); + }, [focusedIndicatorId, indicators, timeframe]); + + // ── 심볼 선택 ───────────────────────────────────────────────────────── + const handleSymbolSelect = useCallback((s: string) => { + if (isUpbitMarket(s)) invalidateMarketCache(s); + setSymbol(s); + setLegend(null); + setShowMarket(false); + }, []); + + return ( +
+ {/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */} +
e.stopPropagation()}> + {/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */} + + + {/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */} + {showMarket && ReactDOM.createPortal( + { handleSymbolSelect(s); }} + onClose={() => setShowMarket(false)} + anchorRect={slotAnchorRect} + />, + document.body, + )} + + {/* 타임프레임 선택 */} +
+ {TF_OPTIONS.map(tf => ( + + ))} +
+ + {/* 현재가 */} + {currentBar && ( + + {formatPrice(currentBar.close)} + + {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% + + + )} + + {useUpbit && } + {isLoading && 로딩...} +
+ + {/* ── TradingChart ─────────────────────────────────────────────────── */} +
+ {useUpbit && isLoading && ( +
+
+
+ )} + {isLoadingMore && ( +
+
+ 과거 데이터 로딩... +
+ )} + + { + setLegend(data); + if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0); + }} + onManagerReady={mgr => { + managerRef.current = mgr; + const pending = pendingRealtimeBarRef.current; + if (pending) { + pendingRealtimeBarRef.current = null; + const last = bars.length > 0 ? bars[bars.length - 1] : null; + if (last && pending.time === last.time) mgr.updateBar(pending); + else if (!last || pending.time > last.time) void mgr.appendBar(pending); + else mgr.updateBar({ ...pending, time: last.time }); + } + mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels); + mgr.setVolumeVisible(chartVolumeVisible); + // Time sync: 가시 시간 범위 변화 구독 + mgr.subscribeVisibleTimeRange(r => { + if (!r || isSyncingTimeRef.current) return; + onVisibleRangeChangeRef.current?.(r); + }); + // Date range sync: 가시 논리 범위 변화 구독 + // + 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 + mgr.subscribeVisibleLogicalRange(r => { + if (!r) return; + if (!isSyncingLogicalRef.current) { + onLogicalRangeChangeRef.current?.(r); + } + // 가시 범위 왼쪽 끝이 LOAD_MORE_TRIGGER 이내이면 추가 로드 + if (r.from < LOAD_MORE_TRIGGER) { + loadMoreRef.current(); + } + }); + }} + onDataLoaded={() => { + // reloadAll(setData + setInitialVisibleRange) 완료 후 + // 첫 마운트 시 pending 상태였던 sync range 를 재적용한다 + const mgr = managerRef.current; + if (!mgr) return; + if (pendingSyncVisibleRef.current) { + isSyncingTimeRef.current = true; + mgr.applyVisibleTimeRange( + pendingSyncVisibleRef.current.from, + pendingSyncVisibleRef.current.to, + ); + requestAnimationFrame(() => { isSyncingTimeRef.current = false; }); + pendingSyncVisibleRef.current = null; + } + if (pendingSyncLogicalRef.current) { + isSyncingLogicalRef.current = true; + mgr.applyVisibleLogicalRange( + pendingSyncLogicalRef.current.from, + pendingSyncLogicalRef.current.to, + ); + requestAnimationFrame(() => { isSyncingLogicalRef.current = false; }); + pendingSyncLogicalRef.current = null; + } + }} + magnifierEnabled={magnifierEnabled} + onMagnifierClose={onMagnifierClose} + onAddDrawing={() => {}} + onSeriesClick={(entryId, point) => { + if (!entryId) { setCtxToolbar(null); return; } + setCtxToolbar({ entryId, screenX: point.x, screenY: point.y }); + }} + onSeriesDoubleClick={entryId => { + setCtxToolbar(null); + if (!entryId) return; + if (entryId === '__main__') setShowMainChartModal(true); + else setSettingsModalId(entryId); + }} + onHoverPaneLegend={() => {}} + onLeavePaneLegend={() => {}} + onTradeOrderRequest={req => onTradeOrderRequest?.(req, slotIndex)} + onReorderIndicators={handleReorderIndicators} + onMergeIndicators={handleMergeIndicators} + onSplitIndicatorPane={handleSplitIndicatorPane} + onExpandIndicator={handleExpandIndicator} + onRemoveIndicator={handleRemoveByIdSlot} + onDuplicateIndicator={handleDuplicateIndicator} + focusedIndicatorId={focusedIndicatorId} + onRestoreIndicators={handleRestoreIndicators} + onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined} + /> + + {/* 컨텍스트 툴바 */} + {ctxToolbar && (() => { + const cfg = ctxToolbar.entryId !== '__main__' + ? indicators.find(i => i.id === ctxToolbar.entryId) ?? null + : null; + return ( + {}} + onMouseLeaveToolbar={() => {}} + onSettings={() => { + const id = ctxToolbar.entryId; + setCtxToolbar(null); + if (id === '__main__') setShowMainChartModal(true); + else setSettingsModalId(id); + }} + onToggleHidden={() => handleToggleHidden(ctxToolbar.entryId)} + onRemove={() => handleRemoveById(ctxToolbar.entryId)} + onClose={() => setCtxToolbar(null)} + /> + ); + })()} + + {/* 메인 차트 설정 모달 */} + {showMainChartModal && ( + { setMainChartStyle(style); setShowMainChartModal(false); }} + onCancel={() => setShowMainChartModal(false)} + /> + )} + + {/* 지표 설정 모달 */} + {settingsModalId && settingsModalId !== '__main__' && (() => { + const cfg = indicators.find(i => i.id === settingsModalId); + if (!cfg) return null; + const def = getIndicatorDef(cfg.type); + const mergedCfg = enrichIndicatorConfig({ + ...cfg, + params: getParams(cfg.type, def?.defaultParams), + hlines: cfg.hlines ?? def?.hlines, + }); + return ( + setSettingsModalId(null)} + /> + ); + })()} +
+
+ ); +}); + +export default ChartSlot; diff --git a/frontend/src/components/ColorInput.tsx b/frontend/src/components/ColorInput.tsx new file mode 100644 index 0000000..8017372 --- /dev/null +++ b/frontend/src/components/ColorInput.tsx @@ -0,0 +1,123 @@ +/** + * 보조지표 설정 — 색상 + 투명도 (스와치 클릭 시 팔레트·고급 선택기) + */ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { parsePlotColor, formatPlotColor, plotColorCss } from '../utils/plotColorUtils'; +import { getOpacityPercentSpec } from '../utils/indicatorParamSpec'; +import NumericParamInput from './NumericParamInput'; +import ColorPickerPanel from './ColorPickerPanel'; + +export interface ColorInputProps { + value: string; + onChange: (v: string) => void; +} + +const ColorInput: React.FC = ({ value, onChange }) => { + const { hex6, alpha } = parsePlotColor(value); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const popoverRef = useRef(null); + const [pos, setPos] = useState({ top: 0, left: 0 }); + + const updatePosition = useCallback(() => { + const el = anchorRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const popW = 280; + let left = rect.left; + if (left + popW > window.innerWidth - 12) { + left = window.innerWidth - popW - 12; + } + setPos({ top: rect.bottom + 6, left: Math.max(8, left) }); + }, []); + + useEffect(() => { + if (!open) return; + updatePosition(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + const onScroll = () => updatePosition(); + window.addEventListener('keydown', onKey); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', onScroll, true); + }; + }, [open, updatePosition]); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + const t = e.target as Node; + if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return; + setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + const patchHex = (h: string) => onChange(formatPlotColor(h, alpha)); + const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a)); + + const popover = open && typeof document !== 'undefined' ? ( +
e.stopPropagation()} + > +
+ +
+ +
+ ) : null; + + return ( +
+ + patchHex(e.target.value)} + /> + + % + {popover ? createPortal(popover, document.body) : null} +
+ ); +}; + +export default ColorInput; diff --git a/frontend/src/components/ColorPickerPanel.tsx b/frontend/src/components/ColorPickerPanel.tsx new file mode 100644 index 0000000..5cb15f2 --- /dev/null +++ b/frontend/src/components/ColorPickerPanel.tsx @@ -0,0 +1,44 @@ +/** + * 색상 선택 패널 — 기존 스와치 팔레트 + 하단 고급 색상 팔레트 + */ +import React from 'react'; +import { COLOR_PALETTE } from '../utils/plotColorUtils'; +import AdvancedColorPicker from './AdvancedColorPicker'; + +export interface ColorPickerPanelProps { + hex6: string; + onHexChange: (hex6: string) => void; + /** false면 스와치 그리드만 숨김 (고급 팔레트만) */ + showQuickPalette?: boolean; + className?: string; +} + +const ColorPickerPanel: React.FC = ({ + hex6, + onHexChange, + showQuickPalette = true, + className = '', +}) => ( +
+ {showQuickPalette && ( +
+
+ {COLOR_PALETTE.map(c => ( +
+
+ )} +
+ +
+); + +export default ColorPickerPanel; diff --git a/frontend/src/components/DashboardPage.tsx b/frontend/src/components/DashboardPage.tsx new file mode 100644 index 0000000..f6aea2c --- /dev/null +++ b/frontend/src/components/DashboardPage.tsx @@ -0,0 +1,293 @@ +/** + * 운영 현황 대시보드 — 멀티스레드 파이프라인·JVM·트래픽·모의/실거래 + */ +import React, { useEffect, useState, useCallback } from 'react'; +import type { Theme } from '../types'; +import { + loadDashboardSummary, + type DashboardSummaryDto, + type ProcessMonitorDto, + type SystemMonitorDto, +} from '../utils/backendApi'; + +interface Props { + theme: Theme; + onGoChart?: (market: string) => void; +} + +const fmtKrw = (n: number) => + n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR'); + +const STATUS_LABEL: Record = { + healthy: '정상', + degraded: '주의', + down: '중단', + disabled: '비활성', +}; + +function statusClass(s: string): string { + if (s === 'healthy') return 'dash-proc--ok'; + if (s === 'degraded') return 'dash-proc--warn'; + if (s === 'down') return 'dash-proc--down'; + return 'dash-proc--off'; +} + +function formatMetricValue(v: unknown): string { + if (typeof v === 'boolean') return v ? 'Y' : 'N'; + if (typeof v === 'number') { + if (Number.isInteger(v)) return v.toLocaleString('ko-KR'); + return v.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); + } + return String(v ?? '—'); +} + +function formatAgo(ms: number): string { + if (!ms) return '—'; + const sec = Math.floor((Date.now() - ms) / 1000); + if (sec < 60) return `${sec}초 전`; + if (sec < 3600) return `${Math.floor(sec / 60)}분 전`; + return `${Math.floor(sec / 3600)}시간 전`; +} + +const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => ( +
+
+ {proc.layer} + + {STATUS_LABEL[proc.status] ?? proc.status} + +
+

{proc.name}

+
    + {Object.entries(proc.metrics).slice(0, 6).map(([k, v]) => ( +
  • + {k} + {formatMetricValue(v)} +
  • + ))} +
+
+); + +const DashboardPage: React.FC = ({ theme, onGoChart }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(() => { + setLoading(true); + loadDashboardSummary() + .then(d => { setData(d); setError(null); }) + .catch(e => setError((e as Error).message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + refresh(); + const id = window.setInterval(refresh, 5_000); + return () => window.clearInterval(id); + }, [refresh]); + + const app = data?.app as Record | undefined; + const mon: SystemMonitorDto | undefined = data?.systemMonitor; + + return ( +
+
+

운영 대시보드

+
+ {mon && ( + + 파이프라인 {mon.pipelineEnabled ? 'ON' : 'OFF'} + {mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)} 갱신` : ''} + + )} + +
+
+ + {error &&

{error}

} + + {data && mon && ( +
+ {/* JVM · 트래픽 */} +
+

JVM · 트래픽

+
+
+
+ Heap {mon.jvm.heapUsedMb} / {mon.jvm.heapMaxMb} MB ({mon.jvm.heapPct}%) +
+
+
85 ? ' dash-jvm-fill--warn' : ''}`} + style={{ width: `${Math.min(100, mon.jvm.heapPct)}%` }} + /> +
+
+
    +
  • WS 메시지/s{mon.traffic.wsMessagesPerSec ?? 0}
  • +
  • 틱 적재/s{mon.traffic.ticksEnqueuedPerSec ?? 0}
  • +
  • 틱 처리/s{mon.traffic.ticksProcessedPerSec ?? 0}
  • +
  • 주문 실행/s{mon.traffic.ordersExecutedPerSec ?? 0}
  • +
  • 스레드{mon.jvm.threadCount}
  • +
  • Non-Heap{mon.jvm.nonHeapUsedMb} MB
  • +
+
+
+ + {/* Ta4j 메모리 */} +
+

인메모리 캔들 (Ta4j)

+
    +
  • 종목{mon.memory.markets}
  • +
  • 시리즈{mon.memory.series}
  • +
  • 총 봉 수{mon.memory.totalBars.toLocaleString('ko-KR')}
  • +
  • 시리즈 상한{mon.memory.maxBarsPerSeries}
  • +
+
+ + {/* 이벤트 큐 */} +
+

이벤트 큐

+
    +
  • 대기{mon.queue.pending}
  • +
  • 용량{mon.queue.capacity?.toLocaleString('ko-KR') ?? '—'}
  • +
  • 적재율{Math.round((mon.queue.fillRatio ?? 0) * 1000) / 10}%
  • +
  • 드롭 0 ? 'warn' : ''}>{mon.queue.dropped}
  • +
+
+ + {/* 멀티스레드 프로세스 */} +
+

멀티스레드 프로세스

+
+ {mon.processes.map(p => ( + + ))} +
+
+ + {/* 스레드 목록 */} +
+

관련 스레드

+ {mon.threads.length === 0 ? ( +

표시할 스레드 없음

+ ) : ( + + + + + + {mon.threads.map(t => ( + + + + + + + ))} + +
이름ID상태데몬
{t.name}{t.id}{t.state}{t.daemon ? 'Y' : 'N'}
+ )} +
+ + {/* 틱 정체 종목 */} + {mon.staleMarkets.length > 0 && ( +
+

틱 정체 종목 ({mon.staleMarkets.length})

+ + + + + + {mon.staleMarkets.slice(0, 12).map(s => ( + + + + + ))} + +
종목마지막 틱 경과
+ + {s.lastTickAgeSec}초
+
+ )} + +
+

매매 모드

+
    +
  • 운영 모드{String(app?.tradingMode ?? '—')}
  • +
  • 실거래 자동{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}
  • +
  • 모의 자동{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}
  • +
  • 전략 체크{app?.liveStrategyCheck ? 'ON' : 'OFF'}
  • +
+
+ +
+

모니터링

+
    +
  • 관심종목{data.watchlistCount}
  • +
  • 전략 감시{data.monitoredMarkets}
  • +
  • FCM + {data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '설정됨·서버 미연결') : 'OFF'} +
  • +
+
+ +
+

모의투자

+
    +
  • 현금{fmtKrw(data.paper.cashBalance)} KRW
  • +
  • 총자산{fmtKrw(data.paper.totalAsset)} KRW
  • +
  • 보유 종목{data.paper.positions?.length ?? 0}
  • +
+
+ +
+

실거래

+
    +
  • KRW{fmtKrw(data.live.krwBalance)}
  • +
  • 보유{data.live.positions?.length ?? 0} 종목
  • +
  • 연동{data.live.configured ? 'OK' : '미설정'}
  • +
+
+ +
+

최근 시그널

+ {data.recentSignals.length === 0 ? ( +

최근 시그널 없음

+ ) : ( + + + + + + {data.recentSignals.map(s => ( + + + + + + + + ))} + +
종목방향가격분봉시각
+ + {s.signalType}{s.price?.toLocaleString('ko-KR')}{s.candleType}{s.createdAt?.slice(0, 19) ?? '—'}
+ )} +
+
+ )} +
+ ); +}; + +export default DashboardPage; diff --git a/frontend/src/components/DraggableModalFrame.tsx b/frontend/src/components/DraggableModalFrame.tsx new file mode 100644 index 0000000..49e4fc5 --- /dev/null +++ b/frontend/src/components/DraggableModalFrame.tsx @@ -0,0 +1,78 @@ +/** + * 드래그 가능한 모달 프레임 — 그라데이션 타이틀바(gc-popup-header) + 화면 중앙 초기 배치 + */ +import React from 'react'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +export interface DraggableModalFrameProps { + onClose: () => void; + title: React.ReactNode; + children: React.ReactNode; + overlayClassName?: string; + dialogClassName?: string; + bodyClassName?: string; + zIndex?: number; + width?: number | string; + closeOnBackdrop?: boolean; +} + +export const DraggableModalFrame: React.FC = ({ + onClose, + title, + children, + overlayClassName = 'sp-modal-overlay', + dialogClassName = 'sp-modal', + bodyClassName = 'sp-modal-body', + zIndex = 1000, + width, + closeOnBackdrop = true, +}) => { + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + return ( +
{ + if (closeOnBackdrop && e.target === e.currentTarget) onClose(); + }} + > +
e.stopPropagation()} + > +
+ {title} + +
+
{children}
+
+
+ ); +}; + +export default DraggableModalFrame; diff --git a/frontend/src/components/DrawingCanvas.tsx b/frontend/src/components/DrawingCanvas.tsx new file mode 100644 index 0000000..3a19954 --- /dev/null +++ b/frontend/src/components/DrawingCanvas.tsx @@ -0,0 +1,2690 @@ +import React, { useRef, useEffect, useCallback, useState } from 'react'; +import type { ChartManager } from '../utils/ChartManager'; +import type { Drawing, DrawingPoint, DrawingToolId, Theme } from '../types'; +import { formatUnixDateTime } from '../utils/timezone'; + +// ─── Hit-test ────────────────────────────────────────────────────────────── +const HIT_RADIUS = 7; // px (CSS 좌표) + +export function distToSegment( + px: number, py: number, + x0: number, y0: number, + x1: number, y1: number, +): number { + const dx = x1 - x0, dy = y1 - y0; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(px - x0, py - y0); + const t = Math.max(0, Math.min(1, ((px - x0) * dx + (py - y0) * dy) / lenSq)); + return Math.hypot(px - x0 - t * dx, py - y0 - t * dy); +} + +/** 툴 alias 해소 - 저장된 드로잉의 type을 실제 렌더 type으로 변환 */ +function resolveAlias(type: string): string { + return TOOL_ALIAS[type] ?? type; +} + +/** 캔버스 CSS 좌표 (cx, cy)가 주어진 Drawing 위에 있으면 true */ +export function hitTestDrawing( + cx: number, cy: number, + d: Drawing, + m: ChartManager, + cssW: number, + cssH: number, +): boolean { + if (d.visible === false) return false; + + const effectiveType = resolveAlias(d.type); + switch (effectiveType) { + case 'hline': { + if (!d.points[0]) return false; + const y = m.priceToY(d.points[0].price); + return y !== null && Math.abs(cy - y) <= HIT_RADIUS; + } + case 'vline': { + if (!d.points[0]) return false; + const x = m.timeToX(d.points[0].time); + return x !== null && Math.abs(cx - x) <= HIT_RADIUS; + } + case 'trendline': + case 'ray': { + if (d.points.length < 2) return false; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return false; + const dx = x1 - x0, dy = y1 - y0; + const extend = d.type === 'trendline' ? 'both' : 'right'; + let sx = x0, sy = y0, ex = x1, ey = y1; + if (extend === 'right' || extend === 'both') { + if (Math.abs(dx) > 0.001) { + const t = (cssW - x0) / dx; + if (t > 1) { ex = cssW; ey = y0 + dy * t; } + } + } + if (extend === 'both') { + if (Math.abs(dx) > 0.001) { + const t = (0 - x0) / dx; + if (t < 0) { sx = 0; sy = y0 + dy * t; } + } + } + return distToSegment(cx, cy, sx, sy, ex, ey) <= HIT_RADIUS; + } + case 'fib': { + if (d.points.length < 2) return false; + const [p0, p1] = d.points; + const range = p0.price - p1.price; + for (const level of FIB_LEVELS) { + const price = p0.price - range * level; + const y = m.priceToY(price); + if (y !== null && Math.abs(cy - y) <= HIT_RADIUS) return true; + } + return false; + } + case 'fibtz': { + if (d.points.length < 1) return false; + // 기준선(0) 히트 테스트 + const x0tz = m.timeToX(d.points[0].time); + if (x0tz !== null && Math.abs(cx - x0tz) <= HIT_RADIUS) return true; + if (d.points.length < 2) return false; + // 각 피보나치 레벨 수직선 히트 테스트 + const dt = d.points[1].time - d.points[0].time; + for (const level of FIB_TZ_LEVELS) { + const xTZ = m.timeToX(d.points[0].time + dt * level); + if (xTZ !== null && Math.abs(cx - xTZ) <= HIT_RADIUS) return true; + } + return false; + } + case 'rect': { + if (d.points.length < 2) return false; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return false; + const lx = Math.min(x0, x1), rx = Math.max(x0, x1); + const ty = Math.min(y0, y1), by = Math.max(y0, y1); + // 내부 + 테두리 모두 hit + return cx >= lx - HIT_RADIUS && cx <= rx + HIT_RADIUS + && cy >= ty - HIT_RADIUS && cy <= by + HIT_RADIUS; + } + case 'measure': { + if (d.points.length < 2) return false; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return false; + const lx = Math.min(x0, x1) - HIT_RADIUS, rx = Math.max(x0, x1) + HIT_RADIUS; + const ty = Math.min(y0, y1) - HIT_RADIUS, by = Math.max(y0, y1) + HIT_RADIUS; + return cx >= lx && cx <= rx && cy >= ty && cy <= by; + } + case 'channel': { + // 첫 번째 선만 hit-test (main line) + if (d.points.length < 2) return false; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return false; + const dx = x1 - x0, dy = y1 - y0; + let sx = x0, sy = y0, ex = x1, ey = y1; + if (Math.abs(dx) > 0.001) { + const tR = (cssW - x0) / dx; if (tR > 1) { ex = cssW; ey = y0 + dy * tR; } + const tL = -x0 / dx; if (tL < 0) { sx = 0; sy = y0 + dy * tL; } + } + return distToSegment(cx, cy, sx, sy, ex, ey) <= HIT_RADIUS; + } + case 'pencil': { + if (d.points.length < 2) return false; + for (let i = 0; i < d.points.length - 1; i++) { + const x0 = m.timeToX(d.points[i].time), y0 = m.priceToY(d.points[i].price); + const x1 = m.timeToX(d.points[i + 1].time), y1 = m.priceToY(d.points[i + 1].price); + if (x0 !== null && y0 !== null && x1 !== null && y1 !== null) { + if (distToSegment(cx, cy, x0, y0, x1, y1) <= HIT_RADIUS) return true; + } + } + return false; + } + case 'text': { + if (!d.points[0]) return false; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return false; + return cx >= x - 4 && cx <= x + 120 && cy >= y - 20 && cy <= y + 6; + } + case 'circle': { + if (d.points.length < 2) return false; + const cx0 = m.timeToX(d.points[0].time), cy0 = m.priceToY(d.points[0].price); + const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price); + if (cx0 === null || cy0 === null || ex === null || ey === null) return false; + const r = Math.hypot(ex - cx0, ey - cy0); + const dist = Math.hypot(cx - cx0, cy - cy0); + return Math.abs(dist - r) <= HIT_RADIUS; + } + case 'triangle': { + if (d.points.length < 3) return false; + for (let i = 0; i < 3; i++) { + const a = d.points[i], b = d.points[(i + 1) % 3]; + const ax = m.timeToX(a.time), ay = m.priceToY(a.price); + const bx = m.timeToX(b.time), by = m.priceToY(b.price); + if (ax === null || ay === null || bx === null || by === null) continue; + if (distToSegment(cx, cy, ax, ay, bx, by) <= HIT_RADIUS) return true; + } + return false; + } + case 'pitchfork': { + if (d.points.length < 3) return false; + const [p0, p1, p2] = d.points; + const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price); + const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price); + const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price); + if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return false; + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + if (distToSegment(cx, cy, x0, y0, mx, my) <= HIT_RADIUS) return true; + if (distToSegment(cx, cy, x0+(x1-mx), y0+(y1-my), x1, y1) <= HIT_RADIUS) return true; + if (distToSegment(cx, cy, x0+(x2-mx), y0+(y2-my), x2, y2) <= HIT_RADIUS) return true; + return false; + } + case 'arrow_mark_up': + case 'arrow_mark_down': + case 'arrow_mark_left': + case 'arrow_mark_right': + case 'arrow_mark': { + if (!d.points[0]) return false; + const ax = m.timeToX(d.points[0].time), ay = m.priceToY(d.points[0].price); + if (ax === null || ay === null) return false; + return Math.hypot(cx - ax, cy - ay) <= 14; + } + case 'long_position': + case 'short_position': { + if (d.points.length < 2) return false; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return false; + return cx >= Math.min(x0,x1)-HIT_RADIUS && cx <= Math.max(x0,x1)+HIT_RADIUS + && cy >= Math.min(y0,y1)-HIT_RADIUS && cy <= Math.max(y0,y1)+HIT_RADIUS; + } + // ── 신규 툴 히트 테스트 ── + case 'cross_line': { + if (!d.points[0]) return false; + const xc = m.timeToX(d.points[0].time), yc = m.priceToY(d.points[0].price); + if (xc === null || yc === null) return false; + return Math.abs(cy - yc) <= HIT_RADIUS || Math.abs(cx - xc) <= HIT_RADIUS; + } + case 'hline_ray': + case 'info_line': + case 'guide_line': { + if (!d.points[0]) return false; + const xr = m.timeToX(d.points[0].time), yr = m.priceToY(d.points[0].price); + if (xr === null || yr === null) return false; + return Math.abs(cy - yr) <= HIT_RADIUS && cx >= xr - HIT_RADIUS; + } + case 'arrow': { + if (d.points.length < 2) return false; + const xa0 = m.timeToX(d.points[0].time), ya0 = m.priceToY(d.points[0].price); + const xa1 = m.timeToX(d.points[1].time), ya1 = m.priceToY(d.points[1].price); + if (xa0===null||ya0===null||xa1===null||ya1===null) return false; + return distToSegment(cx, cy, xa0, ya0, xa1, ya1) <= HIT_RADIUS; + } + case 'trend_angle': { + if (d.points.length < 2) return false; + const xt0 = m.timeToX(d.points[0].time), yt0 = m.priceToY(d.points[0].price); + const xt1 = m.timeToX(d.points[1].time), yt1 = m.priceToY(d.points[1].price); + if (xt0===null||yt0===null||xt1===null||yt1===null) return false; + return distToSegment(cx, cy, xt0, yt0, xt1, yt1) <= HIT_RADIUS; + } + case 'parallel_channel': { + if (d.points.length < 2) return false; + const xp0 = m.timeToX(d.points[0].time), yp0 = m.priceToY(d.points[0].price); + const xp1 = m.timeToX(d.points[1].time), yp1 = m.priceToY(d.points[1].price); + if (xp0===null||yp0===null||xp1===null||yp1===null) return false; + return distToSegment(cx, cy, xp0, yp0, xp1, yp1) <= HIT_RADIUS; + } + case 'fib_ext': + case 'fib_channel': + case 'fib_speed_fan': + case 'fib_circles': + case 'fib_spiral': + case 'fib_speed_arc': { + if (d.points.length < 2) return false; + const xfe0 = m.timeToX(d.points[0].time), yfe0 = m.priceToY(d.points[0].price); + const xfe1 = m.timeToX(d.points[1].time), yfe1 = m.priceToY(d.points[1].price); + if (xfe0===null||yfe0===null) return false; + // 중심점 근처 또는 반지름 선 근처 + if (Math.hypot(cx - xfe0, cy - yfe0) <= HIT_RADIUS * 2) return true; + if (xfe1!==null && yfe1!==null) { + const r = Math.hypot(xfe1 - xfe0, yfe1 - yfe0); + const distFromCenter = Math.hypot(cx - xfe0, cy - yfe0); + return Math.abs(distFromCenter - r) <= HIT_RADIUS; + } + return false; + } + case 'gann_fan': + case 'gann_box': + case 'gann_square': { + if (d.points.length < 2) return false; + const xg0 = m.timeToX(d.points[0].time), yg0 = m.priceToY(d.points[0].price); + const xg1 = m.timeToX(d.points[1].time), yg1 = m.priceToY(d.points[1].price); + if (xg0===null||yg0===null||xg1===null||yg1===null) return false; + const lgx = Math.min(xg0,xg1)-HIT_RADIUS, rgx = Math.max(xg0,xg1)+HIT_RADIUS; + const tgy = Math.min(yg0,yg1)-HIT_RADIUS, bgy = Math.max(yg0,yg1)+HIT_RADIUS; + return cx >= lgx && cx <= rgx && cy >= tgy && cy <= bgy; + } + case 'forecast': { + if (d.points.length < 2) return false; + const xf0 = m.timeToX(d.points[0].time), yf0 = m.priceToY(d.points[0].price); + const xf1 = m.timeToX(d.points[1].time), yf1 = m.priceToY(d.points[1].price); + if (xf0===null||yf0===null||xf1===null||yf1===null) return false; + return cx >= Math.min(xf0,xf1)-HIT_RADIUS && cx <= Math.max(xf0,xf1)+HIT_RADIUS + && cy >= Math.min(yf0,yf1)-HIT_RADIUS && cy <= Math.max(yf0,yf1)+HIT_RADIUS; + } + case 'date_range': { + if (d.points.length < 2) return false; + const xd0 = m.timeToX(d.points[0].time); + const xd1 = m.timeToX(d.points[1].time); + if (xd0===null||xd1===null) return false; + return cx >= Math.min(xd0,xd1)-HIT_RADIUS && cx <= Math.max(xd0,xd1)+HIT_RADIUS; + } + case 'date_price_range': { + if (d.points.length < 2) return false; + const xdp0 = m.timeToX(d.points[0].time), ydp0 = m.priceToY(d.points[0].price); + const xdp1 = m.timeToX(d.points[1].time), ydp1 = m.priceToY(d.points[1].price); + if (xdp0===null||ydp0===null||xdp1===null||ydp1===null) return false; + return cx >= Math.min(xdp0,xdp1)-HIT_RADIUS && cx <= Math.max(xdp0,xdp1)+HIT_RADIUS + && cy >= Math.min(ydp0,ydp1)-HIT_RADIUS && cy <= Math.max(ydp0,ydp1)+HIT_RADIUS; + } + case 'data_window': { + if (!d.points[0]) return false; + const xdw = m.timeToX(d.points[0].time), ydw = m.priceToY(d.points[0].price); + if (xdw===null||ydw===null) return false; + return cx >= xdw - HIT_RADIUS && cx <= xdw + 180 && Math.abs(cy - ydw) <= 50; + } + case 'magnifier': { + if (!d.points[0]) return false; + const xmg = m.timeToX(d.points[0].time), ymg = m.priceToY(d.points[0].price); + if (xmg===null||ymg===null) return false; + let rmg = 40; + if (d.points.length >= 2) { + const xmg1 = m.timeToX(d.points[1].time), ymg1 = m.priceToY(d.points[1].price); + if (xmg1!==null && ymg1!==null) rmg = Math.max(20, Math.hypot(xmg1-xmg, ymg1-ymg)); + } + return Math.hypot(cx - xmg, cy - ymg) <= rmg + HIT_RADIUS; + } + case 'callout': { + if (!d.points[0]) return false; + const xca = m.timeToX(d.points[0].time), yca = m.priceToY(d.points[0].price); + if (xca===null||yca===null) return false; + return cx >= xca - 30 && cx <= xca + 150 && cy >= yca - 60 && cy <= yca + 10; + } + case 'pin_mark': + case 'flag_mark': { + if (!d.points[0]) return false; + const xpm = m.timeToX(d.points[0].time), ypm = m.priceToY(d.points[0].price); + if (xpm===null||ypm===null) return false; + return Math.hypot(cx - xpm, cy - ypm) <= 20; + } + case 'note_text': { + if (!d.points[0]) return false; + const xnt = m.timeToX(d.points[0].time), ynt = m.priceToY(d.points[0].price); + if (xnt===null||ynt===null) return false; + return cx >= xnt && cx <= xnt + 140 && cy >= ynt && cy <= ynt + 60; + } + case 'price_note': + case 'price_label': { + if (!d.points[0]) return false; + const xpn = m.timeToX(d.points[0].time), ypn = m.priceToY(d.points[0].price); + if (xpn===null||ypn===null) return false; + return Math.hypot(cx - xpn, cy - ypn) <= HIT_RADIUS * 3; + } + case 'comment_mark': { + if (!d.points[0]) return false; + const xcm = m.timeToX(d.points[0].time), ycm = m.priceToY(d.points[0].price); + if (xcm===null||ycm===null) return false; + return cx >= xcm - 40 && cx <= xcm + 40 && cy >= ycm - 50 && cy <= ycm + 10; + } + case 'fixed_text': { + if (!d.points[0]) return false; + const xft = m.timeToX(d.points[0].time), yft = m.priceToY(d.points[0].price); + if (xft===null||yft===null) return false; + return cx >= xft && cx <= xft + 140 && Math.abs(cy - yft) <= 12; + } + case 'pitchfork_schiff': + case 'pitchfork_inside': { + if (d.points.length < 3) return false; + const [ps0, ps1, ps2] = d.points; + const xps0 = m.timeToX(ps0.time), yps0 = m.priceToY(ps0.price); + const xps1 = m.timeToX(ps1.time), yps1 = m.priceToY(ps1.price); + const xps2 = m.timeToX(ps2.time), yps2 = m.priceToY(ps2.price); + if (xps0===null||yps0===null||xps1===null||yps1===null||xps2===null||yps2===null) return false; + if (distToSegment(cx, cy, xps0, yps0, (xps1+xps2)/2, (yps1+yps2)/2) <= HIT_RADIUS) return true; + if (distToSegment(cx, cy, xps0, yps0, xps1, yps1) <= HIT_RADIUS) return true; + return distToSegment(cx, cy, xps0, yps0, xps2, yps2) <= HIT_RADIUS; + } + case 'ellipse': { + if (d.points.length < 2) return false; + const xel = m.timeToX(d.points[0].time), yel = m.priceToY(d.points[0].price); + const xel1 = m.timeToX(d.points[1].time), yel1 = m.priceToY(d.points[1].price); + if (xel===null||yel===null||xel1===null||yel1===null) return false; + const rx = Math.abs(xel1-xel), ry = Math.abs(yel1-yel); + if (rx < 1 || ry < 1) return false; + const norm = Math.hypot((cx-xel)/rx, (cy-yel)/ry); + return Math.abs(norm - 1) <= HIT_RADIUS / Math.min(rx, ry); + } + case 'arc': { + if (!d.points[0]) return false; + const xar = m.timeToX(d.points[0].time), yar = m.priceToY(d.points[0].price); + if (xar===null||yar===null) return false; + return Math.hypot(cx - xar, cy - yar) <= HIT_RADIUS * 2; + } + case 'rotated_rect': + case 'parallelogram': { + if (d.points.length < 2) return false; + // 경계 박스로 근사 히트테스트 + const pts2d = d.points.map(p => ({x:m.timeToX(p.time), y:m.priceToY(p.price)})); + for (let i = 0; i < pts2d.length - 1; i++) { + const {x:ax,y:ay} = pts2d[i], {x:bx,y:by} = pts2d[i+1]; + if (ax!==null&&ay!==null&&bx!==null&&by!==null) { + if (distToSegment(cx,cy,ax,ay,bx,by) <= HIT_RADIUS) return true; + } + } + // 마지막 변 (마지막→첫 번째) + const first = pts2d[0], last = pts2d[pts2d.length-1]; + if (first.x!==null&&first.y!==null&&last.x!==null&&last.y!==null) { + if (distToSegment(cx,cy,last.x,last.y,first.x,first.y) <= HIT_RADIUS) return true; + } + return false; + } + case 'polyline': { + for (let i = 0; i < d.points.length - 1; i++) { + const xpl0=m.timeToX(d.points[i].time), ypl0=m.priceToY(d.points[i].price); + const xpl1=m.timeToX(d.points[i+1].time), ypl1=m.priceToY(d.points[i+1].price); + if (xpl0!==null&&ypl0!==null&&xpl1!==null&&ypl1!==null) { + if (distToSegment(cx,cy,xpl0,ypl0,xpl1,ypl1) <= HIT_RADIUS) return true; + } + } + return false; + } + // ── 패턴 (하모닉/엘리엇) ── 임의 선분 클릭 + case 'xabcd': case 'cypher': case 'abcd': + case 'head_shoulders': case 'wedge_pattern': case 'three_drives': + case 'elliott_impulse': case 'elliott_correction': + case 'elliott_triangle_wave': case 'elliott_double_combo': { + for (let i = 0; i < d.points.length - 1; i++) { + const xp0 = m.timeToX(d.points[i].time), yp0 = m.priceToY(d.points[i].price); + const xp1 = m.timeToX(d.points[i+1].time), yp1 = m.priceToY(d.points[i+1].price); + if (xp0!==null&&yp0!==null&&xp1!==null&&yp1!==null) { + if (distToSegment(cx, cy, xp0, yp0, xp1, yp1) <= HIT_RADIUS) return true; + } + } + return false; + } + // ── 프로젝션 도구 ── + case 'ghost_feed': case 'bar_pattern': case 'fixed_volume_profile': case 'forecast': { + if (d.points.length < 2) return false; + const xpr0 = m.timeToX(d.points[0].time), ypr0 = m.priceToY(d.points[0].price); + const xpr1 = m.timeToX(d.points[1].time), ypr1 = m.priceToY(d.points[1].price); + if (xpr0===null||ypr0===null||xpr1===null||ypr1===null) return false; + return cx >= Math.min(xpr0,xpr1)-HIT_RADIUS && cx <= Math.max(xpr0,xpr1)+HIT_RADIUS + && cy >= Math.min(ypr0,ypr1)-HIT_RADIUS && cy <= Math.max(ypr0,ypr1)+HIT_RADIUS; + } + case 'anchored_vwap': { + if (!d.points[0]) return false; + const xav = m.timeToX(d.points[0].time), yav = m.priceToY(d.points[0].price); + if (xav===null||yav===null) return false; + return Math.abs(cy - yav) <= HIT_RADIUS && cx >= xav - HIT_RADIUS; + } + default: return false; + } +} + +// ─── Tool config ─────────────────────────────────────────────────────────── + +// 신규 툴 → 기존 툴 렌더/히트테스트 위임 매핑 +// (독립 렌더러가 구현된 툴은 여기에 없음) +const TOOL_ALIAS: Record = { + // 선 계열 + extended_line: 'trendline', // 양방향 확장선 (trendline과 동일) + fib_trend_time: 'fib', // 추세기반 피보나치 시간 → 기본 fib 레벨 + fib_wedge: 'fib', // 피보나치 웻지 → 기본 fib 레벨 + // fib_circles, fib_spiral, fib_speed_arc 는 독립 렌더러로 구현됨 + disjoint_channel: 'channel', + flat_top_bottom: 'rect', + // 텍스트 기반 (자체 렌더러 없는 것만 alias 유지) + table_tool: 'text', // 테이블은 단순 텍스트 박스로 처리 + // note_text / price_note / comment_mark / fixed_text / price_label → 자체 렌더러 + // 화살표 마크 (arrow_mark / anchored_arrow → 화살촉 있는 arrow 렌더) + arrow_mark: 'arrow', + anchored_arrow: 'arrow', + arrow_mark_left: 'arrow_mark_left', + arrow_mark_right: 'arrow_mark_right', + signpost_up: 'arrow_mark_up', + signpost_down: 'arrow_mark_down', + // 기타 + highlight: 'pencil', + price_range: 'measure', + // date_price_range / data_window → 자체 렌더러 + // magnifier → 자체 렌더러 + bar_pattern: 'rect', + ghost_feed: 'rect', + projection_tool: 'trendline', + anchored_vwap: 'hline', + fixed_volume_profile: 'rect', +}; + +// 실제 드로잉을 생성하지 않는 스텁 툴 (UI 선택만) +const STUB_TOOLS = new Set([ + // 현재 모든 툴 구현 완료 +]); + +const TOOL_COLORS: Record = { + hline: '#FF6D00', + vline: '#2196F3', + trendline: '#4CAF50', + extended_line: '#4CAF50', + ray: '#9C27B0', + arrow: '#9C27B0', + trend_angle: '#4CAF50', + cross_line: '#2196F3', + hline_ray: '#FF6D00', + info_line: '#FF6D00', + guide_line: '#78909C', + fib: '#FFC107', + fib_ext: '#FF9800', + fib_channel: '#FFC107', + fib_speed_fan: '#FFB300', + measure: '#00BCD4', + date_range: '#00BCD4', + forecast: '#78909C', + text: '#FFEB3B', + callout: '#FFEB3B', + pin_mark: '#F06292', + flag_mark: '#EF5350', + channel: '#E91E63', + parallel_channel: '#E91E63', + rect: '#607D8B', + gann_box: '#8BC34A', + gann_square: '#8BC34A', + gann_fan: '#CDDC39', + pencil: '#FF5722', + circle: '#26C6DA', + ellipse: '#26C6DA', + triangle: '#AB47BC', + arc: '#AB47BC', + pitchfork: '#EF5350', + pitchfork_schiff: '#FF7043', + pitchfork_inside: '#FF8A65', + arrow_mark_up: '#4CAF50', + arrow_mark_down: '#EF5350', + arrow_mark_left: '#FFC107', + arrow_mark_right: '#FFC107', + arrow_mark: '#9C27B0', + long_position: '#4CAF50', + short_position: '#EF5350', + highlight: '#FFEB3B', + rotated_rect: '#AB47BC', + parallelogram: '#CE93D8', + polyline: '#FF9800', + note_text: '#F9A825', + price_note: '#26C6DA', + comment_mark: '#78909C', + fixed_text: '#AED6F1', + price_label: '#EF5350', + date_price_range: '#00BCD4', + data_window: '#42A5F5', + magnifier: '#80DEEA', +}; + +const POINTS_NEEDED: Record = { + // ── 1점 ── + hline: 1, vline: 1, text: 1, callout: 1, + arrow_mark_up: 1, arrow_mark_down: 1, arrow_mark_left: 1, arrow_mark_right: 1, + arrow_mark: 1, flag_mark: 1, pin_mark: 1, + cross_line: 1, hline_ray: 1, info_line: 1, guide_line: 1, + // 텍스트 별칭 (1점) + fixed_text: 1, note_text: 1, price_note: 1, price_label: 1, + comment_mark: 1, table_tool: 1, + // ── 2점 ── + trendline: 2, extended_line: 2, ray: 2, arrow: 2, trend_angle: 2, + anchored_arrow: 2, projection_tool: 2, + fib: 2, fib_ext: 2, fib_channel: 2, fib_speed_fan: 2, + fib_trend_time: 2, fib_circles: 2, fib_spiral: 2, + fib_speed_arc: 2, fib_wedge: 2, + fibtz: 2, measure: 2, price_range: 2, + rect: 2, flat_top_bottom: 2, + channel: 2, disjoint_channel: 2, + circle: 2, ellipse: 2, long_position: 2, short_position: 2, + gann_fan: 2, gann_box: 2, gann_square: 2, + forecast: 2, date_range: 2, date_price_range: 2, data_window: 1, magnifier: 2, + bar_pattern: 2, ghost_feed: 2, fixed_volume_profile: 2, + anchored_vwap: 1, + // ── 3점 ── + triangle: 3, pitchfork: 3, pitchfork_schiff: 3, pitchfork_inside: 3, + parallel_channel: 3, arc: 3, + rotated_rect: 3, parallelogram: 3, + // ── 다각형 (더블클릭으로 완성, 사실상 무한) ── + polyline: 999, + // ── 패턴 스텁 (포인트 수집용) ── + xabcd: 5, cypher: 5, abcd: 4, head_shoulders: 7, wedge_pattern: 5, three_drives: 6, + elliott_impulse: 6, elliott_correction: 4, elliott_triangle_wave: 6, elliott_double_combo: 4, + // pencil, zoom, highlight : drag 기반이므로 별도 처리 +}; + +const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1, 1.272, 1.618]; +const FIB_COLORS = ['#EF5350','#FF9800','#FFC107','#FFFFFF','#4CAF50','#2196F3','#9C27B0','#607D8B','#607D8B']; + +// 피보나치 확장 레벨 +const FIB_EXT_LEVELS = [0, 0.236, 0.382, 0.618, 0.786, 1.0, 1.272, 1.618, 2.0, 2.618]; +const FIB_EXT_COLORS = ['#EF5350','#FF9800','#FFC107','#4CAF50','#26C6DA','#FFFFFF','#9C27B0','#E91E63','#F44336','#00BCD4']; + +// 갠 팬 비율 +const GANN_RATIOS = [8, 4, 3, 2, 1, 0.5, 0.333, 0.25, 0.125]; +const GANN_LABELS = ['8:1','4:1','3:1','2:1','1:1','1:2','1:3','1:4','1:8']; +const GANN_COLORS_ARR = ['#EF5350','#FF9800','#FFC107','#8BC34A','#FFFFFF','#8BC34A','#FFC107','#FF9800','#EF5350']; + +// 피보나치 타임존 수열 (캔들 간격 배수) +const FIB_TZ_LEVELS = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]; +const FIB_TZ_COLORS = [ + '#2196F3','#4CAF50','#FF9800','#E91E63','#9C27B0', + '#00BCD4','#8BC34A','#FF5722','#607D8B','#795548', +]; + +// ─── Renderers ──────────────────────────────────────────────────────────── +function setLineStyle(ctx: CanvasRenderingContext2D, style?: string) { + if (style === 'dashed') ctx.setLineDash([6, 4]); + else if (style === 'dotted') ctx.setLineDash([2, 4]); + else ctx.setLineDash([]); +} + +function renderHLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const y = m.priceToY(d.points[0].price); + if (y === null || y < 0) return; + setLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); + const lbl = d.points[0].price.toFixed(2); + ctx.font = '11px monospace'; + const tw = ctx.measureText(lbl).width; + ctx.fillStyle = d.color + 'cc'; + ctx.fillRect(cssW - tw - 10, y - 10, tw + 8, 14); + ctx.fillStyle = '#fff'; + ctx.fillText(lbl, cssW - tw - 6, y + 1); +} + +function renderVLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time); + if (x === null || x < 0) return; + setLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cssH); ctx.stroke(); +} + +function renderLineOrRay( + ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, + extend: 'none' | 'both' | 'right', cssW: number, cssH: number, +) { + if (d.points.length < 1) return; + const p0 = d.points[0]; + const x0 = m.timeToX(p0.time); const y0 = m.priceToY(p0.price); + if (x0 === null || y0 === null) return; + + if (d.points.length < 2) { + ctx.beginPath(); ctx.arc(x0, y0, 4, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); return; + } + + const p1 = d.points[1]; + const x1 = m.timeToX(p1.time); const y1 = m.priceToY(p1.price); + if (x1 === null || y1 === null) return; + + setLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + + let sx = x0, sy = y0, ex = x1, ey = y1; + const dx = x1 - x0, dy = y1 - y0; + + if (extend === 'right' || extend === 'both') { + if (Math.abs(dx) > 0.001) { + const t = (cssW - x0) / dx; + if (t > 1) { ex = cssW; ey = y0 + dy * t; } + } + } + if (extend === 'both') { + if (Math.abs(dx) > 0.001) { + const t = (0 - x0) / dx; + if (t < 0) { sx = 0; sy = y0 + dy * t; } + } + } + + ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); + + const endPrice = m.yToPrice(ey); + if (endPrice !== null) { + const lbl = endPrice.toFixed(2); + ctx.font = '10px monospace'; + const tw = ctx.measureText(lbl).width; + ctx.fillStyle = d.color + 'cc'; + ctx.fillRect(ex - tw - 8, ey - 10, tw + 6, 13); + ctx.fillStyle = '#fff'; + ctx.fillText(lbl, ex - tw - 5, ey + 1); + } +} + +function renderFib(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (d.points.length < 2) { + renderLineOrRay(ctx, d, m, 'none', cssW, 0); return; + } + const [p0, p1] = d.points; + const range = p0.price - p1.price; + + for (let i = 0; i < FIB_LEVELS.length; i++) { + const price = p0.price - range * FIB_LEVELS[i]; + const y = m.priceToY(price); + if (y === null) continue; + const color = FIB_COLORS[i] ?? '#607D8B'; + ctx.strokeStyle = color; + ctx.lineWidth = (i === 0 || i === 6) ? 1.5 : 0.8; + ctx.setLineDash(i === 3 ? [6, 4] : [3, 3]); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); + + const pct = (FIB_LEVELS[i] * 100).toFixed(1); + const lbl = `${pct}% ${price.toFixed(2)}`; + ctx.font = '10px monospace'; + const tw = ctx.measureText(lbl).width; + ctx.fillStyle = color + 'cc'; + ctx.fillRect(2, y - 12, tw + 6, 14); + ctx.fillStyle = '#fff'; + ctx.fillText(lbl, 5, y - 1); + } +} + +function applyLineStyle(ctx: CanvasRenderingContext2D, style?: string) { + if (style === 'dashed') ctx.setLineDash([6, 4]); + else if (style === 'dotted') ctx.setLineDash([2, 3]); + else ctx.setLineDash([]); +} + +function renderFibTZ(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number) { + if (!d.points[0]) return; + + const x0 = m.timeToX(d.points[0].time); + if (x0 === null) return; + + // settings 가 있으면 적용, 없으면 기본값 사용 + const settings = d.fibtzSettings; + const bl = settings?.baseline; + + ctx.save(); + ctx.textBaseline = 'top'; + ctx.font = 'bold 10px monospace'; + + // 기준선 (0) + ctx.strokeStyle = bl?.color ?? d.color; + ctx.lineWidth = bl?.lineWidth ?? (d.lineWidth ?? 1.5); + applyLineStyle(ctx, bl?.style ?? 'solid'); + ctx.beginPath(); ctx.moveTo(x0, 0); ctx.lineTo(x0, cssH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = bl?.color ?? d.color; + ctx.fillText('0', x0 + 3, 4); + + if (d.points.length >= 2) { + // 두 기준점 사이의 시간 간격 = 1단위 + const dt = d.points[1].time - d.points[0].time; + const lvList = settings?.levels ?? FIB_TZ_LEVELS.map((v, i) => ({ + value: v, + enabled: true, + color: FIB_TZ_COLORS[i % FIB_TZ_COLORS.length], + lineWidth: 1, + style: 'dashed' as const, + })); + + for (const lv of lvList) { + if (!lv.enabled) continue; + + const zoneTime = d.points[0].time + dt * lv.value; + const xTZ = m.timeToX(zoneTime); + if (xTZ === null) continue; + + ctx.strokeStyle = lv.color; + ctx.lineWidth = lv.lineWidth; + ctx.globalAlpha = 0.85; + applyLineStyle(ctx, lv.style); + ctx.beginPath(); ctx.moveTo(xTZ, 0); ctx.lineTo(xTZ, cssH); ctx.stroke(); + + ctx.globalAlpha = 1; + ctx.setLineDash([]); + ctx.fillStyle = lv.color; + ctx.fillText(String(lv.value), xTZ + 3, 4); + } + ctx.globalAlpha = 1; + } + ctx.restore(); +} + +function renderMeasure(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, theme: Theme) { + if (d.points.length < 2) { + const p = d.points[0]; if (!p) return; + const x = m.timeToX(p.time), y = m.priceToY(p.price); + if (x !== null && y !== null) { ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI*2); ctx.fillStyle = '#00BCD4'; ctx.fill(); } + return; + } + const [p0, p1] = d.points; + const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price); + const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + + const lx = Math.min(x0, x1), rx = Math.max(x0, x1); + const ty = Math.min(y0, y1), by = Math.max(y0, y1); + + ctx.setLineDash([]); ctx.strokeStyle = '#00BCD4'; ctx.lineWidth = 1; + ctx.fillStyle = '#00BCD41a'; + ctx.fillRect(lx, ty, rx - lx, by - ty); + ctx.strokeRect(lx, ty, rx - lx, by - ty); + + const pct = ((p1.price - p0.price) / p0.price * 100); + const sign = pct >= 0 ? '+' : ''; + const cx = (lx + rx) / 2; + const cy = (ty + by) / 2; + + ctx.font = 'bold 13px monospace'; + ctx.fillStyle = pct >= 0 ? '#4CAF50' : '#EF5350'; + ctx.textAlign = 'center'; + ctx.fillText(`${sign}${pct.toFixed(2)}%`, cx, cy); + ctx.font = '11px monospace'; + ctx.fillStyle = theme === 'dark' ? '#80DEEA' : '#006064'; + ctx.fillText(`Δ ${Math.abs(p1.price - p0.price).toFixed(2)}`, cx, cy + 15); + ctx.textAlign = 'left'; +} + +function renderRect(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) { renderLineOrRay(ctx, d, m, 'none', 0, 0); return; } + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + setLineStyle(ctx, d.style); ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + ctx.fillStyle = d.color + '22'; + const lx = Math.min(x0,x1), ty = Math.min(y0,y1), w = Math.abs(x1-x0), h = Math.abs(y1-y0); + ctx.fillRect(lx, ty, w, h); + ctx.strokeRect(lx, ty, w, h); +} + +function renderChannel(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + // 첫 번째 선 (추세선) + renderLineOrRay(ctx, d, m, 'both', cssW, cssH); + if (d.points.length >= 2) { + // 3번째 점이 있으면 그 점의 Y 기준으로 평행선, 없으면 기울기 동일한 평행선 자동 생성 + let parallelPoints: DrawingPoint[]; + if (d.points.length >= 3) { + const [p0, p1, p2] = d.points; + // p2 에서 같은 기울기로 평행선 생성 + const priceDiff = p1.price - p0.price; + const timeDiff = p1.time - p0.time; + const endTime = timeDiff !== 0 ? p2.time + timeDiff : p2.time; + const endPrice = timeDiff !== 0 + ? p2.price + priceDiff + : p2.price; + parallelPoints = [p2, { time: endTime, price: endPrice }]; + } else { + // 2점만 있을 때: 가격 차이를 기준으로 자동 평행선 + const [p0, p1] = d.points; + const dy = p1.price - p0.price; + const offset = Math.abs(dy) * 0.618 || (p0.price * 0.01); + parallelPoints = [ + { time: p0.time, price: p0.price - offset }, + { time: p1.time, price: p1.price - offset }, + ]; + } + renderLineOrRay( + ctx, + { ...d, style: 'dashed', points: parallelPoints }, + m, 'both', cssW, cssH, + ); + // 채널 사이 영역 음영 + if (d.points.length >= 2) { + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + const px0 = m.timeToX(parallelPoints[0].time), py0 = m.priceToY(parallelPoints[0].price); + const px1 = m.timeToX(parallelPoints[1].time), py1 = m.priceToY(parallelPoints[1].price); + if (x0!==null && y0!==null && x1!==null && y1!==null && px0!==null && py0!==null && px1!==null && py1!==null) { + ctx.beginPath(); + ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); + ctx.lineTo(px1, py1); ctx.lineTo(px0, py0); + ctx.closePath(); + ctx.fillStyle = d.color + '18'; + ctx.fill(); + } + } + } +} + +function renderPencil(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const isHighlight = d.type === 'highlight'; + setLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; + ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + if (isHighlight) ctx.globalAlpha = 0.35; + ctx.beginPath(); + for (let i = 0; i < d.points.length; i++) { + const x = m.timeToX(d.points[i].time); + const y = m.priceToY(d.points[i].price); + if (x === null || y === null) continue; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); +} + +function renderText(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0] || !d.text) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + ctx.font = 'bold 13px sans-serif'; + ctx.fillStyle = d.color; + ctx.fillText(d.text, x, y); +} + +function renderCircle(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price); + const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price); + if (cx===null||cy===null||ex===null||ey===null) return; + const r = Math.hypot(ex - cx, ey - cy); + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; + ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = d.color + '22'; + ctx.fill(); + ctx.stroke(); +} + +function renderTriangle(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 3) return; + const pts = d.points.slice(0, 3).map(p => ({ x: m.timeToX(p.time), y: m.priceToY(p.price) })); + if (pts.some(p => p.x === null || p.y === null)) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; + ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); + ctx.moveTo(pts[0].x!, pts[0].y!); + ctx.lineTo(pts[1].x!, pts[1].y!); + ctx.lineTo(pts[2].x!, pts[2].y!); + ctx.closePath(); + ctx.fillStyle = d.color + '22'; + ctx.fill(); + ctx.stroke(); +} + +function renderPitchfork(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 3) return; + const [p0, p1, p2] = d.points; + const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price); + const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price); + const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price); + if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return; + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; + ctx.lineWidth = d.lineWidth ?? 1.5; + // 핸들: p0 → 중간점 + const drawExtendedLine = (sx: number, sy: number, ex: number, ey: number) => { + const dx = ex - sx, dy = ey - sy; + const len = Math.hypot(dx, dy); + if (len < 0.001) return; + const t = Math.max(cssW, cssH) * 2 / len; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx + dx * t, sy + dy * t); + ctx.stroke(); + }; + // 핸들선 + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(mx, my); ctx.stroke(); + // 중간 포크 + drawExtendedLine(mx, my, mx + (mx - x0), my + (my - y0)); + // 좌우 포크 + const offX = x1 - mx, offY = y1 - my; + drawExtendedLine(x0 + offX, y0 + offY, x1, y1); + drawExtendedLine(x0 - offX, y0 - offY, x2, y2); + // 포인트 표시 + [[x0,y0],[x1,y1],[x2,y2]].forEach(([px,py]) => { + ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI*2); + ctx.fillStyle = d.color; ctx.fill(); + }); +} + +function renderArrowMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, dir: 'up'|'down'|'left'|'right') { + if (!d.points[0]) return; + const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price); + if (cx===null||cy===null) return; + const s = 10; + ctx.fillStyle = d.color; + ctx.beginPath(); + switch (dir) { + case 'up': + ctx.moveTo(cx, cy - 20); ctx.lineTo(cx - s, cy - 10); ctx.lineTo(cx + s, cy - 10); break; + case 'down': + ctx.moveTo(cx, cy + 20); ctx.lineTo(cx - s, cy + 10); ctx.lineTo(cx + s, cy + 10); break; + case 'left': + ctx.moveTo(cx - 20, cy); ctx.lineTo(cx - 10, cy - s); ctx.lineTo(cx - 10, cy + s); break; + case 'right': + ctx.moveTo(cx + 20, cy); ctx.lineTo(cx + 10, cy - s); ctx.lineTo(cx + 10, cy + s); break; + } + ctx.closePath(); + ctx.fill(); +} + +function renderLongShort(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, isLong: boolean) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), entry = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), target = m.priceToY(d.points[1].price); + if (x0===null||entry===null||x1===null||target===null) return; + const lx = Math.min(x0, x1), rx = Math.max(x0, x1); + // 손절선: entry에서 target 반대방향으로 같은 거리의 1/3 + const diff = target - entry; + const stop = entry - diff * 0.33; + const profitColor = isLong ? 'rgba(76,175,80,0.25)' : 'rgba(239,83,80,0.25)'; + const lossColor = isLong ? 'rgba(239,83,80,0.18)' : 'rgba(76,175,80,0.18)'; + const lineColor = isLong ? '#4CAF50' : '#EF5350'; + + // 수익 구간 + ctx.fillStyle = profitColor; + ctx.fillRect(lx, Math.min(entry, target), rx - lx, Math.abs(target - entry)); + // 손실 구간 + ctx.fillStyle = lossColor; + ctx.fillRect(lx, Math.min(entry, stop), rx - lx, Math.abs(stop - entry)); + + // 진입선 + ctx.strokeStyle = lineColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(lx, entry); ctx.lineTo(rx, entry); ctx.stroke(); + // 타겟선 + ctx.strokeStyle = isLong ? '#4CAF50' : '#EF5350'; + ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(lx, target); ctx.lineTo(rx, target); ctx.stroke(); + // 손절선 + ctx.strokeStyle = isLong ? '#EF5350' : '#4CAF50'; + ctx.beginPath(); ctx.moveTo(lx, stop); ctx.lineTo(rx, stop); ctx.stroke(); + ctx.setLineDash([]); + + // 레이블 + const tag = (label: string, y: number, fg: string) => { + ctx.font = 'bold 11px sans-serif'; + const tw = ctx.measureText(label).width + 8; + ctx.fillStyle = fg; + ctx.fillRect(rx - tw - 2, y - 9, tw, 16); + ctx.fillStyle = '#fff'; + ctx.fillText(label, rx - tw, y + 4); + }; + tag(isLong ? '목표' : '손절', target, isLong ? '#4CAF50' : '#EF5350'); + tag('진입', entry, '#607D8B'); + tag(isLong ? '손절' : '목표', stop, isLong ? '#EF5350' : '#4CAF50'); +} + +// ── 신규 렌더 함수들 ──────────────────────────────────────────────────────── + +function renderCrossLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cssH); ctx.stroke(); +} + +function renderHlineRay(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(cssW, y); ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +function renderArrowLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const dx = x1 - x0, dy = y1 - y0; + let ex = x1, ey = y1; + if (Math.abs(dx) > 0.001) { + const t = (cssW - x0) / dx; + if (t > 1) { ex = cssW; ey = y0 + dy * t; } + } + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(ex, ey); ctx.stroke(); + const angle = Math.atan2(ey - y0, ex - x0); + const hs = 12; + ctx.fillStyle = d.color; ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - hs * Math.cos(angle - 0.45), ey - hs * Math.sin(angle - 0.45)); + ctx.lineTo(ex - hs * Math.cos(angle + 0.45), ey - hs * Math.sin(angle + 0.45)); + ctx.closePath(); ctx.fill(); +} + +function renderTrendAngle(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 2) return; + renderLineOrRay(ctx, d, m, 'both', cssW, cssH); + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const lineAngle = Math.atan2(y1 - y0, x1 - x0); + const angleDeg = Math.round(-lineAngle * 180 / Math.PI); + const arcR = 28; + ctx.strokeStyle = d.color; ctx.lineWidth = 1; ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(x0, y0, arcR, 0, lineAngle, lineAngle > 0); ctx.stroke(); + ctx.font = '11px sans-serif'; ctx.fillStyle = d.color; + ctx.fillText(`${angleDeg}°`, x0 + arcR + 4, y0 - 4); +} + +function renderInfoLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const y = m.priceToY(d.points[0].price); + if (y === null) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); + const price = d.points[0].price; + const label = price >= 1000 ? price.toLocaleString('ko-KR') : price.toFixed(4); + ctx.font = 'bold 10px sans-serif'; + const tw = ctx.measureText(label).width + 8; + ctx.fillStyle = d.color; + ctx.fillRect(cssW - tw - 4, y - 9, tw, 18); + ctx.fillStyle = '#000'; + ctx.fillText(label, cssW - tw, y + 4); +} + +function renderParallelChannel3(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const dx = x1 - x0, dy = y1 - y0; + const len = Math.hypot(dx, dy); + let offX = 0, offY = 0; + if (d.points.length >= 3 && len > 0.001) { + const x2 = m.timeToX(d.points[2].time), y2 = m.priceToY(d.points[2].price); + if (x2 !== null && y2 !== null) { + const nx = -dy / len, ny = dx / len; + const t = (x2 - x0) * nx + (y2 - y0) * ny; + offX = t * nx; offY = t * ny; + } + } + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + const T = (cssW + cssH) * 3; + const drawExt = (ox: number, oy: number, ls: string) => { + if (len < 0.001) return; + const t2 = T / len; + ctx.setLineDash(ls === 'dashed' ? [6, 4] : []); + ctx.beginPath(); ctx.moveTo(ox - dx * t2, oy - dy * t2); ctx.lineTo(ox + dx * t2, oy + dy * t2); ctx.stroke(); + }; + drawExt(x0, y0, d.style ?? 'solid'); + if (offX !== 0 || offY !== 0) { + drawExt(x0 + offX, y0 + offY, 'dashed'); + const t2 = T / Math.max(len, 1); + ctx.globalAlpha = 0.08; ctx.fillStyle = d.color; + ctx.beginPath(); + ctx.moveTo(x0 - dx * t2, y0 - dy * t2); ctx.lineTo(x0 + dx * t2, y0 + dy * t2); + ctx.lineTo(x0 + offX + dx * t2, y0 + offY + dy * t2); ctx.lineTo(x0 + offX - dx * t2, y0 + offY - dy * t2); + ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; + } + ctx.setLineDash([]); +} + +function renderFibExt(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (d.points.length < 2) return; + const range = d.points[1].price - d.points[0].price; + for (let i = 0; i < FIB_EXT_LEVELS.length; i++) { + const price = d.points[1].price + range * FIB_EXT_LEVELS[i]; + const y = m.priceToY(price); + if (y === null) continue; + ctx.strokeStyle = FIB_EXT_COLORS[i] ?? '#888'; + ctx.lineWidth = FIB_EXT_LEVELS[i] === 0 || FIB_EXT_LEVELS[i] === 1.0 ? 1.5 : 0.8; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); + ctx.setLineDash([]); + ctx.font = '9px monospace'; ctx.fillStyle = FIB_EXT_COLORS[i] ?? '#888'; + ctx.fillText(`${(FIB_EXT_LEVELS[i] * 100).toFixed(1)}%`, 4, y - 2); + } +} + +function renderFibChannel(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const dx = x1 - x0, dy = y1 - y0; + const len = Math.hypot(dx, dy); + if (len < 0.001) return; + const nx = -dy / len, ny = dx / len; + const height = dy; + const FIB_CH = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.618]; + const T = (cssW + cssH) * 2 / Math.max(len, 1); + for (let i = 0; i < FIB_CH.length; i++) { + const off = height * FIB_CH[i]; + const ox = nx * off, oy = ny * off; + ctx.strokeStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)]; + ctx.lineWidth = i === 0 || i === 6 ? 1.5 : 0.8; + ctx.setLineDash(i === 0 ? [] : [3, 3]); + ctx.beginPath(); + ctx.moveTo(x0 + ox - dx * T, y0 + oy - dy * T); + ctx.lineTo(x0 + ox + dx * T, y0 + oy + dy * T); + ctx.stroke(); ctx.setLineDash([]); + ctx.font = '9px monospace'; ctx.fillStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)]; + ctx.fillText(`${(FIB_CH[i] * 100).toFixed(1)}%`, x0 + ox + 4, y0 + oy - 2); + } +} + +function renderFibSpeedFan(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const dx = x1 - x0, dy = y1 - y0; + const FIB_FAN = [0.25, 0.333, 0.5, 0.618, 1.0]; + const T = Math.max(cssW, cssH) * 3 / Math.max(Math.abs(dx), 1); + ctx.setLineDash([]); + FIB_FAN.forEach((r, i) => { + ctx.strokeStyle = FIB_COLORS[Math.min(i * 2, FIB_COLORS.length - 1)]; + ctx.lineWidth = r === 1.0 ? 1.5 : 0.8; + ctx.beginPath(); ctx.moveTo(x0, y0); + ctx.lineTo(x0 + dx * T, y0 + dy * r * T); + ctx.stroke(); + ctx.font = '9px monospace'; ctx.fillStyle = FIB_COLORS[Math.min(i * 2, FIB_COLORS.length - 1)]; + ctx.fillText(`${Math.round(r * 100)}%`, x0 + dx * T - 24, y0 + dy * r * T); + }); +} + +function renderGannFan(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const tdx = Math.abs(x1 - x0); + const tdy = Math.abs(y1 - y0); + if (tdx < 1 || tdy < 1) return; + const baseRatio = tdy / tdx; + const dirX = x1 > x0 ? 1 : -1; + const dirY = y1 > y0 ? 1 : -1; + const T = Math.max(cssW, cssH) * 2; + ctx.setLineDash([]); + GANN_RATIOS.forEach((r, i) => { + ctx.strokeStyle = GANN_COLORS_ARR[i]; ctx.lineWidth = r === 1 ? 1.8 : 0.8; + const ex = x0 + dirX * T; + const ey = y0 + dirY * T * r * baseRatio; + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(ex, ey); ctx.stroke(); + ctx.font = '9px monospace'; ctx.fillStyle = GANN_COLORS_ARR[i]; + ctx.fillText(GANN_LABELS[i], ex - 26, ey); + }); +} + +function renderGannBox(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const lx = Math.min(x0, x1), rx = Math.max(x0, x1); + const ty = Math.min(y0, y1), by = Math.max(y0, y1); + const w = rx - lx, h = by - ty; + ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; + ctx.fillStyle = d.color + '11'; ctx.fillRect(lx, ty, w, h); + ctx.setLineDash([]); ctx.strokeRect(lx, ty, w, h); + ctx.setLineDash([2, 3]); ctx.globalAlpha = 0.4; + ctx.beginPath(); ctx.moveTo(lx, ty); ctx.lineTo(rx, by); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(rx, ty); ctx.lineTo(lx, by); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(lx + w / 2, ty); ctx.lineTo(lx + w / 2, by); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(lx, ty + h / 2); ctx.lineTo(rx, ty + h / 2); ctx.stroke(); + for (const fx of [0.25, 0.75]) { + ctx.beginPath(); ctx.moveTo(lx + w * fx, ty); ctx.lineTo(lx + w * (1 - fx), by); ctx.stroke(); + } + ctx.setLineDash([]); ctx.globalAlpha = 1; +} + +function renderGannSquare(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const s = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); + const sx = x1 > x0 ? x0 : x0 - s; + const sy = y1 > y0 ? y0 : y0 - s; + ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; + ctx.fillStyle = d.color + '0A'; ctx.fillRect(sx, sy, s, s); ctx.strokeRect(sx, sy, s, s); + ctx.setLineDash([2, 3]); ctx.globalAlpha = 0.35; + for (let i = 1; i < 8; i++) { + const f = i / 8; + ctx.beginPath(); ctx.moveTo(sx + s * f, sy); ctx.lineTo(sx + s * (1 - f), sy + s); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(sx, sy + s * f); ctx.lineTo(sx + s, sy + s * (1 - f)); ctx.stroke(); + } + ctx.setLineDash([]); ctx.globalAlpha = 1; +} + +function renderForecast(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0 === null || y0 === null || x1 === null || y1 === null) return; + const lx = Math.min(x0, x1), rx = Math.max(x0, x1); + const isUp = y1 < y0; + const ty = Math.min(y0, y1), by = Math.max(y0, y1); + const fillColor = isUp ? 'rgba(76,175,80,0.14)' : 'rgba(239,83,80,0.14)'; + const borderColor = isUp ? '#4CAF50' : '#EF5350'; + ctx.fillStyle = fillColor; ctx.fillRect(lx, ty, rx - lx, by - ty); + ctx.strokeStyle = borderColor; ctx.lineWidth = 1.2; + ctx.setLineDash([5, 3]); ctx.strokeRect(lx, ty, rx - lx, by - ty); ctx.setLineDash([]); + ctx.strokeStyle = '#9E9E9E'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(lx, y0); ctx.lineTo(rx, y0); ctx.stroke(); + const pct = d.points[0].price > 0 + ? Math.abs((d.points[1].price - d.points[0].price) / d.points[0].price * 100).toFixed(2) + : '0'; + ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = borderColor; + ctx.fillText(`${isUp ? '▲' : '▼'} ${pct}%`, lx + 4, ty + 14); +} + +function formatDateRangeLabel(m: ChartManager, t0: number, t1: number): string { + const secs = Math.abs(t1 - t0); + const bars = m.countBarsBetweenTime(t0, t1); + const days = Math.floor(secs / 86400); + const hrs = Math.floor((secs % 86400) / 3600); + const mins = Math.floor((secs % 3600) / 60); + const timePart = days > 0 + ? `${days}일 ${hrs}시간` + : hrs > 0 + ? `${hrs}시간 ${mins}분` + : mins > 0 + ? `${mins}분` + : `${secs}초`; + return bars > 0 ? `${bars}봉, ${timePart}` : timePart; +} + +function renderDateRange(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time); + const x1 = m.timeToX(d.points[1].time); + if (x0 === null || x1 === null) return; + const lx = Math.min(x0, x1), rx = Math.max(x0, x1); + ctx.fillStyle = d.color + '1A'; ctx.fillRect(lx, 0, rx - lx, cssH); + ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(lx, 0); ctx.lineTo(lx, cssH); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, cssH); ctx.stroke(); + const label = formatDateRangeLabel(m, d.points[0].time, d.points[1].time); + ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = d.color; + const tw = ctx.measureText(label).width; + ctx.fillText(label, lx + (rx - lx) / 2 - tw / 2, 16); +} + +/** 날짜&가격 범위 — 날짜 범위(date_range) + 가격 범위(measure) 결합 */ +function renderDatePriceRange(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number, theme: Theme) { + if (d.points.length < 2) { + const p = d.points[0]; if (!p) return; + const x = m.timeToX(p.time), y = m.priceToY(p.price); + if (x !== null && y !== null) { ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI*2); ctx.fillStyle = d.color; ctx.fill(); } + return; + } + const [p0, p1] = d.points; + const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price); + const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price); + if (x0===null||y0===null||x1===null||y1===null) return; + const lx = Math.min(x0,x1), rx = Math.max(x0,x1); + const ty = Math.min(y0,y1), by = Math.max(y0,y1); + const pct = (p1.price - p0.price) / p0.price * 100; + const isUp = pct >= 0; + const fillCol = isUp ? 'rgba(76,175,80,0.15)' : 'rgba(239,83,80,0.15)'; + const lineCol = isUp ? '#4CAF50' : '#EF5350'; + const textCol = isUp ? '#4CAF50' : '#EF5350'; + // 배경 사각형 + ctx.fillStyle = fillCol; + ctx.fillRect(lx, ty, rx - lx, by - ty); + // 테두리 + ctx.strokeStyle = lineCol; ctx.lineWidth = 1; ctx.setLineDash([]); + ctx.strokeRect(lx, ty, rx - lx, by - ty); + // 수직 경계선 (전체 높이) + ctx.setLineDash([4,3]); ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(lx, 0); ctx.lineTo(lx, cssH); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, cssH); ctx.stroke(); + ctx.setLineDash([]); + // ── 중앙 정보 배지 ── + const cx2 = (lx + rx) / 2; + const cy2 = (ty + by) / 2; + const secs = Math.abs(p1.time - p0.time); + const days = Math.floor(secs / 86400); + const hrs = Math.floor((secs % 86400) / 3600); + const dateLabel = days > 0 ? `${days}일 ${hrs}시간` : `${hrs}시간`; + const sign = pct >= 0 ? '+' : ''; + const priceLabel = `${sign}${pct.toFixed(2)}% Δ${Math.abs(p1.price - p0.price).toFixed(2)}`; + ctx.font = 'bold 12px sans-serif'; + const dw = ctx.measureText(dateLabel).width; + ctx.font = '11px monospace'; + const pw = ctx.measureText(priceLabel).width; + const bw2 = Math.max(dw, pw) + 16; + // 배지 배경 + ctx.fillStyle = isUp ? 'rgba(76,175,80,0.85)' : 'rgba(239,83,80,0.85)'; + if (ctx.roundRect) ctx.roundRect(cx2 - bw2/2, cy2 - 20, bw2, 40, 5); + else ctx.rect(cx2 - bw2/2, cy2 - 20, bw2, 40); + ctx.fill(); + // 날짜 텍스트 + ctx.fillStyle = '#fff'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText(dateLabel, cx2, cy2 - 4); + // 가격 텍스트 + ctx.font = '11px monospace'; + ctx.fillStyle = theme === 'dark' ? '#FFFDE7' : '#fff'; + ctx.fillText(priceLabel, cx2, cy2 + 12); + ctx.textAlign = 'left'; +} + +/** 데이터 창 — 클릭 지점의 가격 정보 창 */ +function renderDataWindow(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x===null||y===null) return; + const price = d.points[0].price; + const ts = d.points[0].time; + const rows: [string, string][] = [ + ['시간', formatUnixDateTime(ts)], + ['가격', price.toFixed(2)], + ]; + const rowH = 20, pad = 10, labelW = 44; + ctx.font = '11px monospace'; + const valueW = Math.max(...rows.map(([,v]) => ctx.measureText(v).width)); + const bw = labelW + valueW + pad * 2 + 8; + const bh = rowH * rows.length + pad * 2 + 24; // +24 for header + const bx = x + 8, by = y - bh / 2; + // 배경 + ctx.fillStyle = '#1E2A35'; ctx.strokeStyle = d.color; ctx.lineWidth = 1; ctx.setLineDash([]); + if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5); + else ctx.rect(bx, by, bw, bh); + ctx.fill(); ctx.stroke(); + // 헤더 + ctx.fillStyle = d.color; + if (ctx.roundRect) ctx.roundRect(bx, by, bw, 22, [5, 5, 0, 0]); + else ctx.rect(bx, by, bw, 22); + ctx.fill(); + ctx.fillStyle = '#fff'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; + ctx.fillText('데이터 창', bx + bw / 2, by + 14); + ctx.textAlign = 'left'; + // 행 + rows.forEach(([label, value], i) => { + const ry = by + 22 + pad + i * rowH; + // 구분선 + if (i > 0) { + ctx.strokeStyle = '#2E3D4D'; ctx.lineWidth = 0.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(bx + 4, ry - rowH/2 + 2); ctx.lineTo(bx + bw - 4, ry - rowH/2 + 2); ctx.stroke(); + } + ctx.fillStyle = '#78909C'; ctx.font = '10px monospace'; + ctx.fillText(label, bx + pad, ry + 4); + ctx.fillStyle = '#ECEFF1'; ctx.font = '11px monospace'; + ctx.fillText(value, bx + pad + labelW, ry + 4); + }); + // 앵커 점 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI*2); + ctx.fillStyle = d.color; ctx.fill(); + ctx.strokeStyle = d.color; ctx.lineWidth = 0.8; ctx.setLineDash([2,2]); + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(bx, by + bh/2); ctx.stroke(); + ctx.setLineDash([]); +} + +/** 돋보기 — 2점으로 렌즈 크기 지정, 원형 돋보기 아이콘 표시 */ +function renderMagnifier(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price); + if (cx === null || cy === null) return; + let r = 40; + if (d.points.length >= 2) { + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x1 !== null && y1 !== null) r = Math.max(20, Math.hypot(x1 - cx, y1 - cy)); + } + // 렌즈 원형 채우기 + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = d.color + '18'; ctx.fill(); + ctx.strokeStyle = d.color; ctx.lineWidth = 2; ctx.setLineDash([]); + ctx.stroke(); + // 내부 십자선 + ctx.strokeStyle = d.color + '60'; ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(cx - r * 0.7, cy); ctx.lineTo(cx + r * 0.7, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx, cy - r * 0.7); ctx.lineTo(cx, cy + r * 0.7); ctx.stroke(); + // 내부 동심원 (3개) + [0.33, 0.55, 0.78].forEach(ratio => { + ctx.beginPath(); ctx.arc(cx, cy, r * ratio, 0, Math.PI * 2); + ctx.strokeStyle = d.color + '30'; ctx.lineWidth = 0.5; + ctx.stroke(); + }); + // 손잡이 (우하단 45도) + const hx1 = cx + r * 0.72, hy1 = cy + r * 0.72; + const hx2 = cx + r * 1.3, hy2 = cy + r * 1.3; + ctx.strokeStyle = d.color; ctx.lineWidth = 4; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(hx1, hy1); ctx.lineTo(hx2, hy2); ctx.stroke(); + ctx.lineCap = 'butt'; + // 중앙 + 표시 + ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(cx - 5, cy); ctx.lineTo(cx + 5, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx, cy - 5); ctx.lineTo(cx, cy + 5); ctx.stroke(); +} + +// ── 텍스트 & 노트 고유 렌더러 ───────────────────────────────────────────────── + +/** 노트 (고정위치문자와 달리 노트패드 스타일 박스) */ +function renderNoteText(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + const text = d.text ?? '노트'; + ctx.font = '12px sans-serif'; + const lines = text.split('\n'); + const lh = 16, pad = 8; + const tw = Math.max(...lines.map(l => ctx.measureText(l).width)); + const bw = Math.max(tw + pad * 2, 80), bh = lines.length * lh + pad * 2; + // 노트패드 배경 (연노랑) + ctx.fillStyle = '#FFFDE7'; + ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]); + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, 4); + else { ctx.rect(x, y, bw, bh); } + ctx.fill(); ctx.stroke(); + // 줄선 (노트패드 효과) + ctx.strokeStyle = '#F9A825'; ctx.lineWidth = 0.5; + for (let i = 1; i <= lines.length; i++) { + const ly = y + pad + i * lh - lh * 0.3; + if (ly < y + bh - 4) { + ctx.beginPath(); ctx.moveTo(x + pad, ly); ctx.lineTo(x + bw - pad, ly); ctx.stroke(); + } + } + // 텍스트 + ctx.fillStyle = '#37474F'; ctx.font = '12px sans-serif'; + lines.forEach((line, i) => ctx.fillText(line, x + pad, y + pad + (i + 1) * lh - 3)); + // 핀 앵커 + ctx.beginPath(); ctx.arc(x + 4, y + 4, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +/** 프라이스 노트 (가격 수준 라인과 연결된 라벨) */ +function renderPriceNote(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + const text = d.text ?? d.points[0].price.toFixed(2); + ctx.font = '11px monospace'; + const tw = ctx.measureText(text).width; + const bw = tw + 16, bh = 22; + // 박스 + ctx.fillStyle = d.color + 'DD'; ctx.strokeStyle = d.color; ctx.lineWidth = 1; ctx.setLineDash([]); + ctx.fillRect(x, y - bh / 2, bw, bh); + ctx.strokeRect(x, y - bh / 2, bw, bh); + // 우측으로 가격 수준선 + ctx.strokeStyle = d.color; ctx.lineWidth = 0.8; ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(x + bw, y); ctx.lineTo(cssW, y); ctx.stroke(); + ctx.setLineDash([]); + // 텍스트 + ctx.fillStyle = '#fff'; ctx.fillText(text, x + 8, y + 4); + // 앵커 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +/** 코멘트 (원형 말풍선 — callout의 사각 말풍선과 구별) */ +function renderCommentMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + const text = d.text ?? '코멘트'; + ctx.font = '12px sans-serif'; + const tw = ctx.measureText(text).width; + const pad = 10, r = 8; + const bw = Math.max(tw + pad * 2, 60), bh = 30; + const bx = x - bw / 2, by = y - bh - 10; + // 원형 모서리 말풍선 + ctx.fillStyle = '#37474F'; ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, r); + else { ctx.rect(bx, by, bw, bh); } + ctx.fill(); ctx.stroke(); + // 꼬리 + ctx.fillStyle = '#37474F'; ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x - 5, by + bh); ctx.lineTo(x, y); ctx.lineTo(x + 5, by + bh); + ctx.fill(); ctx.stroke(); + // 작은 원 3개 (채팅 아이콘 느낌) + ctx.fillStyle = '#90A4AE'; + [bx + bw * 0.3, bx + bw * 0.5, bx + bw * 0.7].forEach(cx2 => { + ctx.beginPath(); ctx.arc(cx2, by + bh / 2, 3, 0, Math.PI * 2); ctx.fill(); + }); + // 텍스트 + ctx.fillStyle = '#ECEFF1'; ctx.textAlign = 'center'; + ctx.fillText(text, x, by + bh / 2 + 4); + ctx.textAlign = 'left'; + // 앵커 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +/** 고정위치문자 (화면 고정 위치 텍스트 — 위치 앵커 원 표시) */ +function renderFixedText(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + const text = d.text ?? '고정 텍스트'; + ctx.font = '12px sans-serif'; + const tw = ctx.measureText(text).width; + // 텍스트 배경 + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(x + 10, y - 13, tw + 8, 18); + ctx.fillStyle = d.color; ctx.fillText(text, x + 14, y); + // 앵커 원 + 십자 + ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - 9, y); ctx.lineTo(x + 9, y); + ctx.moveTo(x, y - 9); ctx.lineTo(x, y + 9); + ctx.stroke(); +} + +/** 가격 라벨 (우측 가격축 라벨 스타일) */ +function renderPriceLabel(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + const text = d.text ?? d.points[0].price.toFixed(2); + ctx.font = '11px monospace'; + const tw = ctx.measureText(text).width; + const lbx = cssW - tw - 14; + // 수평 점선 연결 + ctx.strokeStyle = d.color; ctx.lineWidth = 0.8; ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(lbx - 4, y); ctx.stroke(); + ctx.setLineDash([]); + // 화살표 뾰족 라벨 박스 (가격축 스타일) + ctx.fillStyle = d.color; + ctx.beginPath(); + ctx.moveTo(lbx - 8, y); + ctx.lineTo(lbx, y - 9); + ctx.lineTo(cssW, y - 9); + ctx.lineTo(cssW, y + 9); + ctx.lineTo(lbx, y + 9); + ctx.closePath(); + ctx.fill(); + // 텍스트 + ctx.fillStyle = '#fff'; ctx.fillText(text, lbx + 2, y + 4); + // 앵커 + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +function renderCallout(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + const text = d.text ?? '텍스트'; + ctx.font = '12px sans-serif'; + const tw = ctx.measureText(text).width; + const bx = x - 8, bw = tw + 16, bh = 28, by = y - 44; + ctx.fillStyle = d.color + 'CC'; + ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]); + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5); else ctx.rect(bx, by, bw, bh); + ctx.fill(); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x - 6, by + bh); ctx.lineTo(x, y); ctx.lineTo(x + 6, by + bh); ctx.closePath(); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#000'; ctx.fillText(text, bx + 8, by + 18); +} + +function renderPinMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + ctx.fillStyle = d.color; ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(x, y - 14, 6, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.moveTo(x, y - 8); ctx.lineTo(x, y); ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; ctx.fill(); + ctx.fillStyle = d.color; ctx.strokeStyle = d.color; ctx.stroke(); +} + +function renderFlagMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 24); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y - 24); ctx.lineTo(x + 14, y - 20); ctx.lineTo(x, y - 16); + ctx.closePath(); ctx.fill(); +} + +function renderGuideLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const y = m.priceToY(d.points[0].price); + if (y === null) return; + ctx.strokeStyle = d.color + 'BB'; ctx.lineWidth = d.lineWidth ?? 1; + ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke(); + ctx.setLineDash([]); + const lbl = d.points[0].price.toFixed(2); + ctx.font = '10px monospace'; ctx.fillStyle = d.color + 'BB'; + ctx.fillText(lbl, 4, y - 3); +} + +function renderPitchSchiff(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 3) return; + const [p0, p1, p2] = d.points; + const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price); + const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price); + const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price); + if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return; + // Schiff: 핸들 시작점 = P0과 P1의 중간점 + const hsx = (x0 + x1) / 2, hsy = (y0 + y1) / 2; + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + const T = (cssW + cssH) * 2; + const drawExt = (sx: number, sy: number, ex: number, ey: number) => { + const ddx = ex - sx, ddy = ey - sy, ll = Math.hypot(ddx, ddy); + if (ll < 0.001) return; + ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + ddx * T / ll, sy + ddy * T / ll); ctx.stroke(); + }; + ctx.beginPath(); ctx.moveTo(hsx, hsy); ctx.lineTo(mx, my); ctx.stroke(); + drawExt(mx, my, mx + (mx - hsx), my + (my - hsy)); + const offX = x1 - mx, offY = y1 - my; + drawExt(hsx + offX, hsy + offY, x1, y1); + drawExt(hsx - offX, hsy - offY, x2, y2); + [[hsx, hsy], [x1, y1], [x2, y2]].forEach(([px, py]) => { + ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fillStyle = d.color; ctx.fill(); + }); +} + +function renderPitchInside(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) { + if (d.points.length < 3) return; + const [p0, p1, p2] = d.points; + const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price); + const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price); + const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price); + if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return; + // Inside: 핸들 = 중간점(P0,P1) → 중간점(P1,P2) + const hsx = (x0 + x1) / 2, hsy = (y0 + y1) / 2; + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + const T = (cssW + cssH) * 2; + const drawExt = (sx: number, sy: number, ex: number, ey: number) => { + const ddx = ex - sx, ddy = ey - sy, ll = Math.hypot(ddx, ddy); + if (ll < 0.001) return; + ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + ddx * T / ll, sy + ddy * T / ll); ctx.stroke(); + }; + ctx.beginPath(); ctx.moveTo(hsx, hsy); ctx.lineTo(mx, my); ctx.stroke(); + drawExt(mx, my, mx + (mx - hsx), my + (my - hsy)); + const offX = x1 - mx, offY = y1 - my; + drawExt(hsx + offX, hsy + offY, x1, y1); + drawExt(hsx - offX, hsy - offY, x2, y2); + [[hsx, hsy], [x1, y1], [x2, y2]].forEach(([px, py]) => { + ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fillStyle = d.color; ctx.fill(); + }); +} + +function renderEllipse(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price); + const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price); + if (cx===null||cy===null||ex===null||ey===null) return; + const rx = Math.abs(ex - cx), ry = Math.abs(ey - cy); + if (rx < 1 || ry < 1) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + ctx.fillStyle = d.color + '22'; ctx.fill(); ctx.stroke(); +} + +// ── 피보나치 원형 계열 ──────────────────────────────────────────────────────── + +function renderFibCircles(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price); + const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price); + if (cx===null||cy===null||ex===null||ey===null) return; + const baseR = Math.hypot(ex - cx, ey - cy); + const levels = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.618]; + levels.forEach((lv, i) => { + const r = baseR * lv; + ctx.strokeStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)]; + ctx.lineWidth = lv === 1.0 ? 1.5 : 0.8; + ctx.setLineDash(lv === 1.0 ? [] : [3, 3]); + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + ctx.font = '9px monospace'; + ctx.fillStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)]; + ctx.fillText(`${(lv * 100).toFixed(1)}%`, cx + r + 2, cy - 2); + }); + // 중심점 + ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +function renderFibSpiral(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price); + const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price); + if (cx===null||cy===null||ex===null||ey===null) return; + const baseR = Math.hypot(ex - cx, ey - cy); + const startAngle = Math.atan2(ey - cy, ex - cx); + // 황금 비율 나선: r = baseR * e^(b*θ), b = ln(φ)/(π/2) + const PHI = 1.6180339887; + const b = Math.log(PHI) / (Math.PI / 2); + const totalAngle = Math.PI * 3; // 1.5 바퀴 + const steps = 300; + ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const theta = -totalAngle * (1 - i / steps); + const r = baseR * Math.exp(b * theta); + const x = cx + r * Math.cos(startAngle + theta); + const y = cy + r * Math.sin(startAngle + theta); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + // 외곽원 (기준) + ctx.strokeStyle = d.color + '44'; ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(cx, cy, baseR, 0, Math.PI * 2); ctx.stroke(); + // 중심점 + ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); +} + +function renderFibSpeedArc(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return; + const baseLen = Math.hypot(x1 - x0, y1 - y0); + const baseAngle = Math.atan2(y1 - y0, x1 - x0); + // P0을 중심으로, 각 피보나치 거리에 반원호 그리기 + const arcLevels = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0]; + arcLevels.forEach((lv, i) => { + const r = baseLen * lv; + ctx.strokeStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)]; + ctx.lineWidth = lv === 1.0 ? 1.5 : 0.8; + ctx.setLineDash(lv === 1.0 ? [] : [3, 2]); + // 기준선 방향 기준으로 ±90° 범위 호 + ctx.beginPath(); + ctx.arc(x0, y0, r, baseAngle - Math.PI / 2, baseAngle + Math.PI / 2, false); + ctx.stroke(); + ctx.setLineDash([]); + ctx.font = '9px monospace'; + ctx.fillStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)]; + const lx = x0 + r * Math.cos(baseAngle + Math.PI / 2) + 2; + const ly = y0 + r * Math.sin(baseAngle + Math.PI / 2); + ctx.fillText(`${(lv * 100).toFixed(1)}%`, lx, ly); + }); + // 기준선 + ctx.strokeStyle = d.color + '66'; ctx.lineWidth = 0.8; ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); + ctx.setLineDash([]); +} + +function renderArc3(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 3) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + const x2 = m.timeToX(d.points[2].time), y2 = m.priceToY(d.points[2].price); + if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return; + // 세 점을 지나는 원 중심 계산 + const D = 2 * (x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1)); + if (Math.abs(D) < 0.001) { + applyLineStyle(ctx, d.style); ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); + return; + } + const ux = ((x0*x0+y0*y0)*(y1-y2)+(x1*x1+y1*y1)*(y2-y0)+(x2*x2+y2*y2)*(y0-y1))/D; + const uy = ((x0*x0+y0*y0)*(x2-x1)+(x1*x1+y1*y1)*(x0-x2)+(x2*x2+y2*y2)*(x1-x0))/D; + const r = Math.hypot(x0 - ux, y0 - uy); + const ang0 = Math.atan2(y0 - uy, x0 - ux); + const ang2 = Math.atan2(y2 - uy, x2 - ux); + const cross = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0); + applyLineStyle(ctx, d.style); ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); ctx.arc(ux, uy, r, ang0, ang2, cross < 0); ctx.stroke(); +} + +// ── 도형 추가 렌더러 ──────────────────────────────────────────────────────────── + +function renderRotatedRect(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return; + const dx = x1 - x0, dy = y1 - y0; + const len = Math.hypot(dx, dy); + if (len < 0.001) return; + // 수직 방향 (단위 법선) + const nx = -dy / len, ny = dx / len; + let offW = len * 0.5; // 기본 폭: 한 변 길이의 절반 미리보기 + if (d.points.length >= 3) { + const x2 = m.timeToX(d.points[2].time), y2 = m.priceToY(d.points[2].price); + if (x2 !== null && y2 !== null) { + offW = (x2 - x0) * nx + (y2 - y0) * ny; + } + } + const corners: [number, number][] = [ + [x0, y0], + [x1, y1], + [x1 + nx * offW, y1 + ny * offW], + [x0 + nx * offW, y0 + ny * offW], + ]; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.fillStyle = d.color + '1A'; + ctx.beginPath(); + ctx.moveTo(corners[0][0], corners[0][1]); + corners.slice(1).forEach(([cx, cy]) => ctx.lineTo(cx, cy)); + ctx.closePath(); + ctx.fill(); ctx.stroke(); +} + +function renderParallelogram(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return; + // P3 = P0 + (P2 - P1) + let x2 = x1, y2 = y1; + let x3 = x0, y3 = y0; + if (d.points.length >= 3) { + const px2 = m.timeToX(d.points[2].time), py2 = m.priceToY(d.points[2].price); + if (px2 !== null && py2 !== null) { + x2 = px2; y2 = py2; + x3 = x0 + (x2 - x1); + y3 = y0 + (y2 - y1); + } + } + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.fillStyle = d.color + '1A'; + ctx.beginPath(); + ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); + ctx.closePath(); + ctx.fill(); ctx.stroke(); +} + +function renderPolyline(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.beginPath(); + for (let i = 0; i < d.points.length; i++) { + const x = m.timeToX(d.points[i].time), y = m.priceToY(d.points[i].price); + if (x === null || y === null) continue; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + // 각 꼭짓점 표시 + for (const pt of d.points) { + const x = m.timeToX(pt.time), y = m.priceToY(pt.price); + if (x !== null && y !== null) { + ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); + } + } +} + +// ── 패턴 & 프로젝션 렌더러 ──────────────────────────────────────────────────── + +// 각 패턴의 포인트 레이블 정의 +const PATTERN_LABELS: Record = { + xabcd: ['X', 'A', 'B', 'C', 'D'], + cypher: ['X', 'A', 'B', 'C', 'D'], + abcd: ['A', 'B', 'C', 'D'], + head_shoulders: ['L', 'LS', 'H', 'RS', 'R', 'NL1', 'NL2'], + wedge_pattern: ['1', '2', '3', '4', '5'], + three_drives: ['O', 'A', '1', 'B', '2', 'C'], + elliott_impulse: ['0', '1', '2', '3', '4', '5'], + elliott_correction: ['0', 'A', 'B', 'C'], + elliott_triangle_wave: ['0', 'A', 'B', 'C', 'D', 'E'], + elliott_double_combo: ['0', 'W', 'X', 'Y'], +}; + +// 패턴별 연결 방식 — undefined이면 순서대로 연결 +// head_shoulders는 네크라인(마지막 2점)을 별도로 그림 +const PATTERN_LINE_ORDER: Record = { + head_shoulders: [[0,1,2,3,4], [5,6]], // 패턴 + 넥라인 +}; + +function renderZigzagPattern(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (!d.points.length) return; + const labels = PATTERN_LABELS[d.type] ?? []; + const lineOrders = PATTERN_LINE_ORDER[d.type]; + + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; + ctx.lineWidth = d.lineWidth ?? 1.5; + ctx.setLineDash([]); + + // 픽셀 좌표 변환 + const pts = d.points.map(p => ({ + x: m.timeToX(p.time), + y: m.priceToY(p.price), + })); + + // 선 그리기 (연결 순서 정의 있으면 사용, 없으면 순서대로) + const drawSegments = (order: number[]) => { + let started = false; + ctx.beginPath(); + for (const idx of order) { + const pt = pts[idx]; + if (!pt || pt.x === null || pt.y === null) break; + if (!started) { ctx.moveTo(pt.x, pt.y); started = true; } + else ctx.lineTo(pt.x, pt.y); + } + ctx.stroke(); + }; + + if (lineOrders) { + lineOrders.forEach((order, i) => { + ctx.setLineDash(i > 0 ? [4, 3] : []); // 넥라인은 파선 + drawSegments(order); + }); + ctx.setLineDash([]); + } else { + drawSegments(pts.map((_, i) => i)); + } + + // 포인트 레이블 & 앵커 원 + pts.forEach((pt, i) => { + if (!pt || pt.x === null || pt.y === null) return; + const label = labels[i] ?? `${i}`; + + // 앵커 원 + ctx.beginPath(); ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2); + ctx.fillStyle = d.color; ctx.fill(); + + // 레이블 배경 + ctx.font = 'bold 10px sans-serif'; + const tw = ctx.measureText(label).width + 6; + const lx = pt.x - tw / 2; + const ly = pt.y - 18; + ctx.fillStyle = d.color + 'DD'; + ctx.fillRect(lx, ly, tw, 14); + + // 레이블 텍스트 + ctx.fillStyle = '#000'; + ctx.fillText(label, lx + 3, ly + 11); + }); +} + +function renderGhostFeed(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return; + const lx=Math.min(x0,x1), rx=Math.max(x0,x1), ty=Math.min(y0,y1), by=Math.max(y0,y1); + // 반투명 점선 박스 + ctx.fillStyle = d.color + '0D'; + ctx.fillRect(lx, ty, rx - lx, by - ty); + ctx.strokeStyle = d.color + '88'; + ctx.lineWidth = 1.2; + ctx.setLineDash([5, 4]); + ctx.strokeRect(lx, ty, rx - lx, by - ty); + ctx.setLineDash([]); + // 내부에 캔들 실루엣 3개 (ghost 효과) + const cw = (rx - lx) / 5; + for (let i = 1; i <= 3; i++) { + const cx = lx + i * (rx - lx) / 4; + const h = (by - ty) * (0.4 + Math.sin(i) * 0.2); + const cy = (ty + by) / 2; + ctx.fillStyle = d.color + '22'; + ctx.fillRect(cx - cw / 2, cy - h / 2, cw, h); + ctx.strokeStyle = d.color + '44'; + ctx.lineWidth = 0.8; + ctx.setLineDash([]); + ctx.strokeRect(cx - cw / 2, cy - h / 2, cw, h); + } +} + +function renderBarPattern(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return; + const lx=Math.min(x0,x1), rx=Math.max(x0,x1), ty=Math.min(y0,y1), by=Math.max(y0,y1); + ctx.fillStyle = d.color + '14'; + ctx.fillRect(lx, ty, rx - lx, by - ty); + ctx.strokeStyle = d.color; + ctx.lineWidth = 1.2; + ctx.setLineDash([]); + ctx.strokeRect(lx, ty, rx - lx, by - ty); + // 바 패턴 내부 구분선 (4등분) + ctx.strokeStyle = d.color + '44'; + ctx.lineWidth = 0.8; + ctx.setLineDash([2, 3]); + for (let i = 1; i <= 3; i++) { + const xx = lx + (rx - lx) * i / 4; + ctx.beginPath(); ctx.moveTo(xx, ty); ctx.lineTo(xx, by); ctx.stroke(); + } + ctx.setLineDash([]); + ctx.font = '10px monospace'; ctx.fillStyle = d.color + 'BB'; + ctx.fillText('봉패턴', lx + 4, ty + 14); +} + +function renderFixedVolumeProfile(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) { + if (d.points.length < 2) return; + const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price); + const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price); + if (x0===null||y0===null||x1===null||y1===null) return; + const lx=Math.min(x0,x1), rx=Math.max(x0,x1), ty=Math.min(y0,y1), by=Math.max(y0,y1); + const h = by - ty, w = rx - lx; + ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]); + ctx.strokeRect(lx, ty, w, h); + // 수평 볼륨 바 5개 + const rowH = h / 6; + const widths = [0.55, 0.8, 1.0, 0.7, 0.45].map(r => w * r * 0.6); + widths.forEach((bw, i) => { + const ry = ty + rowH * (i + 0.5) - rowH * 0.3; + ctx.fillStyle = d.color + '44'; + ctx.fillRect(lx + 1, ry, bw, rowH * 0.7); + }); + ctx.font = '9px monospace'; ctx.fillStyle = d.color + 'BB'; + ctx.fillText('볼륨 프로파일', lx + 2, ty + 12); +} + +function renderAnchoredVwap(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) { + if (!d.points[0]) return; + const x = m.timeToX(d.points[0].time); + const y = m.priceToY(d.points[0].price); + if (x === null || y === null) return; + applyLineStyle(ctx, d.style); + ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5; + // 앵커 시점부터 우측으로 + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(cssW, y); ctx.stroke(); + // 앵커 원 + ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fillStyle = d.color + 'CC'; ctx.fill(); + ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.stroke(); + // VWAP 라벨 + ctx.font = 'bold 10px monospace'; ctx.fillStyle = d.color; + ctx.fillText('VWAP', x + 8, y - 5); +} + +function renderShape(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number, theme: Theme) { + ctx.save(); + const eff = resolveAlias(d.type); + switch (eff) { + // ── 기본 선 ── + case 'hline': renderHLine(ctx, d, m, cssW); break; + case 'vline': renderVLine(ctx, d, m, cssH); break; + case 'trendline': renderLineOrRay(ctx, d, m, 'both', cssW, cssH); break; + case 'ray': renderLineOrRay(ctx, d, m, 'right', cssW, cssH); break; + // ── 신규 독립 선 ── + case 'cross_line': renderCrossLine(ctx, d, m, cssW, cssH); break; + case 'hline_ray': renderHlineRay(ctx, d, m, cssW); break; + case 'arrow': renderArrowLine(ctx, d, m, cssW); break; + case 'trend_angle': renderTrendAngle(ctx, d, m, cssW, cssH); break; + case 'info_line': renderInfoLine(ctx, d, m, cssW); break; + case 'guide_line': renderGuideLine(ctx, d, m, cssW); break; + // ── 피보나치 ── + case 'fib': renderFib(ctx, d, m, cssW); break; + case 'fib_ext': renderFibExt(ctx, d, m, cssW); break; + case 'fib_channel': renderFibChannel(ctx, d, m, cssW, cssH); break; + case 'fib_speed_fan': renderFibSpeedFan(ctx, d, m, cssW, cssH); break; + case 'fib_circles': renderFibCircles(ctx, d, m); break; + case 'fib_spiral': renderFibSpiral(ctx, d, m); break; + case 'fib_speed_arc': renderFibSpeedArc(ctx, d, m); break; + case 'fibtz': renderFibTZ(ctx, d, m, cssH); break; + // ── 갠 ── + case 'gann_fan': renderGannFan(ctx, d, m, cssW, cssH); break; + case 'gann_box': renderGannBox(ctx, d, m); break; + case 'gann_square': renderGannSquare(ctx, d, m); break; + // ── 채널 / 사각형 ── + case 'measure': renderMeasure(ctx, d, m, theme); break; + case 'date_range': renderDateRange(ctx, d, m, cssH); break; + case 'date_price_range': renderDatePriceRange(ctx, d, m, cssH, theme); break; + case 'data_window': renderDataWindow(ctx, d, m); break; + case 'magnifier': renderMagnifier(ctx, d, m); break; + case 'rect': renderRect(ctx, d, m); break; + case 'channel': renderChannel(ctx, d, m, cssW, cssH); break; + case 'parallel_channel': renderParallelChannel3(ctx, d, m, cssW, cssH); break; + case 'forecast': renderForecast(ctx, d, m); break; + // ── 피치포크 ── + case 'pitchfork': renderPitchfork(ctx, d, m, cssW, cssH); break; + case 'pitchfork_schiff': renderPitchSchiff(ctx, d, m, cssW, cssH); break; + case 'pitchfork_inside': renderPitchInside(ctx, d, m, cssW, cssH); break; + // ── 도형 ── + case 'circle': renderCircle(ctx, d, m); break; + case 'ellipse': renderEllipse(ctx, d, m); break; + case 'triangle': renderTriangle(ctx, d, m); break; + case 'arc': renderArc3(ctx, d, m); break; + case 'rotated_rect': renderRotatedRect(ctx, d, m); break; + case 'parallelogram': renderParallelogram(ctx, d, m); break; + case 'polyline': renderPolyline(ctx, d, m); break; + // ── 텍스트 / 마커 ── + case 'pencil': renderPencil(ctx, d, m); break; + case 'text': renderText(ctx, d, m); break; + case 'callout': renderCallout(ctx, d, m); break; + case 'pin_mark': renderPinMark(ctx, d, m); break; + case 'flag_mark': renderFlagMark(ctx, d, m); break; + // ── 텍스트 & 노트 고유 렌더러 ── + case 'note_text': renderNoteText(ctx, d, m); break; + case 'price_note': renderPriceNote(ctx, d, m, cssW); break; + case 'comment_mark': renderCommentMark(ctx, d, m); break; + case 'fixed_text': renderFixedText(ctx, d, m); break; + case 'price_label': renderPriceLabel(ctx, d, m, cssW); break; + // ── 화살표 마크 ── + case 'arrow_mark_up': renderArrowMark(ctx, d, m, 'up'); break; + case 'arrow_mark_down': renderArrowMark(ctx, d, m, 'down'); break; + case 'arrow_mark_left': renderArrowMark(ctx, d, m, 'left'); break; + case 'arrow_mark_right': renderArrowMark(ctx, d, m, 'right'); break; + // ── 포지션 ── + case 'long_position': renderLongShort(ctx, d, m, true); break; + case 'short_position': renderLongShort(ctx, d, m, false); break; + // ── 패턴 (하모닉/엘리엇) ── + case 'xabcd': + case 'cypher': + case 'abcd': + case 'head_shoulders': + case 'wedge_pattern': + case 'three_drives': + case 'elliott_impulse': + case 'elliott_correction': + case 'elliott_triangle_wave': + case 'elliott_double_combo': + renderZigzagPattern(ctx, d, m); break; + // ── 프로젝션 도구 ── + case 'ghost_feed': renderGhostFeed(ctx, d, m); break; + case 'bar_pattern': renderBarPattern(ctx, d, m); break; + case 'fixed_volume_profile': renderFixedVolumeProfile(ctx, d, m); break; + case 'anchored_vwap': renderAnchoredVwap(ctx, d, m, cssW); break; + } + ctx.restore(); +} + +// ─── Component ───────────────────────────────────────────────────────────── +interface Props { + manager: ChartManager; + activeTool: string; + drawings: Drawing[]; + onAddDrawing: (d: Drawing) => void; + onZoom: (x0: number, x1: number) => void; + theme: Theme; + enabled: boolean; // trading 모드 + visible: boolean; // eye 토글 + locked: boolean; // lock 토글 + /** 현재 선택된 드로잉 ID (핸들 표시) */ + selectedDrawingId?: string | null; +} + +const DrawingCanvas: React.FC = ({ + manager, activeTool, drawings, onAddDrawing, onZoom, + theme, enabled, visible, locked, + selectedDrawingId, +}) => { + const outerRef = useRef(null); + const canvasRef = useRef(null); + const cssSize = useRef({ w: 0, h: 0 }); + const animRef = useRef(0); + const pendingPts = useRef([]); + const mousePoint = useRef(null); + + // pencil 드래그 상태 + const isPencilDragging = useRef(false); + const pencilPts = useRef([]); + + // zoom 드래그 상태 + const isZoomDragging = useRef(false); + const zoomStartX = useRef(0); + const zoomCurrentX = useRef(0); + + const [textInput, setTextInput] = useState<{ x: number; y: number; point: DrawingPoint; toolType: string } | null>(null); + const [textVal, setTextVal] = useState(''); + + // ── Core redraw ────────────────────────────────────────────────────── + const redraw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const { w, h } = cssSize.current; + if (w === 0 || h === 0) return; + + const dpr = window.devicePixelRatio || 1; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // 숨김 모드: drawings 자체를 표시 안 함 + if (!visible) return; + + ctx.save(); + ctx.scale(dpr, dpr); + + // 확정된 드로잉 (visible === false 이면 숨김) + for (const d of drawings) { + if (d.visible === false) continue; + renderShape(ctx, d, manager, w, h, theme); + } + + // 선택된 드로잉: 앵커 핸들 표시 + if (selectedDrawingId) { + const sel = drawings.find(d => d.id === selectedDrawingId && d.visible !== false); + if (sel) { + // 선택 강조: 도형 위에 흰 외곽선 오버레이 + ctx.save(); + ctx.globalAlpha = 0.25; + ctx.strokeStyle = '#FFFFFF'; + ctx.lineWidth = (sel.lineWidth ?? 1) + 4; + ctx.setLineDash([]); + renderShape(ctx, { ...sel, style: 'solid' }, manager, w, h, theme); + ctx.restore(); + + // 앵커 포인트마다 흰 원형 핸들 + for (const pt of sel.points) { + const x = manager.timeToX(pt.time); + const y = manager.priceToY(pt.price); + if (x === null || y === null) continue; + ctx.beginPath(); + ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fillStyle = '#FFFFFF'; + ctx.strokeStyle = sel.color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.9; + ctx.fill(); + ctx.stroke(); + ctx.globalAlpha = 1; + } + } + } + + if (enabled && !locked) { + // pencil 실시간 경로 미리보기 + if (activeTool === 'pencil' && isPencilDragging.current && pencilPts.current.length >= 2) { + ctx.save(); + ctx.strokeStyle = TOOL_COLORS['pencil']; + ctx.lineWidth = 1.5; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.globalAlpha = 0.7; + ctx.beginPath(); + for (let i = 0; i < pencilPts.current.length; i++) { + const x = manager.timeToX(pencilPts.current[i].time); + const y = manager.priceToY(pencilPts.current[i].price); + if (x === null || y === null) continue; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + } + + // zoom 선택 영역 미리보기 + if (activeTool === 'zoom' && isZoomDragging.current) { + const lx = Math.min(zoomStartX.current, zoomCurrentX.current); + const rx = Math.max(zoomStartX.current, zoomCurrentX.current); + ctx.save(); + ctx.fillStyle = 'rgba(33,150,243,0.12)'; + ctx.fillRect(lx, 0, rx - lx, h); + ctx.strokeStyle = '#2196F3'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.strokeRect(lx, 0, rx - lx, h); + ctx.restore(); + } + + // 돋보기: 1점 찍은 뒤 두 번째 점 미리보기 + if (activeTool === 'magnifier' && pendingPts.current.length === 1 && mousePoint.current) { + const x0 = manager.timeToX(pendingPts.current[0].time); + const x1 = manager.timeToX(mousePoint.current.time); + if (x0 !== null && x1 !== null) { + const lx = Math.min(x0, x1); + const rx = Math.max(x0, x1); + ctx.save(); + ctx.fillStyle = 'rgba(128,222,234,0.12)'; + ctx.fillRect(lx, 0, rx - lx, h); + ctx.strokeStyle = TOOL_COLORS['magnifier'] ?? '#80DEEA'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.strokeRect(lx, 0, rx - lx, h); + ctx.restore(); + } + } + + // 일반 도구 미리보기 (cursor/pencil/zoom/magnifier 제외) + if (activeTool !== 'cursor' && activeTool !== 'pencil' && activeTool !== 'zoom' && activeTool !== 'magnifier' && mousePoint.current) { + const previewPts = [...pendingPts.current, mousePoint.current]; + if (previewPts.length > 0) { + const preview: Drawing = { + id: '__preview', + type: activeTool as DrawingToolId, + points: previewPts, + color: TOOL_COLORS[activeTool] ?? '#FFFFFF', + lineWidth: 1, style: 'dashed', + }; + ctx.globalAlpha = 0.65; + renderShape(ctx, preview, manager, w, h, theme); + ctx.globalAlpha = 1; + } + for (const pt of pendingPts.current) { + const x = manager.timeToX(pt.time), y = manager.priceToY(pt.price); + if (x !== null && y !== null) { + ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fillStyle = TOOL_COLORS[activeTool] ?? '#FFFFFF'; + ctx.fill(); + } + } + } + } + + ctx.restore(); + }, [drawings, activeTool, manager, theme, enabled, visible, locked, selectedDrawingId]); + + const scheduleRedraw = useCallback(() => { + cancelAnimationFrame(animRef.current); + animRef.current = requestAnimationFrame(redraw); + }, [redraw]); + + // ── Subscribe to LWC viewport changes ──────────────────────────────── + useEffect(() => { + const unsub = manager.subscribeViewport(scheduleRedraw); + return unsub; + }, [manager, scheduleRedraw]); + + useEffect(() => { scheduleRedraw(); }, [scheduleRedraw]); + + // ── Size canvas to match container ──────────────────────────────────── + useEffect(() => { + const outer = outerRef.current; + if (!outer) return; + + const applySize = (w: number, h: number) => { + const canvas = canvasRef.current; + if (!canvas || w === 0 || h === 0) return; + cssSize.current = { w, h }; + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + scheduleRedraw(); + }; + + const rect = outer.getBoundingClientRect(); + applySize(rect.width, rect.height); + + const ro = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + applySize(width, height); + }); + ro.observe(outer); + return () => ro.disconnect(); + }, [scheduleRedraw]); + + // Reset pending when tool changes + useEffect(() => { + pendingPts.current = []; + mousePoint.current = null; + isPencilDragging.current = false; + pencilPts.current = []; + isZoomDragging.current = false; + scheduleRedraw(); + }, [activeTool, scheduleRedraw]); + + // ── Coordinate helpers ─────────────────────────────────────────────── + const getPointFromXY = useCallback((clientX: number, clientY: number): DrawingPoint | null => { + const outer = outerRef.current; + if (!outer) return null; + const rect = outer.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const time = manager.xToTime(x); + const price = manager.yToPrice(y); + return (time !== null && price !== null) ? { time, price } : null; + }, [manager]); + + const getPoint = useCallback((e: React.PointerEvent | React.MouseEvent): DrawingPoint | null => { + return getPointFromXY(e.clientX, e.clientY); + }, [getPointFromXY]); + + const getLocalX = useCallback((e: React.PointerEvent): number => { + const outer = outerRef.current; + if (!outer) return 0; + return e.clientX - outer.getBoundingClientRect().left; + }, []); + + // ── Pointer handlers (마우스·터치) ─────────────────────────────────── + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if ((activeTool === 'pencil' || activeTool === 'highlight') && isPencilDragging.current) { + const pt = getPoint(e); + if (pt) { pencilPts.current.push(pt); scheduleRedraw(); } + return; + } + if (activeTool === 'zoom' && isZoomDragging.current) { + zoomCurrentX.current = getLocalX(e); + scheduleRedraw(); + return; + } + const pt = getPoint(e); + if (!pt) return; + mousePoint.current = pt; + scheduleRedraw(); + }, [activeTool, getPoint, getLocalX, scheduleRedraw]); + + const handlePointerLeave = useCallback(() => { + mousePoint.current = null; + scheduleRedraw(); + }, [scheduleRedraw]); + + const handlePointerCancel = useCallback(() => { + mousePoint.current = null; + if (isPencilDragging.current) { + isPencilDragging.current = false; + pencilPts.current = []; + } + if (isZoomDragging.current) { + isZoomDragging.current = false; + } + scheduleRedraw(); + }, [scheduleRedraw]); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + if (!enabled || locked) return; + if (activeTool === 'pencil' || activeTool === 'highlight') { + isPencilDragging.current = true; + pencilPts.current = []; + const pt = getPoint(e); + if (pt) pencilPts.current.push(pt); + } + if (activeTool === 'zoom') { + isZoomDragging.current = true; + const x = getLocalX(e); + zoomStartX.current = x; + zoomCurrentX.current = x; + } + }, [enabled, locked, activeTool, getPoint, getLocalX]); + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + if ((activeTool === 'pencil' || activeTool === 'highlight') && isPencilDragging.current) { + isPencilDragging.current = false; + if (pencilPts.current.length >= 3) { + const pts = pencilPts.current; + const step = Math.max(1, Math.floor(pts.length / 200)); + const sampled: DrawingPoint[] = []; + for (let i = 0; i < pts.length; i += step) sampled.push(pts[i]); + if (sampled[sampled.length - 1] !== pts[pts.length - 1]) sampled.push(pts[pts.length - 1]); + + onAddDrawing({ + id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type: activeTool as DrawingToolId, + points: sampled, + color: activeTool === 'highlight' ? '#FFEB3B' : TOOL_COLORS['pencil'], + lineWidth: activeTool === 'highlight' ? 12 : 1.5, + style: 'solid', + }); + } + pencilPts.current = []; + scheduleRedraw(); + return; + } + if (activeTool === 'zoom' && isZoomDragging.current) { + isZoomDragging.current = false; + const x0 = zoomStartX.current; + const x1 = getLocalX(e); + scheduleRedraw(); + if (Math.abs(x1 - x0) > 10) { + onZoom(x0, x1); + } + } + }, [activeTool, onAddDrawing, onZoom, getLocalX, scheduleRedraw]); + + const handleClick = useCallback((e: React.MouseEvent) => { + if (!enabled || locked) return; + // drag 기반 툴과 커서는 click 처리 안 함 + if (['pencil','highlight','zoom','cursor'].includes(activeTool)) return; + // 스텁 툴은 드로잉 생성 안 함 (선택만) + if (STUB_TOOLS.has(activeTool)) return; + + const pt = getPoint(e); + if (!pt) return; + + // 텍스트 입력이 필요한 모든 툴 처리 + const TEXT_INPUT_TOOLS = new Set(['text', 'callout', 'fixed_text', 'note_text', 'comment_mark', 'table_tool', 'price_note', 'price_label']); + if (TEXT_INPUT_TOOLS.has(activeTool)) { + const outer = outerRef.current!.getBoundingClientRect(); + setTextInput({ x: e.clientX - outer.left, y: e.clientY - outer.top, point: pt, toolType: activeTool }); + setTextVal(''); + return; + } + + const newPts = [...pendingPts.current, pt]; + const needed = POINTS_NEEDED[activeTool] ?? 1; + + // 돋보기: 두 점으로 시간 구간 지정 → 메인 차트 확대 (TradingView 동작) + if (activeTool === 'magnifier' && newPts.length >= 2) { + const x0 = manager.timeToX(newPts[0].time); + const x1 = manager.timeToX(newPts[1].time); + pendingPts.current = []; + scheduleRedraw(); + if (x0 !== null && x1 !== null && Math.abs(x1 - x0) > 10) { + onZoom(x0, x1); + } + return; + } + + if (newPts.length >= needed) { + const d: Drawing = { + id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type: activeTool as DrawingToolId, + points: newPts.slice(0, needed), + color: TOOL_COLORS[activeTool] ?? '#FFFFFF', + lineWidth: 1, + style: 'solid', + }; + onAddDrawing(d); + pendingPts.current = []; + } else { + pendingPts.current = newPts; + } + scheduleRedraw(); + }, [enabled, locked, activeTool, getPoint, onAddDrawing, onZoom, manager, scheduleRedraw]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + if (!enabled || locked) return; + if (activeTool !== 'polyline') return; + // dblclick 직전 두 번의 click 이벤트가 점을 2개 추가함 + // 그 중 마지막 1개(dblclick 2번째 click)를 제거하고 확정 + const pts = pendingPts.current; + if (pts.length >= 2) { + pts.pop(); // 더블클릭의 2번째 click으로 추가된 중복 제거 + const finalPts = [...pts]; + pendingPts.current = []; + onAddDrawing({ + id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type: 'polyline' as DrawingToolId, + points: finalPts, + color: TOOL_COLORS['polyline'] ?? '#FF9800', + lineWidth: 1.5, + style: 'solid', + }); + scheduleRedraw(); + } + }, [enabled, locked, activeTool, onAddDrawing, scheduleRedraw]); + + const commitText = useCallback(() => { + if (!textInput) return; + if (textVal.trim()) { + const toolType = textInput.toolType ?? 'text'; + onAddDrawing({ + id: `d_${Date.now()}`, + type: toolType as DrawingToolId, + points: [textInput.point], + color: TOOL_COLORS[toolType] ?? TOOL_COLORS['text'] ?? '#FFEB3B', + lineWidth: 1, style: 'solid', + text: textVal.trim(), + }); + } + setTextInput(null); + }, [textInput, textVal, onAddDrawing]); + + const isInteractive = enabled && !locked && activeTool !== 'cursor'; + + // zoom 드래그 중에는 cursor 변경 + const getCursor = () => { + if (!isInteractive) return 'default'; + if (activeTool === 'zoom') return isZoomDragging.current ? 'ew-resize' : 'zoom-in'; + if (activeTool === 'magnifier') return 'zoom-in'; + if (activeTool === 'pencil') return 'crosshair'; + return 'crosshair'; + }; + + return ( +
+ + + {textInput && ( + setTextVal(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') commitText(); if (e.key === 'Escape') setTextInput(null); }} + onBlur={commitText} + placeholder="텍스트 입력…" + /> + )} +
+ ); +}; + +export default DrawingCanvas; diff --git a/frontend/src/components/DrawingContextToolbar.tsx b/frontend/src/components/DrawingContextToolbar.tsx new file mode 100644 index 0000000..89efcc7 --- /dev/null +++ b/frontend/src/components/DrawingContextToolbar.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef } from 'react'; +import type { Drawing } from '../types'; + +// 도구별 한국어 이름 +const DRAWING_LABELS: Record = { + hline: '수평선', + vline: '수직선', + trendline: '추세선', + ray: '레이', + fib: '피보나치', + fibtz: '피보나치 타임존', + measure: '가격 측정', + rect: '사각형', + channel: '채널', + pencil: '자유 드로잉', + text: '텍스트', +}; + +/* ── SVG Icons (IndicatorContextToolbar 와 동일 스타일) ── */ +const IcEye = ({ visible }: { visible: boolean }) => visible ? ( + + + + +) : ( + + + + +); + +const IcGear = () => ( + + + + +); + +const IcTrash = () => ( + + + + + +); + +interface DrawingContextToolbarProps { + drawing: Drawing; + screenX: number; + screenY: number; + onSettings: () => void; + onToggleVisible: () => void; + onRemove: () => void; + onClose: () => void; +} + +const DrawingContextToolbar: React.FC = ({ + drawing, screenX, screenY, + onSettings, onToggleVisible, onRemove, onClose, +}) => { + const ref = useRef(null); + + // 외부 클릭 시 닫기 + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + }; + const tid = setTimeout(() => document.addEventListener('mousedown', handler), 100); + return () => { clearTimeout(tid); document.removeEventListener('mousedown', handler); }; + }, [onClose]); + + const W = 220; + let left = screenX + 8; + let top = screenY + 8; + if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8; + if (top + 50 > window.innerHeight - 8) top = screenY - 58; + + const label = DRAWING_LABELS[drawing.type] ?? drawing.type; + const isHidden = drawing.visible === false; + + return ( +
e.stopPropagation()} + > + {/* 색상 닷 + 이름 */} + + + {label} + + + {/* 가시성 토글 */} + + + {/* 설정 */} + + + {/* 삭제 */} + +
+ ); +}; + +export default DrawingContextToolbar; diff --git a/frontend/src/components/DrawingSettingsModal.tsx b/frontend/src/components/DrawingSettingsModal.tsx new file mode 100644 index 0000000..e41dc20 --- /dev/null +++ b/frontend/src/components/DrawingSettingsModal.tsx @@ -0,0 +1,228 @@ +import React, { useState } from 'react'; +import type { Drawing, DrawingStyle } from '../types'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +const PRESET_COLORS = [ + '#FF6D00','#FF5722','#EF5350','#E91E63','#9C27B0', + '#673AB7','#3F51B5','#2196F3','#03A9F4','#00BCD4', + '#009688','#4CAF50','#8BC34A','#CDDC39','#FFC107', + '#FF9800','#795548','#9E9E9E','#607D8B','#FFFFFF', +]; + +const LINE_WIDTHS = [1, 2, 3, 4] as const; +const LINE_STYLES: { value: DrawingStyle; label: string }[] = [ + { value: 'solid', label: '실선' }, + { value: 'dashed', label: '파선' }, + { value: 'dotted', label: '점선' }, +]; + +interface Props { + drawing: Drawing; + onSave: (updated: Drawing) => void; + onCancel: () => void; +} + +const DrawingSettingsModal: React.FC = ({ drawing, onSave, onCancel }) => { + const [color, setColor] = useState(drawing.color); + const [lineWidth, setLineWidth] = useState(drawing.lineWidth); + const [style, setStyle] = useState(drawing.style); + const [text, setText] = useState(drawing.text ?? ''); + + const handleSave = () => { + onSave({ + ...drawing, + color, + lineWidth, + style, + ...(drawing.type === 'text' ? { text: text.trim() || drawing.text } : {}), + }); + }; + + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
e.stopPropagation()} + > +
+ 드로잉 설정 + +
+
+ + {/* 색상 */} +
+
색상
+
+ {PRESET_COLORS.map(c => ( +
+
+ + {/* 선 굵기 (pencil·text·measure 제외) */} + {drawing.type !== 'text' && drawing.type !== 'measure' && ( +
+
선 굵기
+
+ {LINE_WIDTHS.map(w => ( + + ))} +
+
+ )} + + {/* 선 스타일 (hline, vline, trendline, ray, channel 계열) */} + {!['text', 'measure', 'pencil', 'fib', 'rect'].includes(drawing.type) && ( +
+
선 스타일
+
+ {LINE_STYLES.map(({ value, label }) => ( + + ))} +
+
+ )} + + {/* 텍스트 (text 타입만) */} + {drawing.type === 'text' && ( +
+
텍스트
+ setText(e.target.value)} + style={{ + width: '100%', + boxSizing: 'border-box', + background: 'rgba(255,255,255,0.05)', + border: '1px solid var(--border, rgba(122,162,247,0.25))', + borderRadius: 4, + padding: '6px 8px', + color: 'inherit', + fontSize: 13, + }} + /> +
+ )} + + {/* 버튼 */} +
+ + +
+
+
+
+ ); +}; + +export default DrawingSettingsModal; diff --git a/frontend/src/components/DrawingToolbar.tsx b/frontend/src/components/DrawingToolbar.tsx new file mode 100644 index 0000000..b55343d --- /dev/null +++ b/frontend/src/components/DrawingToolbar.tsx @@ -0,0 +1,627 @@ +import React, { useState, useRef, useEffect } from 'react'; +import type { ChartMode } from '../types'; + +export interface DrawingToolbarProps { + activeTool: string; + mode: ChartMode; + onTool: (tool: string) => void; + onClearAll: () => void; + onUndo: () => void; + locked: boolean; + visible: boolean; + onToggleLock: () => void; + onToggleEye: () => void; + // 신규 확장 props (optional — App.tsx 점진 적용) + indicatorsVisible?: boolean; + magnetMode?: 'off' | 'weak' | 'strong'; + snapToIndicator?: boolean; + drawingCount?: number; + indicatorCount?: number; + onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void; + onSnapToIndicator?: (v: boolean) => void; + onHideDrawings?: () => void; + onHideIndicators?: () => void; + onHideAll?: () => void; + onClearIndicators?: () => void; + keepLockedOnDelete?: boolean; + onToggleKeepLocked?: () => void; +} + +// ─── SVG 헬퍼 ────────────────────────────────────────────────────────────── +const S = (p: React.SVGProps) => + ; + +// ─── 아이콘 ──────────────────────────────────────────────────────────────── +const IcCursor = () => ; +// 업비트 즐겨찾기 툴바 아이콘 +const IcUpbitHline = () => ; +const IcUpbitVline = () => ; +const IcUpbitPeriod = () => ; +const IcUpbitFibTz = () => ; +const IcFavorite = () => ; +// 선 그룹 +const IcTrendline = () => ; +const IcRay = () => ; +const IcInfoLine = () => i; +const IcExtLine = () => ; +const IcTrendAngle = () => θ; +const IcHline = () => ; +const IcHlineRay = () => ; +const IcVline = () => ; +const IcCrossLine = () => ; +const IcChannel = () => ; +const IcParaCh = () => ; +const IcDisjCh = () => ; +const IcFlatTop = () => ; +// 피보나치 그룹 +const IcFib = () => ; +const IcFibExt = () => ; +const IcFibCh = () => ; +const IcFibTZ = () => ; +const IcFibFan = () => ; +const IcFibTrendT = () => ; +const IcFibCircles = () => ; +const IcFibSpiral = () => ; +const IcFibArc = () => ; +const IcFibWedge = () => ; +const IcPitchfork = () => ; +const IcPitchSchiff = () => ; +const IcPitchInside = () => ; +const IcGannBox = () => ; +const IcGannSq = () => ; +const IcGannFan = () => ; +// 패턴 그룹 +const IcXABCD = () => ; +const IcCypher = () => ; +const IcABCD = () => ; +const IcHeadShould = () => ; +const IcWedgePat = () => ; +const IcThreeDrives = () => ; +const IcElliottImp = () => 1 2 3 4 5; +const IcElliottCorr = () => A B C; +const IcElliottTri = () => ABCDE; +const IcElliottDbl = () => W X Y; +// 프로젝션 그룹 +const IcLongPos = () => ; +const IcShortPos = () => ; +const IcForecast = () => ; +const IcBarPattern = () => ; +const IcGhostFeed = () => ; +const IcProjection = () => ; +const IcVWAP = () => ; +const IcVolProfile = () => ; +const IcPriceRange = () => ; +// 도형 그룹 +const IcRect = () => ; +const IcRotRect = () => ; +const IcCircle = () => ; +const IcEllipse = () => ; +const IcTriangle = () => ; +const IcParallelo = () => ; +const IcPolyline = () => ; +const IcArc = () => ; +// 브러시 그룹 +const IcPencil = () => ; +const IcHighlight = () => ; +const IcArrowMark = () => ; +const IcArrowAnch = () => ; +const IcArrowUp = () => ; +const IcArrowDown = () => ; +const IcArrowLeft = () => ; +const IcArrowRight = () => ; +// 텍스트 그룹 +const IcText = () => T; +const IcFixedText = () => T; +const IcNote = () => ; +const IcPriceNote = () => $; +const IcPin = () => ; +const IcTable = () => ; +const IcCallout = () => ; +const IcComment = () => ; +const IcPriceLabel = () => ; +const IcGuide = () => ; +const IcFlag = () => ; +// 측정 그룹 +const IcMeasure = () => ; +const IcDateRange = () => ; +const IcDatePriceR = () => ; +const IcDataWindow = () => ; +const IcZoom = () => ; +const IcMagnifier = () => ; +// 하단 버튼 아이콘 +const IcMagnetWeak = () => ; +const IcMagnetStrg = () => ; +const IcLock = () => ; +const IcEye = () => ; +const IcEyeOff = () => ; +const IcTrash = () => ; +const IcUndo = () => ; +const IcLayers = () => ; + +// ─── 타입 ────────────────────────────────────────────────────────────────── +type FlyoutItem = + | { kind: 'tool'; id: string; title: string; shortcut?: string; icon: React.ReactNode } + | { kind: 'sep'; label?: string }; + +interface GroupDef { + id: number; + label: string; + defaultIcon: React.ReactNode; + items: FlyoutItem[]; +} + +// ─── 그룹 정의 ────────────────────────────────────────────────────────────── +const T = (id: string, title: string, icon: React.ReactNode, shortcut?: string): FlyoutItem => + ({ kind: 'tool', id, title, icon, shortcut }); +const SEP = (label?: string): FlyoutItem => ({ kind: 'sep', label }); + +/** 업비트 차트 좌측 즐겨찾기 플로팅 툴바와 동일 4종 */ +export const FAVORITE_DRAWING_TOOLS = ['hline', 'vline', 'date_range', 'fibtz'] as const; + +const GROUPS: GroupDef[] = [ + { + id: 0, label: '즐겨찾기', defaultIcon: , + items: [ + T('hline', '가로줄', , '⌥H'), + T('vline', '세로줄', , '⌥V'), + T('date_range', '기간', ), + T('fibtz', '피보나치 타임존', ), + ], + }, + { + id: 1, label: '라인', defaultIcon: , + items: [ + T('trendline', '추세줄', , '⌥T'), + T('ray', '빛', ), + T('info_line', '인포 라인', ), + T('extended_line', '익스텐디드 라인', ), + T('trend_angle', '추세각', ), + SEP(), + T('hline', '가로줄', , '⌥H'), + T('hline_ray', '가로빛', , '⌥J'), + T('vline', '수직선', , '⌥V'), + T('cross_line', '크로스 라인', , '⌥C'), + SEP('채널'), + T('channel', '추세 채널', ), + T('parallel_channel','패러렐 채널', ), + T('disjoint_channel','디스조인트 채널',), + T('flat_top_bottom','플랫 탑/바텀', ), + ], + }, + { + id: 2, label: '피보나치', defaultIcon: , + items: [ + T('fib', '피보나치 되돌림', , '⌥F'), + T('fib_ext', '추세기반 피보나치 확장', ), + T('fib_channel', '피보나치 채널', ), + T('fibtz', '피보나치 타임존', ), + T('fib_speed_fan', '스피드 리지스턴스 팬', ), + T('fib_trend_time','추세기반 피보나치 시간', ), + T('fib_circles', '피보나치 서클', ), + T('fib_spiral', '피보나치 스파이럴', ), + T('fib_speed_arc', '스피드 리지스턴스 아크', ), + T('fib_wedge', '피보나치 웻지', ), + SEP('피치포크'), + T('pitchfork', '앤드류스 피치포크', ), + T('pitchfork_schiff', '쉬프 피치포크', ), + T('pitchfork_inside', '인사이드 피치포크', ), + SEP('갠'), + T('gann_box', '갠 박스', ), + T('gann_square', '갠 스퀘어', ), + T('gann_fan', '갠 팬', ), + ], + }, + { + id: 3, label: '패턴', defaultIcon: , + items: [ + T('xabcd', 'XABCD 패턴', ), + T('cypher', '사이퍼 패턴', ), + T('head_shoulders', '헤드 앤 숄더', ), + T('abcd', 'ABCD 패턴', ), + T('wedge_pattern', '세모 패턴', ), + T('three_drives', '쓰리 드라이브 패턴', ), + SEP('엘리엇 웨이브'), + T('elliott_impulse', '엘리엇 임펄스 파동 (12345)', ), + T('elliott_correction', '엘리엇 코렉션 파동 (ABC)', ), + T('elliott_triangle_wave', '엘리엇 트라이앵글 웨이브 (ABCDE)',), + T('elliott_double_combo', '엘리엇 다블콤보 파동 (WXY)', ), + ], + }, + { + id: 4, label: '프로젝션', defaultIcon: , + items: [ + T('long_position', '매수 포지션', ), + T('short_position', '숏 포지션', ), + T('forecast', '예측', ), + T('bar_pattern', '봉패턴', ), + T('ghost_feed', '고스트피드', ), + T('projection_tool','프로젝션', ), + SEP('볼륨-기반'), + T('anchored_vwap', '앵커드 VWAP', ), + T('fixed_volume_profile', '픽스트 레인지 볼륨 프로파일', ), + SEP('계측기'), + T('price_range', '가격범위', ), + T('date_range', '날짜 범위', ), + T('date_price_range','날짜&가격 범위', ), + ], + }, + { + id: 5, label: '도형', defaultIcon: , + items: [ + T('rect', '네모', , '⌥⇧R'), + T('rotated_rect', '회전 사각형', ), + T('circle', '원', ), + T('ellipse', '타원', ), + T('triangle', '삼각형', ), + T('parallelogram', '평행사변형', ), + T('polyline', '다각형', ), + T('arc', '호', ), + ], + }, + { + id: 6, label: '브러시', defaultIcon: , + items: [ + T('pencil', '붓', ), + T('highlight', '하이라이터', ), + SEP('화살'), + T('arrow_mark', '화살표', ), + T('anchored_arrow', '화살표', ), + T('arrow_mark_up', '위화살표', ), + T('arrow_mark_down', '아래화살표', ), + T('arrow_mark_left', '왼화살표', ), + T('arrow_mark_right', '오른화살표', ), + SEP('세이프'), + T('rect', '네모', , '⌥⇧R'), + T('circle', '원', ), + T('triangle', '삼각형', ), + T('ellipse', '타원', ), + ], + }, + { + id: 7, label: '텍스트 & 노트', defaultIcon: , + items: [ + T('text', '텍스트', , 'T'), + T('fixed_text', '고정위치문자', ), + T('note_text', '노트', ), + T('price_note', '프라이스 노트', ), + T('pin_mark', '핀', ), + T('table_tool', '테이블', ), + T('callout', '콜아웃', ), + T('comment_mark', '코멘트', ), + T('price_label', '가격라벨', ), + T('guide_line', '길잡이', ), + T('flag_mark', '플래그 마크', ), + ], + }, + { + id: 8, label: '측정', defaultIcon: , + items: [ + T('measure', '가격 측정', , 'M'), + T('date_range', '날짜 범위', ), + T('date_price_range', '날짜&가격 범위', ), + T('data_window', '데이터 창', ), + ], + }, + { + id: 9, label: '확대/축소', defaultIcon: , + items: [ + T('zoom', '확대/축소', ), + T('magnifier', '돋보기', ), + ], + }, +]; + +/** 차트 모드에서도 사용 가능한 확대 도구 (드로잉 툴바는 chart 모드에서 기본 비활성) */ +export const CHART_ZOOM_TOOLS = new Set(['zoom', 'magnifier']); + +// ─── Component ──────────────────────────────────────────────────────────────── +const DrawingToolbar: React.FC = ({ + activeTool, mode, onTool, onClearAll, onUndo, + locked, visible, onToggleLock, onToggleEye, + indicatorsVisible = true, + magnetMode = 'off', + snapToIndicator = false, + drawingCount = 0, + indicatorCount = 0, + onMagnetMode, + onSnapToIndicator, + onHideDrawings, + onHideIndicators, + onHideAll, + onClearIndicators, + keepLockedOnDelete = false, + onToggleKeepLocked, +}) => { + const disabled = mode === 'chart'; + const canSelectTool = (toolId: string) => !disabled || CHART_ZOOM_TOOLS.has(toolId); + + const [selectedPerGroup, setSelectedPerGroup] = useState>({}); + const [openGroup, setOpenGroup] = useState(null); + const [flyoutKind, setFlyoutKind] = useState<'group' | 'magnet' | 'eye' | 'trash' | null>(null); + const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + + const openTimer = useRef | null>(null); + const closeTimer = useRef | null>(null); + + useEffect(() => () => { + if (openTimer.current) clearTimeout(openTimer.current); + if (closeTimer.current) clearTimeout(closeTimer.current); + }, []); + + const getRepIcon = (group: GroupDef): React.ReactNode => { + const selId = selectedPerGroup[group.id]; + if (selId) { + const it = group.items.find(i => i.kind === 'tool' && i.id === selId); + if (it && it.kind === 'tool') return it.icon; + } + const active = group.items.find(i => i.kind === 'tool' && i.id === activeTool); + if (active && active.kind === 'tool') return active.icon; + return group.defaultIcon; + }; + + const isGroupActive = (group: GroupDef) => + group.items.some(i => i.kind === 'tool' && i.id === activeTool); + + const openFlyout = (kind: 'group' | 'magnet' | 'eye' | 'trash', groupId: number | null, el: HTMLElement) => { + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + if (openTimer.current) clearTimeout(openTimer.current); + openTimer.current = setTimeout(() => { + const rect = el.getBoundingClientRect(); + setFlyoutPos({ top: rect.top, left: rect.right + 4 }); + setOpenGroup(groupId); + setFlyoutKind(kind); + }, 80); + }; + + const scheduleClose = () => { + if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; } + closeTimer.current = setTimeout(() => { setFlyoutKind(null); setOpenGroup(null); }, 200); + }; + + const cancelClose = () => { + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + }; + + const selectTool = (groupId: number, toolId: string) => { + if (!canSelectTool(toolId)) return; + setSelectedPerGroup(prev => ({ ...prev, [groupId]: toolId })); + setFlyoutKind(null); setOpenGroup(null); + if (openTimer.current) clearTimeout(openTimer.current); + if (closeTimer.current) clearTimeout(closeTimer.current); + onTool(toolId); + }; + + const handleGroupClick = (group: GroupDef) => { + if (disabled && group.id !== 9) return; + const selId = selectedPerGroup[group.id]; + const toolId = (selId && group.items.find(i => i.kind === 'tool' && i.id === selId)) + ? selId + : (group.items.find(i => i.kind === 'tool' && i.id === activeTool) as { kind: 'tool'; id: string } | undefined)?.id + ?? (group.items.find(i => i.kind === 'tool') as { kind: 'tool'; id: string } | undefined)?.id; + if (toolId && canSelectTool(toolId)) onTool(toolId); + }; + + const closeFlyout = () => { setFlyoutKind(null); setOpenGroup(null); }; + + // 현재 마그넷 아이콘 + const magnetIcon = magnetMode === 'strong' ? : ; + const eyeIsOff = !visible && !indicatorsVisible; + + return ( +
+ + {/* ── 커서 ── */} + + +
+ + {/* ── 툴 그룹 (즐겨찾기 → 라인 …) ── */} + {GROUPS.map(group => ( +
+ +
+ ))} + +
+
+ + {/* ── 하단 버튼: 마그넷 ── */} + + + {/* ── 드로잉 잠금 ── */} + + + {/* ── 표시/숨기기 토글 (플라이아웃) ── */} + + +
+ + {/* ── 실행 취소 ── */} + + + {/* ── 삭제 (플라이아웃) ── */} + + + {/* ── 레이어 ── */} + + + {/* ════════════════════════════════════════════ + ── 플라이아웃 팝업 (fixed 위치) + ════════════════════════════════════════════ */} + {flyoutKind !== null && (!disabled || (flyoutKind === 'group' && openGroup === 9)) && ( +
+ {/* ── 툴 그룹 플라이아웃 ── */} + {flyoutKind === 'group' && (() => { + const group = GROUPS.find(g => g.id === openGroup); + if (!group) return null; + return ( + <> +
{group.label}
+ {group.items.map((item, idx) => + item.kind === 'sep' ? ( +
+ {item.label && {item.label}} +
+ ) : ( + + ) + )} + + ); + })()} + + {/* ── 마그넷 플라이아웃 ── */} + {flyoutKind === 'magnet' && ( + <> +
마그넷
+ {(['weak','strong'] as const).map(m => ( + + ))} + {magnetMode !== 'off' && ( + + )} +
+
+ 인디케이터에 스냅 +
+ + )} + + {/* ── 눈 플라이아웃 ── */} + {flyoutKind === 'eye' && ( + <> +
표시/숨기기
+ + + + + )} + + {/* ── 삭제 플라이아웃 ── */} + {flyoutKind === 'trash' && ( + <> +
삭제
+ + + +
+
+ 잠긴 드로잉은 항상 제거하세요 +
+ + )} +
+ )} +
+ ); +}; + +export default DrawingToolbar; diff --git a/frontend/src/components/FibTZSettingsModal.tsx b/frontend/src/components/FibTZSettingsModal.tsx new file mode 100644 index 0000000..dac031b --- /dev/null +++ b/frontend/src/components/FibTZSettingsModal.tsx @@ -0,0 +1,359 @@ +import React, { useState } from 'react'; +import type { Drawing, FibTZSettings, FibTZLevel, FibTZBaseline, DrawingStyle } from '../types'; +import { DEFAULT_FIB_TZ_SETTINGS } from '../types'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +const LINE_STYLES: { value: DrawingStyle; label: string }[] = [ + { value: 'solid', label: '━━' }, + { value: 'dashed', label: '╌╌' }, + { value: 'dotted', label: '···' }, +]; +const LINE_WIDTHS = [1, 2, 3, 4] as const; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function ColorCell({ + color, + onChange, +}: { + color: string; + onChange: (c: string) => void; +}) { + return ( + + ); +} + +function StyleSelect({ + value, + onChange, +}: { + value: DrawingStyle; + onChange: (s: DrawingStyle) => void; +}) { + return ( + + ); +} + +function WidthSelect({ + value, + onChange, +}: { + value: number; + onChange: (w: number) => void; +}) { + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface Props { + drawing: Drawing; + onSave: (updated: Drawing) => void; + onCancel: () => void; +} + +const FibTZSettingsModal: React.FC = ({ drawing, onSave, onCancel }) => { + const init = drawing.fibtzSettings ?? DEFAULT_FIB_TZ_SETTINGS; + + const [baseline, setBaseline] = useState({ ...init.baseline }); + const [levels, setLevels] = useState( + init.levels.map(l => ({ ...l })) + ); + + const updateBaseline = (patch: Partial) => + setBaseline(prev => ({ ...prev, ...patch })); + + const updateLevel = (idx: number, patch: Partial) => + setLevels(prev => prev.map((l, i) => i === idx ? { ...l, ...patch } : l)); + + const handleReset = () => { + setBaseline({ ...DEFAULT_FIB_TZ_SETTINGS.baseline }); + setLevels(DEFAULT_FIB_TZ_SETTINGS.levels.map(l => ({ ...l }))); + }; + + const handleSave = () => { + const settings: FibTZSettings = { baseline, levels }; + onSave({ ...drawing, fibtzSettings: settings }); + }; + + const cellStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }; + + const headerStyle: React.CSSProperties = { + fontSize: 10, + color: 'rgba(192,202,245,0.55)', + textAlign: 'center', + paddingBottom: 6, + }; + + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
e.stopPropagation()} + > +
+ 피보나치 타임존 설정 + +
+
+ + {/* ── 기준선 (0) ── */} +
+
+ 기준선 (항상 표시) +
+
+ {/* 항상 활성화 표시 */} +
+
+ + + +
+
+ updateBaseline({ color: c })} /> + updateBaseline({ style: s })} /> + updateBaseline({ lineWidth: w })} /> +
+
+ + {/* ── 레벨 목록 ── */} +
+
+ 레벨 +
+ + {/* 헤더 행 */} +
+
표시
+
+
색상
+
스타일
+
두께
+
+ + {/* 레벨 행들 */} +
+ {levels.map((lv, idx) => ( +
+ {/* 표시 체크박스 */} +
+ +
+ + {/* 값 (편집 가능) */} + updateLevel(idx, { value: Math.max(1, Number(e.target.value)) })} + style={{ + width: '100%', + background: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(122,162,247,0.25)', + borderRadius: 3, + color: 'inherit', + fontSize: 12, + padding: '3px 5px', + textAlign: 'center', + }} + /> + + {/* 색상 */} + updateLevel(idx, { color: c })} /> + + {/* 스타일 */} + updateLevel(idx, { style: s })} /> + + {/* 두께 */} + updateLevel(idx, { lineWidth: w })} /> +
+ ))} +
+
+ + {/* 버튼 영역 */} +
+ + + +
+
+
+
+ ); +}; + +export default FibTZSettingsModal; diff --git a/frontend/src/components/IndicatorBulkSettingsModal.tsx b/frontend/src/components/IndicatorBulkSettingsModal.tsx new file mode 100644 index 0000000..9dc297e --- /dev/null +++ b/frontend/src/components/IndicatorBulkSettingsModal.tsx @@ -0,0 +1,227 @@ +/** + * 보조지표 일괄 설정 — Main 16종 + 전체 보조지표, 검색 필터 + */ +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; +import type { IndicatorConfig } from '../types'; +import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; +import { + buildAllIndicatorDraftMap, + applyAllDraftToChart, + allDraftListFromMap, + indicatorTypesOnChart, + type MainIndicatorDraftMap, +} from '../utils/indicatorMainConfig'; +import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor'; +import { + getSettingsIndicatorTypes, + filterSettingsIndicatorTypes, + partitionFilteredTypes, +} from '../utils/indicatorSettingsList'; +import IndicatorSettingsListSearch from './IndicatorSettingsListSearch'; +import IndicatorSettingsListRow from './IndicatorSettingsListRow'; +import type { IchimokuCloudColors } from '../utils/ichimokuConfig'; +import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab'; + +export interface IndicatorBulkSettingsModalProps { + open: boolean; + indicators: IndicatorConfig[]; + chartMarket?: string; + onApply: (result: { + chartIndicators: IndicatorConfig[]; + allConfigs: IndicatorConfig[]; + }) => void; + onLiveChartChange: (chartIndicators: IndicatorConfig[]) => void; + onClose: () => void; + getParams: (type: string, defaults: Record) => Record; + getVisualConfig: ( + type: string, + defaultPlots?: PlotDef[], + defaultHlines?: HLineDef[], + ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }; +} + +const ALL_TYPES = getSettingsIndicatorTypes(); + +const IndicatorBulkSettingsModal: React.FC = ({ + open, + indicators, + chartMarket = '', + onApply, + onLiveChartChange, + onClose, + getParams, + getVisualConfig, +}) => { + const [draftMap, setDraftMap] = useState({}); + const [expandedTypes, setExpandedTypes] = useState>(new Set()); + const [search, setSearch] = useState(''); + const { + panelRef: dialogRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + const onChartTypes = useMemo(() => indicatorTypesOnChart(indicators), [indicators]); + + const filtered = useMemo( + () => filterSettingsIndicatorTypes(ALL_TYPES, search), + [search], + ); + const { main: filteredMain, other: filteredOther } = useMemo( + () => partitionFilteredTypes(filtered), + [filtered], + ); + + const prevOpenRef = useRef(false); + useEffect(() => { + if (open && !prevOpenRef.current) { + setDraftMap(buildAllIndicatorDraftMap(indicators, getParams, getVisualConfig)); + setExpandedTypes(new Set()); + setSearch(''); + } + prevOpenRef.current = open; + }, [open, indicators, getParams, getVisualConfig]); + + const setTypeEnabled = useCallback((type: string, enabled: boolean) => { + const next = new Set(onChartTypes); + if (enabled) next.add(type); + else next.delete(type); + onLiveChartChange(applyAllDraftToChart(indicators, draftMap, next)); + }, [onChartTypes, indicators, draftMap, onLiveChartChange]); + + const updateType = useCallback((type: string, updated: IndicatorConfig) => { + setDraftMap(prev => ({ ...prev, [type]: updated })); + }, []); + + const toggleExpand = useCallback((type: string) => { + setExpandedTypes(prev => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }, []); + + const handleResetAll = useCallback(() => { + if (!window.confirm('목록에 있는 모든 보조지표 설정을 기본값으로 되돌릴까요?')) return; + setDraftMap(prev => { + const next = { ...prev }; + for (const type of ALL_TYPES) { + const cfg = next[type]; + if (!cfg) continue; + const reset = resetConfigToDefaults(cfg); + const active = indicators.find(i => i.type === type); + next[type] = { + ...reset, + id: onChartTypes.has(type) && active ? active.id : `template_${type}`, + }; + } + return next; + }); + }, [indicators, onChartTypes]); + + const handleApply = useCallback(() => { + const allConfigs = allDraftListFromMap(draftMap); + const chartIndicators = applyAllDraftToChart(indicators, draftMap, onChartTypes); + onApply({ chartIndicators, allConfigs }); + onClose(); + }, [draftMap, onChartTypes, indicators, onApply, onClose]); + + const renderRows = (types: string[]) => types.map(type => { + const cfg = draftMap[type]; + if (!cfg) return null; + return ( + toggleExpand(type)} + onToggleEnabled={on => setTypeEnabled(type, on)} + onChange={updated => updateType(type, updated)} + /> + ); + }); + + if (!open) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
e.stopPropagation()} + style={{ + ...panelStyle, + zIndex: 5001, + cursor: dragging ? 'grabbing' : undefined, + }} + > +
+ 📊 보조지표 설정 +
+ + +
+
+ + + +

+ Main 탭 {MAIN_INDICATOR_TYPES.length}종을 상단에 두었습니다. 우측 스위치로 차트에 즉시 추가·제거하고, + ▼에서 파라미터·색상을 편집할 수 있습니다. 적용 시 DB 기본값에 저장됩니다. +

+ +
+ {filtered.length === 0 ? ( +
+ '{search}' 검색 결과 없음 +
+ ) : ( + <> + {filteredMain.length > 0 && ( +
+

+ Main 보조지표 {filteredMain.length} +

+ {renderRows(filteredMain)} +
+ )} + {filteredOther.length > 0 && ( +
+

+ 기타 보조지표 {filteredOther.length} +

+ {renderRows(filteredOther)} +
+ )} + + )} +
+ +
+ + +
+
+
+ ); +}; + +export default IndicatorBulkSettingsModal; diff --git a/frontend/src/components/IndicatorContextToolbar.tsx b/frontend/src/components/IndicatorContextToolbar.tsx new file mode 100644 index 0000000..0690778 --- /dev/null +++ b/frontend/src/components/IndicatorContextToolbar.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useRef } from 'react'; +import type { IndicatorConfig } from '../types'; +import { getIndicatorChartTitle } from '../utils/indicatorRegistry'; +import { formatBbLegendLabel } from '../utils/bollingerConfig'; + +interface IndicatorContextToolbarProps { + indicatorId: string; + config: IndicatorConfig | null; + chartType?: string; + screenX: number; + screenY: number; + onSettings: () => void; + onToggleHidden: () => void; + onRemove: () => void; + onClose: () => void; + /** 툴바 자체에 마우스 진입 시 hide 타이머 취소 */ + onMouseEnterToolbar?: () => void; + /** 툴바에서 마우스 이탈 시 hide 타이머 재시작 */ + onMouseLeaveToolbar?: () => void; +} + +/* SVG Icons */ +const IcEye = ({ hidden }: { hidden: boolean }) => hidden ? ( + + + + +) : ( + + + + +); +const IcGear = () => ( + + + + +); +const IcTrash = () => ( + + + + + +); +const IcMore = () => ( + + + + + +); + +/** 인디케이터 파라미터 요약 문자열 (예: "RSI (14)") */ +function paramSummary(config: IndicatorConfig): string { + if (config.type === 'BollingerBands') { + return formatBbLegendLabel(config.params ?? {}); + } + const name = getIndicatorChartTitle(config.type); + const nums = Object.values(config.params) + .filter(v => typeof v === 'number') + .slice(0, 2) + .join(', '); + return nums ? `${name} (${nums})` : name; +} + +const IndicatorContextToolbar: React.FC = ({ + indicatorId, config, chartType, screenX, screenY, + onSettings, onToggleHidden, onRemove, onClose, + onMouseEnterToolbar, onMouseLeaveToolbar, +}) => { + const ref = useRef(null); + + // hover 모드(onMouseEnterToolbar 존재)에선 hover leave 로만 닫힘 + // 클릭 모드에선 외부 클릭 시 닫기 + useEffect(() => { + if (onMouseEnterToolbar) return; // hover 모드 → 외부 클릭 핸들러 불필요 + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + }; + const tid = setTimeout(() => document.addEventListener('mousedown', handler), 100); + return () => { clearTimeout(tid); document.removeEventListener('mousedown', handler); }; + }, [onClose, onMouseEnterToolbar]); + + // pane 좌측 상단 기준 (screenX = pane left, screenY = pane top) + const W = 240; + const isMain = indicatorId === '__main__'; + /** 메인 캔들 pane 상단 tv-legend(OHLCV) 행 아래에 배치 */ + const TOP_GAP_MAIN = 38; + let left = screenX + 8; + let top = screenY + (isMain ? TOP_GAP_MAIN : 8); + // 화면 오른쪽 경계 보정 + if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8; + + const isHidden = config?.hidden === true; + const label = isMain + ? (chartType === 'candlestick' ? '캔들스틱' : chartType ?? '메인 차트') + : (config ? paramSummary(config) : indicatorId); + + return ( +
e.stopPropagation()} + onMouseEnter={onMouseEnterToolbar} + onMouseLeave={onMouseLeaveToolbar} + > + {/* 이름 */} + {label} + + {/* 가시성 토글 */} + {!isMain && ( + + )} + + {/* 설정 */} + + + {/* 삭제 */} + {!isMain && ( + + )} + + {/* 더보기 */} + +
+ ); +}; + +export default IndicatorContextToolbar; diff --git a/frontend/src/components/IndicatorMainDefaultsPanel.tsx b/frontend/src/components/IndicatorMainDefaultsPanel.tsx new file mode 100644 index 0000000..6d72b4b --- /dev/null +++ b/frontend/src/components/IndicatorMainDefaultsPanel.tsx @@ -0,0 +1,197 @@ +/** + * 설정 화면 — 보조지표 기본값 (Main 16종 상단 + 전체 지표, 검색 필터) + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import type { IndicatorConfig } from '../types'; +import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; +import { buildMainIndicatorConfig } from '../utils/indicatorMainConfig'; +import { indicatorTypesOnChart } from '../utils/indicatorMainConfig'; +import IndicatorSettingsForm from './IndicatorSettingsForm'; +import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor'; +import type { IchimokuCloudColors } from '../utils/ichimokuConfig'; +import { + getSettingsIndicatorTypes, + filterSettingsIndicatorTypes, + partitionFilteredTypes, +} from '../utils/indicatorSettingsList'; +import IndicatorSettingsListSearch from './IndicatorSettingsListSearch'; +import IndicatorSettingsListRow from './IndicatorSettingsListRow'; +import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab'; + +export interface IndicatorMainDefaultsPanelProps { + chartIndicators: IndicatorConfig[]; + onAddToChart: (type: string, config?: IndicatorConfig) => void; + onRemoveFromChart: (type: string) => void; + getParams: (type: string, defaults: Record) => Record; + saveParams: (type: string, params: Record) => void; + getVisualConfig: ( + type: string, + defaultPlots?: PlotDef[], + defaultHlines?: HLineDef[], + ) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors }; + saveVisual: ( + type: string, + plots?: PlotDef[], + hlines?: HLineDef[], + cloudColors?: IchimokuCloudColors, + ) => void; +} + +function persistDefaults( + updated: IndicatorConfig, + saveParams: IndicatorMainDefaultsPanelProps['saveParams'], + saveVisual: IndicatorMainDefaultsPanelProps['saveVisual'], +) { + saveParams(updated.type, updated.params); + saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors); +} + +const ALL_TYPES = getSettingsIndicatorTypes(); + +const IndicatorMainDefaultsPanel: React.FC = ({ + chartIndicators, + onAddToChart, + onRemoveFromChart, + getParams, + saveParams, + getVisualConfig, + saveVisual, +}) => { + const onChartTypes = useMemo(() => indicatorTypesOnChart(chartIndicators), [chartIndicators]); + const [expandedTypes, setExpandedTypes] = useState>(() => new Set()); + const [configs, setConfigs] = useState>({}); + const [search, setSearch] = useState(''); + + const filtered = useMemo( + () => filterSettingsIndicatorTypes(ALL_TYPES, search), + [search], + ); + const { main: filteredMain, other: filteredOther } = useMemo( + () => partitionFilteredTypes(filtered), + [filtered], + ); + + useEffect(() => { + const next: Record = {}; + for (const type of ALL_TYPES) { + const cfg = buildMainIndicatorConfig(type, chartIndicators, getParams, getVisualConfig); + if (cfg) next[type] = cfg; + } + setConfigs(next); + }, [chartIndicators, getParams, getVisualConfig]); + + const toggleExpand = useCallback((type: string) => { + setExpandedTypes(prev => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }, []); + + const handleConfigChange = useCallback((type: string, updated: IndicatorConfig) => { + setConfigs(prev => ({ ...prev, [type]: updated })); + }, []); + + const handleToggleChart = useCallback((type: string, enabled: boolean) => { + if (enabled) { + onAddToChart(type, configs[type]); + } else { + onRemoveFromChart(type); + } + }, [configs, onAddToChart, onRemoveFromChart]); + + const handleRowDefaults = useCallback((type: string) => { + const base = configs[type]; + if (!base) return; + const reset = resetConfigToDefaults(base); + persistDefaults(reset, saveParams, saveVisual); + handleConfigChange(type, reset); + }, [configs, saveParams, saveVisual, handleConfigChange]); + + const handleResetAll = useCallback(() => { + if (!window.confirm('목록에 있는 모든 보조지표 기본값을 초기화할까요?')) return; + const next: Record = {}; + for (const type of ALL_TYPES) { + const base = configs[type] ?? buildMainIndicatorConfig(type, [], getParams, getVisualConfig); + if (!base) continue; + const reset = resetConfigToDefaults(base); + next[type] = reset; + persistDefaults(reset, saveParams, saveVisual); + } + setConfigs(next); + }, [configs, getParams, getVisualConfig, saveParams, saveVisual]); + + const renderRows = (types: string[]) => types.map(type => { + const cfg = configs[type]; + if (!cfg) return null; + return ( + toggleExpand(type)} + onToggleEnabled={on => handleToggleChart(type, on)} + onChange={updated => { + persistDefaults(updated, saveParams, saveVisual); + handleConfigChange(type, updated); + }} + onRowDefaults={() => handleRowDefaults(type)} + /> + ); + }); + + return ( + <> +
+

+ 지표 추가 팝업 Main 탭 {MAIN_INDICATOR_TYPES.length}종을 상단에 두었고, 그 아래에 등록된 모든 보조지표가 있습니다. + 우측 스위치로 차트에 바로 추가·제거할 수 있으며, 파라미터·색상 변경은 DB 기본값에 자동 저장됩니다. +

+ +
+ + + +
+ {filtered.length === 0 ? ( +
+ '{search}' 검색 결과 없음 +
+ ) : ( + <> + {filteredMain.length > 0 && ( +
+

+ Main 보조지표 {filteredMain.length} +

+ {renderRows(filteredMain)} +
+ )} + {filteredOther.length > 0 && ( +
+

+ 기타 보조지표 {filteredOther.length} +

+ {renderRows(filteredOther)} +
+ )} + + )} +
+ + ); +}; + +export default IndicatorMainDefaultsPanel; diff --git a/frontend/src/components/IndicatorModal.tsx b/frontend/src/components/IndicatorModal.tsx new file mode 100644 index 0000000..4447e27 --- /dev/null +++ b/frontend/src/components/IndicatorModal.tsx @@ -0,0 +1,191 @@ +import React, { useState, useMemo } from 'react'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; +import { INDICATOR_REGISTRY, type IndicatorDef, type IndicatorCategory } from '../utils/indicatorRegistry'; +import type { IndicatorConfig } from '../types'; +import { getNumericParamSpec } from '../utils/indicatorParamSpec'; +import NumericParamInput from './NumericParamInput'; + +interface IndicatorModalProps { + activeIndicators: IndicatorConfig[]; + onAdd: (def: IndicatorDef, params: Record) => void; + onRemove: (id: string) => void; + onClose: () => void; +} + +const CATEGORIES: IndicatorCategory[] = [ + 'Moving Averages', 'Channels & Bands', 'Oscillators', 'Momentum', + 'Trend', 'Volatility', 'Volume', 'Candlestick Patterns', +]; + +const IndicatorModal: React.FC = ({ activeIndicators, onAdd, onRemove, onClose }) => { + const [search, setSearch] = useState(''); + const [category, setCategory] = useState('All'); + const [selected, setSelected] = useState(null); + const [params, setParams] = useState>({}); + + const filtered = useMemo(() => { + return INDICATOR_REGISTRY.filter(d => { + const matchCat = category === 'All' || d.category === category; + const matchQ = !search || d.name.toLowerCase().includes(search.toLowerCase()) || d.shortName.toLowerCase().includes(search.toLowerCase()); + return matchCat && matchQ; + }); + }, [search, category]); + + const select = (def: IndicatorDef) => { + setSelected(def); + setParams({ ...def.defaultParams }); + }; + + const isActive = (type: string) => activeIndicators.some(a => a.type === type); + + const handleAdd = () => { + if (!selected) return; + onAdd(selected, params); + setSelected(null); + }; + + const handleRemoveAll = (type: string) => { + activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id)); + }; + + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
e.stopPropagation()} + > +
+ 📈 지표 추가 + {INDICATOR_REGISTRY.length}개 지표 지원 + +
+ +
+ setSearch(e.target.value)} + autoFocus + /> +
+ +
+ {(['All', ...CATEGORIES] as Array<'All' | IndicatorCategory>).map(c => ( + + ))} +
+ +
+
+ {filtered.map(def => ( +
select(def)} + > +
+ {def.name} + {def.description} +
+
+ {def.overlay && 오버레이} + {def.returnsMarkers && 마커} + {isActive(def.type) ? ( + + ) : ( + + )} +
+
+ ))} + {filtered.length === 0 && ( +
'{search}' 검색 결과 없음
+ )} +
+ + {selected && ( +
+
{selected.name} 설정
+
{selected.description}
+ {Object.entries(params).map(([key, val]) => ( +
+ + {typeof val === 'boolean' ? ( + setParams(p => ({ ...p, [key]: e.target.checked }))} /> + ) : typeof val === 'number' ? ( + setParams(p => ({ ...p, [key]: v }))} + /> + ) : ( + setParams(p => ({ ...p, [key]: e.target.value }))} + /> + )} +
+ ))} +
+ + +
+ {selected.plots.length > 0 && ( +
+ {selected.plots.map(p => ( +
+ + {p.title} +
+ ))} +
+ )} +
+ )} +
+ + {activeIndicators.length > 0 && ( +
+
활성 지표 ({activeIndicators.length})
+
+ {activeIndicators.map(a => ( + + {a.type} + + + ))} +
+
+ )} +
+
+ ); +}; + +export default IndicatorModal; diff --git a/frontend/src/components/IndicatorSettingsForm.tsx b/frontend/src/components/IndicatorSettingsForm.tsx new file mode 100644 index 0000000..de2e977 --- /dev/null +++ b/frontend/src/components/IndicatorSettingsForm.tsx @@ -0,0 +1,368 @@ +/** + * 보조지표 설정 폼 — 개별 설정 모달·일괄 설정 팝업에서 동일 UI/로직 공유 + */ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import type { IndicatorConfig, Timeframe } from '../types'; +import { getIndicatorDef } from '../utils/indicatorRegistry'; +import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; +import { getPlotLabel } from '../utils/indicatorLabels'; +import { getNumericParamSpec } from '../utils/indicatorParamSpec'; +import NumericParamInput from './NumericParamInput'; +import SmaPeriodInput from './SmaPeriodInput'; +import { + SMA_DEFAULT_SRC, + SMA_MA_COUNT, + smaPeriodKey, + smaPlotId, +} from '../utils/smaConfig'; +import PlotLineStylePicker from './PlotLineStylePicker'; +import { + GlobalParamRow, + IchimokuCloudSection, + BollingerSymbolSection, + BollingerBackgroundRow, + SettingsOutputFooter, + UnifiedIndicatorBody, +} from './IndicatorSettingsSections'; +import { + createEditorSnapshot, + configFromEditorSnapshot, + resetConfigToDefaults, + type IndicatorSettingsEditorSnapshot, +} from '../utils/indicatorSettingsEditor'; + +export const ALL_TIMEFRAMES: { tf: Timeframe; label: string }[] = [ + { tf: '1m', label: '1분' }, + { tf: '3m', label: '3분' }, + { tf: '5m', label: '5분' }, + { tf: '15m', label: '15분' }, + { tf: '30m', label: '30분' }, + { tf: '1h', label: '1시간' }, + { tf: '4h', label: '4시간' }, + { tf: '1D', label: '일봉' }, + { tf: '1W', label: '주봉' }, + { tf: '1M', label: '월봉' }, +]; + +export interface IndicatorSettingsFormProps { + config: IndicatorConfig; + chartMarket?: string; + /** embedded: 일괄 설정 카드 안 */ + variant?: 'modal' | 'embedded'; + /** + * modal: 편집 중 부모 setState 없이 ref만 갱신 (포커스 유지). + * embedded: 변경 즉시 onChange 호출. + */ + deferParentSync?: boolean; + /** @deprecated 탭 제거 — 통합 화면만 사용 */ + inputsOnly?: boolean; + /** 개별 설정 모달: 지표 전체 표시 여부 */ + chartHidden?: boolean; + onChartHiddenChange?: (showOnChart: boolean) => void; + onChange: (updated: IndicatorConfig) => void; +} + +function configFingerprint(c: IndicatorConfig): string { + return JSON.stringify({ + id: c.id, + params: c.params, + plots: c.plots, + plotVisibility: c.plotVisibility, + hlines: c.hlines, + hlinesBackground: c.hlinesBackground, + bandBackground: c.bandBackground, + cloudColors: c.cloudColors, + lastValueVisible: c.lastValueVisible, + timeframeVisibility: c.timeframeVisibility, + hidden: c.hidden, + }); +} + +const IndicatorSettingsForm: React.FC = ({ + config, + chartMarket = '', + variant = 'modal', + deferParentSync = variant === 'modal', + inputsOnly = false, + chartHidden = false, + onChartHiddenChange, + onChange, +}) => { + const def = getIndicatorDef(config.type); + const isSma = config.type === 'SMA'; + const isIchimoku = config.type === 'IchimokuCloud'; + const isBollinger = config.type === 'BollingerBands'; + + const [snap, setSnap] = useState(() => createEditorSnapshot(config)); + const baseRef = useRef(config); + const fpRef = useRef(configFingerprint(config)); + const onChangeRef = useRef(onChange); + const internalEmitRef = useRef(false); + onChangeRef.current = onChange; + + const emitDraft = useCallback((next: IndicatorSettingsEditorSnapshot) => { + const updated = configFromEditorSnapshot(baseRef.current, next); + baseRef.current = updated; + if (deferParentSync) { + onChangeRef.current(updated); + return; + } + internalEmitRef.current = true; + onChangeRef.current(updated); + }, [deferParentSync]); + + const scheduleDraftEmit = useCallback((next: IndicatorSettingsEditorSnapshot) => { + if (deferParentSync) { + emitDraft(next); + return; + } + emitDraft(next); + }, [deferParentSync, emitDraft]); + + useEffect(() => { + if (deferParentSync) return; + if (internalEmitRef.current) { + internalEmitRef.current = false; + fpRef.current = configFingerprint(config); + baseRef.current = config; + return; + } + const fp = configFingerprint(config); + if (fp !== fpRef.current) { + fpRef.current = fp; + baseRef.current = config; + setSnap(createEditorSnapshot(config)); + } + }, [config, deferParentSync]); + + const emit = useCallback((next: IndicatorSettingsEditorSnapshot) => { + setSnap(next); + scheduleDraftEmit(next); + }, [scheduleDraftEmit]); + + const patch = useCallback((partial: Partial) => { + setSnap(prev => { + const next = { ...prev, ...partial }; + scheduleDraftEmit(next); + return next; + }); + }, [scheduleDraftEmit]); + + const { params, plots, plotVis, hlines, hlinesBg, lastValueVisible, timeframeVis, cloudColors, bandBg } = snap; + const hasHLines = (def?.hlines ?? []).length > 0 || hlines.length > 0; + + const handleHLineVisible = useCallback((idx: number, visible: boolean) => + patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, visible } : h) }), [hlines, patch]); + const handleHLinePrice = useCallback((idx: number, price: number) => + patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, price } : h) }), [hlines, patch]); + const handleHLinePlotStyle = useCallback((idx: number, p: Partial) => + patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, ...p } : h) }), [hlines, patch]); + + const handleParamChange = useCallback((key: string, raw: string | boolean) => { + const oldVal = params[key]; + const defVal = def?.defaultParams?.[key]; + let nextParams: Record; + if (typeof oldVal === 'number' || typeof defVal === 'number') { + const n = parseFloat(raw as string); + const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number); + nextParams = { ...params, [key]: isNaN(n) ? fallback : n }; + } else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') { + nextParams = { ...params, [key]: raw as boolean }; + } else { + nextParams = { ...params, [key]: raw as string }; + } + let nextPlotVis = plotVis; + if (key === 'maType' && (config.type === 'CCI' || config.type === 'RSI')) { + nextPlotVis = { ...plotVis, plot1: raw !== 'None' }; + } + patch({ params: nextParams, plotVis: nextPlotVis }); + }, [params, plotVis, def, config.type, patch]); + + const handlePlotStyle = useCallback((idx: number, p: Partial) => { + patch({ plots: plots.map((pl, i) => i === idx ? { ...pl, ...p } : pl) }); + }, [plots, patch]); + + const handlePlotToggle = useCallback((plotId: string, enabled: boolean) => { + patch({ plotVis: { ...plotVis, [plotId]: enabled } }); + }, [plotVis, patch]); + + const handleSmaPeriod = useCallback((maIndex: number, v: number) => { + patch({ params: { ...params, [smaPeriodKey(maIndex)]: v } }); + }, [params, patch]); + + const handleSmaToggle = useCallback((plotIndex: number, enabled: boolean) => { + patch({ plotVis: { ...plotVis, [smaPlotId(plotIndex)]: enabled } }); + }, [plotVis, patch]); + + const handleDefaults = useCallback(() => { + const reset = resetConfigToDefaults(config); + baseRef.current = reset; + fpRef.current = configFingerprint(reset); + const nextSnap = createEditorSnapshot(reset); + setSnap(nextSnap); + onChange(reset); + }, [config, onChange]); + + const footerProps = { + lastValueVisible, + onLastValueVisible: (v: boolean) => patch({ lastValueVisible: v }), + timeframeVis, + onTimeframeVis: (tf: Timeframe, v: boolean) => + patch({ timeframeVis: { ...timeframeVis, [tf]: v } }), + timeframes: ALL_TIMEFRAMES, + chartHidden, + onChartHidden: onChartHiddenChange, + }; + + const sectionClass = variant === 'embedded' + ? 'ism-section unified-settings-section ibsm-embedded-form' + : 'ism-section unified-settings-section'; + + return ( +
+ {variant === 'embedded' && ( +
+ +
+ )} + + {isSma && ( +
+ patch({ params: { ...params, src: raw as string } })} + /> +
+ {Array.from({ length: SMA_MA_COUNT }, (_, i) => { + const maNum = i + 1; + const plotId = smaPlotId(i); + const periodKey = smaPeriodKey(maNum); + const enabled = plotVis[plotId] !== false; + const plot = plots[i]; + return ( +
+ + +
e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + > + handleSmaPeriod(maNum, v)} + /> + {plot && !inputsOnly && ( + handlePlotStyle(i, p)} + /> + )} +
+
+ ); + })} + {!inputsOnly && } +
+ )} + + {isIchimoku && ( + patch({ hlinesBg: v })} + onLastValueVisible={v => patch({ lastValueVisible: v })} + onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })} + chartHidden={chartHidden} + onChartHidden={onChartHiddenChange} + cloudSection={inputsOnly ? undefined : ( + patch({ cloudColors: { ...cloudColors, ...p } })} + /> + )} + inputsOnly={inputsOnly} + showOutputFooter={!inputsOnly} + /> + )} + + {!isSma && !isIchimoku && ( + patch({ hlinesBg: v })} + onLastValueVisible={v => patch({ lastValueVisible: v })} + onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })} + chartHidden={chartHidden} + onChartHidden={onChartHiddenChange} + inputsOnly={inputsOnly} + showOutputFooter={!inputsOnly && !isBollinger} + symbolSection={isBollinger ? ( + { + const next = { ...params }; + if (p.symbolMode !== undefined) next.symbolMode = p.symbolMode; + if (p.refSymbol !== undefined) next.refSymbol = p.refSymbol; + patch({ params: next }); + }} + /> + ) : undefined} + afterPlotsSection={isBollinger ? ( + patch({ bandBg: v })} /> + ) : undefined} + /> + )} +
+ ); +}; + +export default IndicatorSettingsForm; diff --git a/frontend/src/components/IndicatorSettingsListRow.tsx b/frontend/src/components/IndicatorSettingsListRow.tsx new file mode 100644 index 0000000..0572d5f --- /dev/null +++ b/frontend/src/components/IndicatorSettingsListRow.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import type { IndicatorConfig } from '../types'; +import { getIndicatorDef } from '../utils/indicatorRegistry'; +import { getIndicatorListLabels } from '../utils/indicatorSettingsList'; +import IndicatorSettingsForm from './IndicatorSettingsForm'; + +export interface IndicatorSettingsListRowProps { + type: string; + config: IndicatorConfig; + enabled: boolean; + chartMarket?: string; + expanded: boolean; + variant: 'bulk' | 'settings'; + onToggleExpand: () => void; + onToggleEnabled: (enabled: boolean) => void; + onChange: (updated: IndicatorConfig) => void; + onRowDefaults?: () => void; +} + +const IndicatorSettingsListRow: React.FC = ({ + type, + config, + enabled, + chartMarket = '', + expanded, + variant, + onToggleExpand, + onToggleEnabled, + onChange, + onRowDefaults, +}) => { + const labels = getIndicatorListLabels(type); + const def = getIndicatorDef(type); + + const cardClass = + variant === 'bulk' + ? `ibsm-card ibsm-main-row${enabled ? ' ibsm-main-row--active' : ''}${expanded ? ' ibsm-card-expanded' : ''}` + : `stg-ind-card${enabled ? ' stg-ind-card--on-chart' : ''}${expanded ? ' stg-ind-card--expanded' : ''}`; + + const headClass = variant === 'bulk' ? 'ibsm-card-main ibsm-main-row-head' : 'stg-ind-card-head'; + const infoClass = variant === 'bulk' ? 'ibsm-main-row-info' : 'stg-ind-card-titles'; + const koClass = variant === 'bulk' ? 'ibsm-main-row-ko' : 'stg-ind-card-name'; + const enClass = variant === 'bulk' ? 'ibsm-main-row-en' : 'stg-ind-card-sub'; + const badgesClass = variant === 'bulk' ? 'ibsm-main-row-badges' : 'stg-ind-card-badges'; + const onBadgeClass = variant === 'bulk' ? 'ibsm-main-badge ibsm-main-badge--on' : 'stg-ind-badge stg-ind-badge--on'; + const offBadgeClass = variant === 'bulk' ? 'ibsm-main-badge' : 'stg-ind-badge'; + const expandClass = variant === 'bulk' ? 'ibsm-expand' : 'stg-ind-card-expand'; + const expandWrapClass = variant === 'bulk' ? 'ibsm-card-expand' : 'stg-ind-card-body'; + + return ( +
+
+
+ {labels.ko} + {labels.en} +
+
+ {def?.overlay && 오버레이} + {def?.returnsMarkers && 마커} + {enabled ? ( + 차트 적용 + ) : ( + 미적용 + )} +
+ + {variant === 'settings' && onRowDefaults && ( + + )} + +
+ + {expanded && ( +
+ +
+ )} +
+ ); +}; + +export default IndicatorSettingsListRow; diff --git a/frontend/src/components/IndicatorSettingsListSearch.tsx b/frontend/src/components/IndicatorSettingsListSearch.tsx new file mode 100644 index 0000000..0cada3e --- /dev/null +++ b/frontend/src/components/IndicatorSettingsListSearch.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +export interface IndicatorSettingsListSearchProps { + value: string; + onChange: (value: string) => void; + totalCount: number; + filteredCount: number; + className?: string; +} + +/** 보조지표 설정 목록 상단 검색 (지표 추가 팝업과 동일 UX) */ +const IndicatorSettingsListSearch: React.FC = ({ + value, + onChange, + totalCount, + filteredCount, + className = '', +}) => ( +
+ onChange(e.target.value)} + aria-label="보조지표 검색" + /> + + {value.trim() + ? `검색 결과 ${filteredCount} / ${totalCount}` + : `전체 ${totalCount}개`} + +
+); + +export default IndicatorSettingsListSearch; diff --git a/frontend/src/components/IndicatorSettingsModal.tsx b/frontend/src/components/IndicatorSettingsModal.tsx new file mode 100644 index 0000000..13cdd3d --- /dev/null +++ b/frontend/src/components/IndicatorSettingsModal.tsx @@ -0,0 +1,124 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import type { IndicatorConfig } from '../types'; +import { getIndicatorDef, enrichIndicatorConfig } from '../utils/indicatorRegistry'; +import IndicatorSettingsForm from './IndicatorSettingsForm'; +import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +interface IndicatorSettingsModalProps { + config: IndicatorConfig; + chartMarket?: string; + onSave: (updated: IndicatorConfig) => void; + onCancel: () => void; +} + +/** 차트·설정 화면 공통 — 입력·스타일·가시성 단일 화면 (탭 없음) */ +const IndicatorSettingsModal: React.FC = ({ + config, + chartMarket = '', + onSave, + onCancel, +}) => { + const def = getIndicatorDef(config.type); + const draftRef = useRef(enrichIndicatorConfig(config)); + const [formRevision, setFormRevision] = useState(0); + const [chartHidden, setChartHidden] = useState(() => draftRef.current.hidden ?? false); + + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + const openedIdRef = React.useRef(null); + useEffect(() => { + if (openedIdRef.current !== config.id) { + openedIdRef.current = config.id; + draftRef.current = enrichIndicatorConfig(config); + setChartHidden(draftRef.current.hidden ?? false); + setFormRevision(r => r + 1); + } + }, [config]); + + const title = def + ? `${def.koreanName} (${def.name})` + : config.type; + + const handleSave = useCallback(() => { + onSave(enrichIndicatorConfig(draftRef.current)); + }, [onSave]); + + const handleDefaults = useCallback(() => { + draftRef.current = resetConfigToDefaults(draftRef.current); + setChartHidden(draftRef.current.hidden ?? false); + setFormRevision(r => r + 1); + }, []); + + const handleHidden = useCallback((showOnChart: boolean) => { + draftRef.current = { ...draftRef.current, hidden: !showOnChart }; + setChartHidden(!showOnChart); + }, []); + + const handleDraftRef = useCallback((updated: IndicatorConfig) => { + draftRef.current = updated; + }, []); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + > +
+ {title} + +
+ +
e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + > + +
+ +
+ +
+ + +
+
+
+
+ ); +}; + +export default IndicatorSettingsModal; diff --git a/frontend/src/components/IndicatorSettingsSections.tsx b/frontend/src/components/IndicatorSettingsSections.tsx new file mode 100644 index 0000000..74b02a7 --- /dev/null +++ b/frontend/src/components/IndicatorSettingsSections.tsx @@ -0,0 +1,595 @@ +import React from 'react'; +import type { IndicatorConfig, HlinesBackground, Timeframe } from '../types'; +import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; +import { getHLineLabel } from '../utils/indicatorRegistry'; +import { getParamLabel, getPlotLabel, getSrcOptionLabel } from '../utils/indicatorLabels'; +import { getIchimokuPlotTitle } from '../utils/ichimokuConfig'; +import { + getNumericParamSpec, + getHlinePriceSpec, +} from '../utils/indicatorParamSpec'; +import { + getGlobalParamKeys, + getPlotParamKeys, + plotParamsOnGlobalRowOnly, + plotRowHasNoInputs, +} from '../utils/indicatorSettingsLayout'; +import NumericParamInput from './NumericParamInput'; +import PlotLineStylePicker from './PlotLineStylePicker'; +import ColorInput from './ColorInput'; +import { colorToRgbaString, cloudColorOpacityPercent } from '../utils/ichimokuConfig'; +import type { IchimokuCloudColors } from '../utils/ichimokuConfig'; + +const SRC_OPTIONS = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4']; + +const MA_TYPE_OPTIONS_CCI = ['None', 'SMA', 'EMA', 'WMA'] as const; +const MA_TYPE_OPTIONS_OBV = ['SMA', 'EMA', 'WMA'] as const; + +/** 업비트 OBV 드롭다운 표기 */ +const OBV_MA_TYPE_LABELS: Record = { + SMA: '단순이동평균', + EMA: '지수 이동 평균', + WMA: '가중이동평균', +}; + +const MA_TYPE_LABELS: Record = { + None: '없음 (None)', + SMA: '단순이동평균 (SMA)', + EMA: '지수이동평균 (EMA)', + WMA: '가중이동평균 (WMA)', + 'SMMA (RMA)': 'RMA (SMMA)', + VWMA: '거래량가중이동평균 (VWMA)', +}; + +function isMaTypeParam(indicatorType: string, key: string, value: unknown): boolean { + if (key !== 'maType' || typeof value !== 'string') return false; + return indicatorType === 'CCI' || indicatorType === 'OBV' || indicatorType === 'RSI'; +} + +function maTypeOptionsFor(indicatorType: string): readonly string[] { + if (indicatorType === 'OBV') return MA_TYPE_OPTIONS_OBV; + return MA_TYPE_OPTIONS_CCI; +} + +function isSrcParam(key: string, value: unknown): boolean { + return (key === 'src' || key.toLowerCase().includes('src')) && typeof value === 'string'; +} + +export { ColorInput }; + +/* ── 단일 파라미터 컨트롤 ─────────────────────────────────────── */ +export const ParamField: React.FC<{ + indicatorType: string; + paramKey: string; + value: number | string | boolean; + disabled?: boolean; + onChange: (key: string, raw: string | boolean) => void; +}> = ({ indicatorType, paramKey, value, disabled, onChange }) => { + if (typeof value === 'boolean') { + return ( + + ); + } + if (isMaTypeParam(indicatorType, paramKey, value)) { + return ( + + ); + } + if (isSrcParam(paramKey, value)) { + return ( + + ); + } + if (typeof value === 'number') { + return ( + onChange(paramKey, String(v))} + /> + ); + } + return ( + onChange(paramKey, e.target.value)} + /> + ); +}; + +/* ── 플롯 설정 행 (on/off · 이름 · 입력 · 색상·선) ─────────────── */ +export const PlotSettingsRow: React.FC<{ + indicatorType: string; + plot: PlotDef; + plotIndex: number; + plots: PlotDef[]; + params: Record; + enabled: boolean; + inputsOnly?: boolean; + onToggle: (plotId: string, on: boolean) => void; + onParamChange: (key: string, raw: string | boolean) => void; + onPlotStyle: (plotIndex: number, patch: Partial) => void; +}> = ({ + indicatorType, plot, plotIndex, plots, params, enabled, inputsOnly, + onToggle, onParamChange, onPlotStyle, +}) => { + const paramKeys = getPlotParamKeys(indicatorType, plot.id, plotIndex, plots, params); + const noInputs = plotRowHasNoInputs(indicatorType, plot.id, plotIndex, plots, params); + const globalOnlyInputs = plotParamsOnGlobalRowOnly(indicatorType, plot.id); + const label = indicatorType === 'IchimokuCloud' + ? getIchimokuPlotTitle(plot.id) + : getPlotLabel(plot.title); + const isHistogram = plot.type === 'histogram'; + + return ( +
+ + +
+ {noInputs && globalOnlyInputs ? ( + + ) : noInputs ? ( + 자동 + ) : ( +
+ {paramKeys.map(key => ( + + ))} +
+ )} + {!inputsOnly && ( + isHistogram ? ( + onPlotStyle(plotIndex, { color: v })} + /> + ) : ( + onPlotStyle(plotIndex, patch)} + /> + ) + )} +
+
+ ); +}; + +/* ── 전역 파라미터 행 (토글 없음) ─────────────────────────────── */ +export const GlobalParamRow: React.FC<{ + indicatorType: string; + paramKey: string; + value: number | string | boolean; + onChange: (key: string, raw: string | boolean) => void; +}> = ({ indicatorType, paramKey, value, onChange }) => ( +
+ {getParamLabel(paramKey, indicatorType)} +
+ +
+
+); + +/* ── 수평선 행 ───────────────────────────────────────────────── */ +export const HLineSettingsRow: React.FC<{ + indicatorType: string; + hl: HLineDef; + idx: number; + allPrices: number[]; + inputsOnly?: boolean; + onVisible: (idx: number, v: boolean) => void; + onPrice: (idx: number, v: number) => void; + onStyle: (idx: number, patch: Partial) => void; +}> = ({ indicatorType, hl, idx, allPrices, inputsOnly, onVisible, onPrice, onStyle }) => { + const label = hl.label ?? getHLineLabel(hl.price, allPrices); + const isOn = hl.visible !== false; + return ( +
+ + +
+ onPrice(idx, v)} + /> + {!inputsOnly && ( + onStyle(idx, patch)} + /> + )} +
+
+ ); +}; + +/* ── 출력 · 타임프레임 (하단 공통) ───────────────────────────── */ +export const SettingsOutputFooter: React.FC<{ + lastValueVisible: boolean; + onLastValueVisible: (v: boolean) => void; + timeframeVis: Partial>; + onTimeframeVis: (tf: Timeframe, v: boolean) => void; + timeframes: { tf: Timeframe; label: string }[]; + /** 개별 설정 모달: 지표 전체 숨김 */ + chartHidden?: boolean; + onChartHidden?: (showOnChart: boolean) => void; +}> = ({ + lastValueVisible, onLastValueVisible, timeframeVis, onTimeframeVis, timeframes, + chartHidden, onChartHidden, +}) => ( + <> +
+
출력 · 표시
+ {onChartHidden != null && ( +
+ 차트에 표시 +
+ +
+
+ )} +
+ 가격축 라벨·설명 +
+ +
+
+
+ {timeframes.map(({ tf, label }) => ( + + ))} +
+ +); + +/* ── 일목 구름 색상 ─────────────────────────────────────────── */ +export const IchimokuCloudSection: React.FC<{ + cloudColors: IchimokuCloudColors; + onChange: (patch: Partial) => void; +}> = ({ cloudColors, onChange }) => ( + <> +
+
+

구름 영역 색상

+

+ 선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름이 표시됩니다. +

+
+
+ 상승 구름 (선행1 > 선행2) +
+ onChange({ bullishColor: v })} + /> +
+
+ {colorToRgbaString(cloudColors.bullishColor)} + {' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}% +
+
+
+ 하락 구름 (선행1 < 선행2) +
+ onChange({ bearishColor: v })} + /> +
+
+ {colorToRgbaString(cloudColors.bearishColor)} + {' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}% +
+
+
+
+ +); + +/* ── 볼린저밴드 백그라운드 (업비트 모습 탭) ───────────────────── */ +export const BollingerBackgroundRow: React.FC<{ + value: HlinesBackground; + onChange: (v: HlinesBackground) => void; +}> = ({ value, onChange }) => ( +
+ + 백그라운드 그리기 +
+ + onChange({ ...value, color: c })} + /> +
+
+); + +/* ── 수평선 배경 ─────────────────────────────────────────────── */ +export const HlinesBackgroundRow: React.FC<{ + value: HlinesBackground; + onChange: (v: HlinesBackground) => void; +}> = ({ value, onChange }) => ( + <> +
+
+ + 수평선 배경 +
+ + onChange({ ...value, color: c })} + /> +
+
+ +); + +/* ── 볼린저밴드 심볼 (업비트 인풋) ───────────────────────────── */ +export const BollingerSymbolSection: React.FC<{ + symbolMode: string; + refSymbol: string; + chartMarket: string; + onChange: (patch: { symbolMode?: string; refSymbol?: string }) => void; +}> = ({ symbolMode, refSymbol, chartMarket, onChange }) => { + const isOther = symbolMode === 'other'; + return ( +
+
심볼
+ + +
+ ); +}; + +/* ── 통합 본문 (일반 지표) ───────────────────────────────────── */ +export const UnifiedIndicatorBody: React.FC<{ + config: IndicatorConfig; + params: Record; + plots: PlotDef[]; + plotVis: Record; + hlines: HLineDef[]; + hlinesBg: HlinesBackground; + hasHLines: boolean; + lastValueVisible: boolean; + timeframeVis: Partial>; + timeframes: { tf: Timeframe; label: string }[]; + onParamChange: (key: string, raw: string | boolean) => void; + onPlotToggle: (plotId: string, on: boolean) => void; + onPlotStyle: (idx: number, patch: Partial) => void; + onHLineVisible: (idx: number, v: boolean) => void; + onHLinePrice: (idx: number, v: number) => void; + onHLineStyle: (idx: number, patch: Partial) => void; + onHlinesBg: (v: HlinesBackground) => void; + onLastValueVisible: (v: boolean) => void; + onTimeframeVis: (tf: Timeframe, v: boolean) => void; + cloudSection?: React.ReactNode; + symbolSection?: React.ReactNode; + /** 플롯 행 아래 추가 섹션 (BB 백그라운드 등) */ + afterPlotsSection?: React.ReactNode; + /** false면 가격 스케일·타임프레임 보임 설정 숨김 (BB) */ + showOutputFooter?: boolean; + inputsOnly?: boolean; + chartHidden?: boolean; + onChartHidden?: (showOnChart: boolean) => void; +}> = (props) => { + const { + config, params, plots, plotVis, hlines, hlinesBg, hasHLines, + lastValueVisible, timeframeVis, timeframes, + onParamChange, onPlotToggle, onPlotStyle, + onHLineVisible, onHLinePrice, onHLineStyle, onHlinesBg, + onLastValueVisible, onTimeframeVis, cloudSection, symbolSection, + afterPlotsSection, + showOutputFooter = true, + inputsOnly = false, + chartHidden, + onChartHidden, + } = props; + + const globalKeys = getGlobalParamKeys(config.type, params, plots); + const hlinePrices = hlines.map(h => h.price); + + return ( +
+ {symbolSection} + {symbolSection && globalKeys.length > 0 &&
} + {globalKeys.map(key => ( + + ))} + + {globalKeys.length > 0 && plots.length > 0 &&
} + + {plots.map((plot, idx) => ( + + ))} + + {cloudSection} + + {afterPlotsSection} + + {hlines.length > 0 && ( + <> +
+
수평선
+ {hlines.map((hl, idx) => ( + + ))} + + )} + + {hasHLines && !inputsOnly && } + + {showOutputFooter && ( + + )} +
+ ); +}; diff --git a/frontend/src/components/IndicatorSettingsStyleSection.tsx b/frontend/src/components/IndicatorSettingsStyleSection.tsx new file mode 100644 index 0000000..2e63171 --- /dev/null +++ b/frontend/src/components/IndicatorSettingsStyleSection.tsx @@ -0,0 +1,239 @@ +/** + * 보조지표 설정 — 스타일 탭 (플롯·수평선 색상/굵기/타입) + * 타이틀 열 너비를 섹션 내 최장 라벨에 맞춰 컨트롤 상하 정렬 + */ +import React, { useLayoutEffect, useRef } from 'react'; +import type { PlotDef, HLineDef, HLineStyle } from '../utils/indicatorRegistry'; +import { getHLineLabel } from '../utils/indicatorRegistry'; +import { getPlotLabel } from '../utils/indicatorLabels'; +import { getHlinePriceSpec } from '../utils/indicatorParamSpec'; +import NumericParamInput from './NumericParamInput'; +import { ColorInput } from './IndicatorSettingsSections'; +import { LINE_WIDTH_OPTIONS, LINE_STYLE_OPTIONS } from '../utils/plotColorUtils'; +import { IchimokuCloudSection } from './IndicatorSettingsSections'; +import { getIchimokuPlotTitle, type IchimokuCloudColors } from '../utils/ichimokuConfig'; + +/** 스타일 탭 선 유형 드롭다운 (업비트 표기) */ +const LINE_STYLE_TAB_LABELS: Record = { + solid: '라인', + dashed: '점선', + dotted: '도트', +}; + +export interface IndicatorSettingsStyleSectionProps { + indicatorType: string; + plots: PlotDef[]; + hlines: HLineDef[]; + cloudColors?: IchimokuCloudColors; + showIchimokuCloud?: boolean; + onPlotStyle: (idx: number, patch: Partial) => void; + onHLineStyle: (idx: number, patch: Partial) => void; + onHLinePrice?: (idx: number, price: number) => void; + onCloudColors?: (patch: Partial) => void; +} + +function measureTitleColumn(sectionEl: HTMLElement | null, measureEl: HTMLElement | null) { + if (!sectionEl || !measureEl) return; + let maxW = 0; + measureEl.querySelectorAll('.ism-plot-title').forEach(el => { + maxW = Math.max(maxW, el.offsetWidth); + }); + if (maxW > 0) { + sectionEl.style.setProperty('--ism-plot-title-width', `${Math.ceil(maxW)}px`); + } +} + +const PlotStyleRow: React.FC<{ + label: string; + plot: PlotDef; + disabled?: boolean; + onStyle: (patch: Partial) => void; +}> = ({ label, plot, disabled, onStyle }) => { + const isHistogram = plot.type === 'histogram'; + const lineWidth = plot.lineWidth ?? 1; + const lineStyle = plot.lineStyle ?? 'solid'; + + return ( +
+
+ {label} +
+
+ 색상 + onStyle({ color: v })} + /> +
+
+ 굵기 + {isHistogram ? ( + + ) : ( + + )} +
+
+ 타입 + {isHistogram ? ( + 히스토그램 + ) : ( + + )} +
+
+ ); +}; + +const HLineStyleRow: React.FC<{ + indicatorType: string; + label: string; + hl: HLineDef; + onStyle: (patch: Partial) => void; + onPrice?: (price: number) => void; +}> = ({ indicatorType, label, hl, onStyle, onPrice }) => { + const isOn = hl.visible !== false; + const lineWidth = hl.lineWidth ?? 1; + const lineStyle = hl.lineStyle ?? 'dashed'; + + return ( +
+
+ {label} +
+
+ {onPrice != null ? ( + + ) : ( + {hl.price} + )} +
+
+ onStyle({ color: v })} /> + + +
+
+ ); +}; + +const IndicatorSettingsStyleSection: React.FC = ({ + indicatorType, + plots, + hlines, + cloudColors, + showIchimokuCloud, + onPlotStyle, + onHLineStyle, + onHLinePrice, + onCloudColors, +}) => { + const sectionRef = useRef(null); + const measureRef = useRef(null); + const hlinePrices = hlines.map(h => h.price); + + const plotLabel = (p: PlotDef) => indicatorType === 'IchimokuCloud' + ? getIchimokuPlotTitle(p.id) + : getPlotLabel(p.title); + + const titleLabels = [ + ...plots.map(plotLabel), + ...hlines.map(h => h.label ?? getHLineLabel(h.price, hlinePrices)), + ]; + + useLayoutEffect(() => { + measureTitleColumn(sectionRef.current, measureRef.current); + }, [titleLabels.join('\0'), plots.length, hlines.length]); + + return ( +
+
+ {titleLabels.map((t, i) => ( + {t} + ))} +
+ +
+ {plots.map((plot, idx) => ( + onPlotStyle(idx, patch)} + /> + ))} + + {hlines.length > 0 && ( + <> +
+
+ 이름 + 가격 + 색상 · 굵기 · 타입 +
+ {hlines.map((hl, idx) => ( + onHLineStyle(idx, patch)} + onPrice={onHLinePrice ? v => onHLinePrice(idx, v) : undefined} + /> + ))} + + )} +
+ + {showIchimokuCloud && cloudColors && onCloudColors && ( + + )} +
+ ); +}; + +export default IndicatorSettingsStyleSection; diff --git a/frontend/src/components/IndicatorSettingsVisibilitySection.tsx b/frontend/src/components/IndicatorSettingsVisibilitySection.tsx new file mode 100644 index 0000000..898573e --- /dev/null +++ b/frontend/src/components/IndicatorSettingsVisibilitySection.tsx @@ -0,0 +1,51 @@ +/** + * 보조지표 설정 — 가시성 탭 + */ +import React from 'react'; +import type { Timeframe } from '../types'; +import { SettingsOutputFooter } from './IndicatorSettingsSections'; + +export interface IndicatorSettingsVisibilitySectionProps { + hidden: boolean; + onHidden: (v: boolean) => void; + lastValueVisible: boolean; + onLastValueVisible: (v: boolean) => void; + timeframeVis: Partial>; + onTimeframeVis: (tf: Timeframe, v: boolean) => void; + timeframes: { tf: Timeframe; label: string }[]; +} + +const IndicatorSettingsVisibilitySection: React.FC = ({ + hidden, + onHidden, + lastValueVisible, + onLastValueVisible, + timeframeVis, + onTimeframeVis, + timeframes, +}) => ( +
+
+ 차트에 표시 +
+ +
+
+ +
+); + +export default IndicatorSettingsVisibilitySection; diff --git a/frontend/src/components/LayoutPicker.tsx b/frontend/src/components/LayoutPicker.tsx new file mode 100644 index 0000000..4f828d4 --- /dev/null +++ b/frontend/src/components/LayoutPicker.tsx @@ -0,0 +1,162 @@ +/** + * LayoutPicker + * TradingView 스타일 레이아웃 선택 팝업. + */ +import React, { useEffect, useRef } from 'react'; +import type { LayoutDef, SyncOptions } from '../utils/layoutTypes'; +import { LAYOUTS } from '../utils/layoutTypes'; + +// grid-template-areas 문자열을 파싱해 셀 rect 배열 반환 +function parseAreas(def: LayoutDef, bw: number, bh: number, pad: number) { + const areaGrid: string[][] = def.areas + .split('"') + .map(s => s.trim()) + .filter(Boolean) + .map(row => row.split(/\s+/).filter(Boolean)); + + if (areaGrid.length === 0) return []; + + const rowCount = areaGrid.length; + const colCount = areaGrid[0]?.length ?? 1; + const colW = bw / colCount; + const rowH = bh / rowCount; + + const cellMap = new Map(); + for (let ri = 0; ri < areaGrid.length; ri++) { + for (let ci = 0; ci < (areaGrid[ri]?.length ?? 0); ci++) { + const name = areaGrid[ri][ci]; + if (!name || name === '.') continue; + const existing = cellMap.get(name); + if (!existing) { + cellMap.set(name, { c1: ci, r1: ri, c2: ci + 1, r2: ri + 1 }); + } else { + cellMap.set(name, { + c1: Math.min(existing.c1, ci), + r1: Math.min(existing.r1, ri), + c2: Math.max(existing.c2, ci + 1), + r2: Math.max(existing.r2, ri + 1), + }); + } + } + } + + return Array.from(cellMap.values()).map(r => ({ + x: pad + r.c1 * colW, + y: pad + r.r1 * rowH, + w: (r.c2 - r.c1) * colW, + h: (r.r2 - r.r1) * rowH, + })); +} + +// ── 레이아웃 미니 아이콘 (SVG) ──────────────────────────────────────────────── +const LayoutIcon: React.FC<{ def: LayoutDef; size?: number }> = ({ def, size = 34 }) => { + const S = size; + const PAD = 2; + const BW = S - PAD * 2; + const BH = S - PAD * 2; + const rects = parseAreas(def, BW, BH, PAD); + + return ( + + + {rects.map((r, i) => ( + + ))} + + ); +}; + +// ── 토글 스위치 ────────────────────────────────────────────────────────────── +const Toggle: React.FC<{ checked: boolean; onChange: () => void }> = ({ checked, onChange }) => ( + + ))} +
+
+ ); + })} +
+ +
+ + {/* SYNC IN LAYOUT */} +
+
SYNC IN LAYOUT
+ {([ + ['symbol', 'Symbol'], + ['interval', 'Interval'], + ['crosshair', 'Crosshair'], + ['time', 'Time'], + ['dateRange', 'Date range'], + ] as [keyof SyncOptions, string][]).map(([key, label]) => ( +
+ {label} + onSyncChange(key, !syncOptions[key])} + /> +
+ ))} +
+
+ ); +}; + +export default LayoutPicker; diff --git a/frontend/src/components/LiveSignalNotifier.tsx b/frontend/src/components/LiveSignalNotifier.tsx new file mode 100644 index 0000000..7c71369 --- /dev/null +++ b/frontend/src/components/LiveSignalNotifier.tsx @@ -0,0 +1,76 @@ +/** + * 실시간 전략 STOMP 구독 — 화면과 무관하게 동작, 시그널은 알림 Context로 전달 + */ +import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import { useLiveStrategyMarkers, type LiveMarker } from '../hooks/useLiveStrategyMarkers'; +import { useTradeNotification } from '../contexts/TradeNotificationContext'; + +export interface LiveSignalNotifierHandle { + clearMarkers: () => void; +} + +interface Props { + markets: string[]; + /** 종목별 평가 분봉 — STOMP 구독 경로 */ + marketSubscriptions?: { market: string; candleType: string }[]; + activeMarket: string; + enabled: boolean; + popupEnabled: boolean; + strategyId: number | undefined; + executionType: string; + strategyName: string | null; + onMarkersChange: (markers: LiveMarker[]) => void; + /** 차트 화면일 때만 마커를 차트에 반영 */ + chartMarkersActive: boolean; +} + +export const LiveSignalNotifier = forwardRef(function LiveSignalNotifier({ + markets, + marketSubscriptions, + activeMarket, + enabled, + popupEnabled: _popupEnabled, + strategyName, + executionType, + onMarkersChange, + chartMarkersActive, +}, ref) { + const { addNotification, refreshHistory } = useTradeNotification(); + const onMarkersRef = useRef(onMarkersChange); + onMarkersRef.current = onMarkersChange; + + useEffect(() => { + if (!enabled || markets.length === 0) return; + const id = window.setInterval(() => { void refreshHistory(); }, 20_000); + return () => window.clearInterval(id); + }, [enabled, markets.length, refreshHistory]); + + const { clearMarkers } = useLiveStrategyMarkers({ + markets, + subscriptions: marketSubscriptions, + activeMarket, + enabled: enabled && markets.length > 0, + onMarkersChange: markers => { + if (chartMarkersActive) onMarkersRef.current(markers); + }, + onSignal: (marker: LiveMarker) => { + addNotification({ + market: marker.market, + signalType: marker.signal, + price: marker.price, + candleTime: marker.time, + strategyName, + executionType, + candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m', + }); + // STOMP만 수신·DB 저장 누락 시 배지 동기화 + window.setTimeout(() => { void refreshHistory(); }, 800); + }, + }); + + useImperativeHandle(ref, () => ({ clearMarkers }), [clearMarkers]); + + return null; +}); + +export default LiveSignalNotifier; diff --git a/frontend/src/components/LiveStrategyPanel.tsx b/frontend/src/components/LiveStrategyPanel.tsx new file mode 100644 index 0000000..9c60452 --- /dev/null +++ b/frontend/src/components/LiveStrategyPanel.tsx @@ -0,0 +1,217 @@ +/** + * LiveStrategyPanel + * + * 실시간 전략 체크 — 전역 설정 UI. + * 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다. + */ +import React, { useState, useCallback } from 'react'; +import type { Theme } from '../types'; +import { + saveLiveStrategySettings, + type LiveStrategySettingsDto, +} from '../utils/backendApi'; + +interface Strategy { + id: number; + name: string; +} + +interface LiveStrategyPanelProps { + theme: Theme; + market: string; + strategies: Strategy[]; + settings: LiveStrategySettingsDto; + watchlistCount?: number; + monitoredMarkets?: string[]; + onClearMarkers?: () => void; + onClose?: () => void; + onSettingsChange?: (settings: LiveStrategySettingsDto) => void; +} + +const LiveStrategyPanel: React.FC = ({ + theme, market, strategies, settings, + watchlistCount = 0, monitoredMarkets = [], + onClearMarkers, onClose, onSettingsChange, +}) => { + const [saving, setSaving] = useState(false); + + const persist = useCallback(async (patch: Partial) => { + const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; + const prev = settings; + onSettingsChange?.(next); + setSaving(true); + try { + const saved = await saveLiveStrategySettings(next); + if (saved) { + onSettingsChange?.(saved); + onClearMarkers?.(); + } else { + onSettingsChange?.(prev); + window.alert('실시간 전략 설정 저장에 실패했습니다. 네트워크·서버 상태를 확인해 주세요.'); + } + } catch { + onSettingsChange?.(prev); + window.alert('실시간 전략 설정 저장 중 오류가 발생했습니다.'); + } finally { + setSaving(false); + } + }, [settings, market, onClearMarkers, onSettingsChange]); + + const isOn = settings.isLiveCheck; + const execType = settings.executionType; + const stratId = settings.strategyId; + const monCount = monitoredMarkets.length; + + return ( +
+
+ + + + + 실시간 전략 체크 + {saving && 저장 중…} + {onClose && ( + + )} +
+ +
+ 관심 {watchlistCount}개 + · + 0 ? 'lsp-monitor-on' : ''}> + 체크 대상 {monCount}개 + +
+ {isOn && stratId && watchlistCount > 0 && ( +

+ ★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다. +

+ )} + {monCount > 0 && ( +
+ {monitoredMarkets.slice(0, 8).map(m => ( + {m.replace('KRW-', '')} + ))} + {monCount > 8 && +{monCount - 8}} +
+ )} + {isOn && !stratId && ( +

전략을 선택하면 관심 {watchlistCount}개 종목에 적용됩니다.

+ )} + +
+ 실시간 체크 + +
+ +
+ 전략 선택 + +
+ +
+ 실행 방식 +
+ + +
+
+ +
+ 시그널 모드 +
+ + +
+
+ + {isOn && ( +
+ + {stratId + ? `${strategies.find(s => s.id === stratId)?.name ?? '전략'} · 관심 ${monCount}종목` + : '전략을 선택하세요'} +
+ )} +
+ ); +}; + +export default LiveStrategyPanel; diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx new file mode 100644 index 0000000..af0ce1c --- /dev/null +++ b/frontend/src/components/LoginModal.tsx @@ -0,0 +1,103 @@ +/** + * 로그인 모달 — 기본값 admin / admin + */ +import React, { useEffect, useState } from 'react'; +import { loginUser, type LoginResponse } from '../utils/backendApi'; + +const DEFAULT_USERNAME = 'admin'; +const DEFAULT_PASSWORD = 'admin'; + +interface Props { + open: boolean; + onClose: () => void; + onSuccess: (session: LoginResponse) => void; +} + +const LoginModal: React.FC = ({ open, onClose, onSuccess }) => { + const [username, setUsername] = useState(DEFAULT_USERNAME); + const [password, setPassword] = useState(DEFAULT_PASSWORD); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + setUsername(DEFAULT_USERNAME); + setPassword(DEFAULT_PASSWORD); + setError(null); + setLoading(false); + }, [open]); + + if (!open) return null; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await loginUser(username.trim(), password); + if (!res) { + setError('로그인에 실패했습니다. 아이디와 비밀번호를 확인하세요.'); + return; + } + onSuccess(res); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : '로그인에 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

로그인

+ +
+

+ 로그인 시 동일 계정의 설정이 모든 기기에서 공유됩니다. 로그인하지 않으면 이 브라우저(기기)별로 설정이 저장됩니다. +

+
+ + + {error &&

{error}

} +
+ + +
+
+
+
+ ); +}; + +export default LoginModal; diff --git a/frontend/src/components/MainChartSettingsModal.tsx b/frontend/src/components/MainChartSettingsModal.tsx new file mode 100644 index 0000000..81925cf --- /dev/null +++ b/frontend/src/components/MainChartSettingsModal.tsx @@ -0,0 +1,131 @@ +/** + * MainChartSettingsModal + * 메인 차트(캔들스틱) 설정 다이얼로그 — TradingView Settings 와 유사. + * 탭: Symbol (캔들 색상) / 스타일 / 가시성 (미래 확장용) + */ +import React, { useState } from 'react'; +import type { MainChartStyle } from '../types'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +interface Props { + style: MainChartStyle; + onSave: (style: MainChartStyle) => void; + onCancel: () => void; +} + +type Tab = 'symbol'; + +const TABS: { id: Tab; label: string }[] = [ + { id: 'symbol', label: '심볼 (Symbol)' }, +]; + +const ColorSwatch: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => { + const hex6 = value.slice(0, 7); + return ( + + ); +}; + +const MainChartSettingsModal: React.FC = ({ style, onSave, onCancel }) => { + const [tab, setTab] = useState('symbol'); + const [draft, setDraft] = useState({ ...style }); + + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + const set = (key: keyof MainChartStyle) => (v: string) => + setDraft(prev => ({ ...prev, [key]: v })); + + const handleOk = () => onSave(draft); + + const handleDefaults = () => setDraft({ + upColor: '#ff6b6b', + downColor: '#4dabf7', + borderUpColor: '#ff6b6b', + borderDownColor: '#4dabf7', + wickUpColor: '#ff6b6b', + wickDownColor: '#4dabf7', + }); + + return ( +
e.target === e.currentTarget && onCancel()}> +
e.stopPropagation()} + > +
+ 차트 설정 (Settings) + +
+ +
+ {TABS.map(t => ( + + ))} +
+ +
+ {tab === 'symbol' && ( + <> +
캔들 (CANDLES)
+
+ 상승 (Up) +
+ + + +
+
+
+ 하락 (Down) +
+ + + +
+
+ + )} +
+ +
+ +
+ + +
+
+
+
+ ); +}; + +export default MainChartSettingsModal; diff --git a/frontend/src/components/MarketPanel.tsx b/frontend/src/components/MarketPanel.tsx new file mode 100644 index 0000000..d928e27 --- /dev/null +++ b/frontend/src/components/MarketPanel.tsx @@ -0,0 +1,415 @@ +/** + * 좌측 마켓 패널 + * + * 탭 구성: + * 원화 - KRW 마켓 목록, 현재가(KRW) + * BTC - KRW 마켓 목록, 현재가(USD 환산) + * 보유 - 보유 종목 목록 (localStorage 관리) + * 관심 - 즐겨찾기 목록 (localStorage 관리) + */ +import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react'; +import type { TickerData, ChangeDir } from '../hooks/useMarketTicker'; +import { + getFavorites, saveFavorites, toggleFavorite, + getHoldings, addHolding, removeHolding, +} from '../utils/marketStorage'; +// ─── 타입 ────────────────────────────────────────────────────────────────── +type Tab = 'krw' | 'btc' | 'hold' | 'fav'; +type SortKey = 'name' | 'price' | 'change'; +type SortDir = 'asc' | 'desc'; + +export interface MarketPanelProps { + open: boolean; + tickers: Map; + loading: boolean; + usdRate: number; // KRW per 1 USD (from KRW-USDT) + activeSymbol: string; + onSymbolSelect: (symbol: string) => void; +} + +// ─── 가격 포맷 헬퍼 ──────────────────────────────────────────────────────── +function fmtKrw(price: number | null | undefined): string { + if (price == null || !isFinite(price)) return '-'; + if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); + if (price >= 0.01) return price.toFixed(4); + return price.toFixed(8); +} + +function fmtUsd(krwPrice: number | null | undefined, rate: number): string { + if (krwPrice == null || !isFinite(krwPrice) || !rate) return '-'; + const usd = krwPrice / rate; + if (usd >= 10_000) return '$' + usd.toLocaleString('en-US', { maximumFractionDigits: 0 }); + if (usd >= 1) return '$' + usd.toFixed(2); + if (usd >= 0.001) return '$' + usd.toFixed(4); + return '$' + usd.toFixed(6); +} + +function fmtPct(rate: number | null | undefined): string { + if (rate == null || !isFinite(rate)) return '-'; + const sign = rate >= 0 ? '+' : ''; + return `${sign}${(rate * 100).toFixed(2)}%`; +} + +// ─── 방향 지시자 삼각형 ──────────────────────────────────────────────────── +const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) { + if (change === 'RISE') return ; + if (change === 'FALL') return ; + return ; +}); + +// ─── 개별 행 ─────────────────────────────────────────────────────────────── +interface RowProps { + ticker: TickerData | undefined; + market: string; + isActive: boolean; + isFav: boolean; + isHold: boolean; + showUsd: boolean; + showHoldBtn: boolean; + usdRate: number; + onSelect: () => void; + onToggleFav: (e: React.MouseEvent) => void; + onToggleHold: (e: React.MouseEvent) => void; + onRemoveHold?: (e: React.MouseEvent) => void; +} + +const MarketRow = memo(function MarketRow({ + ticker, market, isActive, isFav, isHold, + showUsd, showHoldBtn, usdRate, + onSelect, onToggleFav, onToggleHold, onRemoveHold, +}: RowProps) { + const change: ChangeDir = ticker?.change ?? 'EVEN'; + const code = market.replace(/^KRW-/, ''); + + // ── 가격 변동 플래시 (frontend_golden TickerItem 로직) ───────────────── + const [flashClass, setFlashClass] = useState(''); + const prevPriceRef = useRef(null); + useEffect(() => { + const price = ticker?.tradePrice ?? null; + if (price === null) return; + const prev = prevPriceRef.current; + if (prev !== null && prev !== price) { + const cls = price > prev ? 'mp-row--flash-rise' : 'mp-row--flash-fall'; + setFlashClass(cls); + const t = setTimeout(() => setFlashClass(''), 700); + prevPriceRef.current = price; + return () => clearTimeout(t); + } + prevPriceRef.current = price; + }, [ticker?.tradePrice]); + + const colorCls = change === 'RISE' ? 'mp-up' : change === 'FALL' ? 'mp-dn' : 'mp-even'; + + return ( +
+ {/* 즐겨찾기 별 */} + + + {/* 이름 + 방향 지시자 */} +
+ + + {ticker?.koreanName ?? code} + + {code}/KRW +
+ + {/* 현재가 */} + + {ticker + ? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice)) + : '-'} + + + {/* 전일대비 */} + + {ticker ? fmtPct(ticker.changeRate) : '-'} + + + {/* 보유 토글 / 제거 버튼 */} + {onRemoveHold ? ( + + ) : showHoldBtn ? ( + + ) : null} +
+ ); +}); + +// ─── 메인 패널 ───────────────────────────────────────────────────────────── +export const MarketPanel: React.FC = ({ + open, tickers, loading, usdRate, activeSymbol, onSymbolSelect, +}) => { + const [tab, setTab] = useState('krw'); + const [search, setSearch] = useState(''); + const [favorites, setFavs] = useState(getFavorites); + const [holdings, setHolds] = useState(getHoldings); + const [holdSearch, setHoldSrch] = useState(''); + const [showHoldAdd, setShowHoldAdd] = useState(false); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('desc'); + const searchRef = useRef(null); + + // 패널 열릴 때 검색창 포커스 + useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]); + + // 검색 초기화 (탭 전환 시) + const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []); + + // 컬럼 정렬 토글 + const handleSort = useCallback((key: SortKey) => { + setSortKey(prev => { + if (prev === key) { + setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + return key; + } + setSortDir('desc'); + return key; + }); + }, []); + + // 즐겨찾기 토글 + const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => { + e.stopPropagation(); + const row = tickers.get(market); + try { + const next = await toggleFavorite(market, { koreanName: row?.koreanName }); + setFavs(next); + } catch (err) { + console.warn('[MarketPanel] watchlist toggle failed', err); + } + }, [tickers]); + + // 보유 토글 + const handleToggleHold = useCallback((market: string, e: React.MouseEvent) => { + e.stopPropagation(); + setHolds(prev => + prev.includes(market) ? removeHolding(market) : addHolding(market) + ); + }, []); + + // 보유 제거 (보유 탭) + const handleRemoveHold = useCallback((market: string, e: React.MouseEvent) => { + e.stopPropagation(); + setHolds(removeHolding(market)); + }, []); + + // 보유 추가 검색에서 선택 + const handleAddHolding = useCallback((market: string) => { + setHolds(addHolding(market)); + setHoldSrch(''); + setShowHoldAdd(false); + }, []); + + // 탭별 마켓 목록 계산 + const allKrwMarkets = useMemo(() => { + const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-')); + // 24h 거래대금 내림차순 정렬 + return keys.sort((a, b) => + (tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0) + ); + }, [tickers]); + + const filtered = useMemo(() => { + let base: string[] = []; + switch (tab) { + case 'krw': case 'btc': base = allKrwMarkets; break; + case 'hold': base = holdings.filter(m => tickers.has(m) || true); break; + case 'fav': base = favorites; break; + } + if (search) { + const q = search.toUpperCase(); + base = base.filter(m => { + const code = m.replace(/^KRW-/, ''); + const name = tickers.get(m)?.koreanName ?? ''; + return code.includes(q) || name.includes(search); + }); + } + if (!sortKey) return base; + const mul = sortDir === 'asc' ? 1 : -1; + return [...base].sort((a, b) => { + const ta = tickers.get(a); + const tb = tickers.get(b); + switch (sortKey) { + case 'name': { + const na = ta?.koreanName ?? a.replace(/^KRW-/, ''); + const nb = tb?.koreanName ?? b.replace(/^KRW-/, ''); + return mul * na.localeCompare(nb, 'ko'); + } + case 'price': + return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0)); + case 'change': + return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0)); + default: + return 0; + } + }); + }, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]); + + // 보유 탭 "추가" 검색 결과 + const holdAddResults = useMemo(() => { + if (!holdSearch) return []; + const q = holdSearch.toUpperCase(); + return allKrwMarkets + .filter(m => { + const code = m.replace(/^KRW-/, ''); + const name = tickers.get(m)?.koreanName ?? ''; + return code.includes(q) || name.includes(holdSearch); + }) + .slice(0, 10); + }, [holdSearch, allKrwMarkets, tickers]); + + const tabLabels: Record = { + krw: '원화', btc: 'BTC', hold: '보유', fav: '관심', + }; + + return ( +
+ {/* ── 패널 내용 ─────────────────────────────────────────────── */} +
+ {/* 탭 헤더 */} +
+ {(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => ( + + ))} +
+ + {/* 검색 */} +
+
+ + + + setSearch(e.target.value)} + /> + {search && ( + + )} +
+
+ + {/* 컬럼 헤더 */} +
+ {(['name', 'price', 'change'] as SortKey[]).map(key => { + const label = key === 'name' ? '한글명' : key === 'price' ? '현재가' : '전일대비'; + const cls = key === 'name' ? 'mp-h-name' : key === 'price' ? 'mp-h-price' : 'mp-h-change'; + const active = sortKey === key; + return ( + + ); + })} +
+ + {/* 보유 탭: 추가 버튼 */} + {tab === 'hold' && ( +
+ {showHoldAdd ? ( +
+ setHoldSrch(e.target.value)} + autoFocus + /> + + {holdAddResults.length > 0 && ( +
+ {holdAddResults.map(m => ( +
handleAddHolding(m)}> + {tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')} + {m.replace(/^KRW-/, '')} +
+ ))} +
+ )} +
+ ) : ( + + )} +
+ )} + + {/* 마켓 목록 */} +
+ {loading && ( +
+
+ 마켓 정보 로딩 중... +
+ )} + + {!loading && filtered.length === 0 && ( +
+ {tab === 'hold' ? '보유 종목이 없습니다' : + tab === 'fav' ? '관심 종목이 없습니다' : + '검색 결과 없음'} +
+ )} + + {!loading && filtered.map(market => ( + onSymbolSelect(market)} + onToggleFav={e => handleToggleFav(market, e)} + onToggleHold={e => handleToggleHold(market, e)} + onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined} + /> + ))} +
+
+ +
+ ); +}; + +export default MarketPanel; diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx new file mode 100644 index 0000000..cffa637 --- /dev/null +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -0,0 +1,330 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { setMarketNames } from '../utils/marketNameCache'; +import { + getFavorites, + toggleFavorite, + FAVORITES_CHANGED_EVENT, +} from '../utils/marketStorage'; + +// ─── Types ─────────────────────────────────────────────────────────────────── +interface MarketInfo { + market: string; // "KRW-BTC" + korean_name: string; // "비트코인" + english_name: string; // "Bitcoin" + symbol: string; // "BTC" +} + +interface TickerInfo { + trade_price: number; + signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%) + acc_trade_price_24h: number; // 24h 거래대금 (KRW) +} + +// ─── Constants ─────────────────────────────────────────────────────────────── +const UPBIT_API = import.meta.env.DEV ? '/upbit-api/v1' : 'https://api.upbit.com/v1'; +const TICKER_TTL = 3000; // 3초마다 시세 갱신 + +function formatVol(vol: number): string { + if (vol >= 1e12) return `${(vol / 1e12).toFixed(1)}조`; + if (vol >= 1e8) return `${(vol / 1e8).toFixed(0)}억`; + if (vol >= 1e4) return `${(vol / 1e4).toFixed(0)}만`; + return vol.toLocaleString(); +} + +// ─── Component ─────────────────────────────────────────────────────────────── +interface MarketSearchPanelProps { + currentMarket: string; + onSelect: (market: string) => void; + onClose: () => void; + /** + * 슬롯 내 심볼 버튼 클릭 시 전달되는 슬롯 컨테이너 rect. + * 제공되면 해당 슬롯 영역 안에서 패널이 표시된다. + * 없으면 기본(전체화면 좌측 고정) 위치로 표시된다. + */ + anchorRect?: DOMRect | null; + /** 외부 입력과 검색어 동기화 */ + initialQuery?: string; +} + +export const MarketSearchPanel: React.FC = ({ + currentMarket, onSelect, onClose, anchorRect, initialQuery = '', +}) => { + const [markets, setMarkets] = useState([]); + const [tickers, setTickers] = useState>({}); + const [query, setQuery] = useState(initialQuery); + const [favs, setFavs] = useState>(() => new Set(getFavorites())); + const [loading, setLoading] = useState(true); + + const refreshFavs = useCallback(() => { + setFavs(new Set(getFavorites())); + }, []); + + const inputRef = useRef(null); + const tickerTimer = useRef | null>(null); + const marketCodesRef = useRef(''); + + // ── 1. 마켓 목록 로드 ───────────────────────────────────────────────────── + useEffect(() => { + setLoading(true); + fetch(`${UPBIT_API}/market/all?isDetails=false`) + .then(r => r.json()) + .then((data: Array<{ market: string; korean_name: string; english_name: string }>) => { + const krw = data + .filter(m => m.market.startsWith('KRW-')) + .map(m => ({ ...m, symbol: m.market.replace('KRW-', '') })); + setMarkets(krw); + marketCodesRef.current = krw.map(m => m.market).join(','); + + // 한글명 캐시 저장 (Toolbar 등에서 사용) + const nameMap: Record = {}; + for (const m of krw) nameMap[m.market] = m.korean_name; + setMarketNames(nameMap); + + setLoading(false); + }) + .catch(() => setLoading(false)); + + setTimeout(() => inputRef.current?.focus(), 50); + refreshFavs(); + }, [refreshFavs]); + + useEffect(() => { + setQuery(initialQuery); + }, [initialQuery]); + + // 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화 + useEffect(() => { + const onChanged = () => refreshFavs(); + window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged); + return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged); + }, [refreshFavs]); + + // ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ───────────────────────────── + const fetchTickers = useCallback(async () => { + const codes = marketCodesRef.current; + if (!codes) return; + const allCodes = codes.split(','); + const BATCH = 100; + const results: Array<{ market: string } & TickerInfo> = []; + + for (let i = 0; i < allCodes.length; i += BATCH) { + const batch = allCodes.slice(i, i + BATCH).join(','); + try { + const data: Array<{ market: string } & TickerInfo> = + await fetch(`${UPBIT_API}/ticker?markets=${batch}`).then(r => r.json()); + results.push(...data); + } catch { /* ignore partial failures */ } + } + + setTickers(prev => { + const map: Record = { ...prev }; + for (const t of results) map[t.market] = t; + return map; + }); + }, []); + + useEffect(() => { + if (markets.length === 0) return; + fetchTickers(); + tickerTimer.current = setInterval(fetchTickers, TICKER_TTL); + return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); }; + }, [markets, fetchTickers]); + + // ── 3. 즐겨찾기 토글 ───────────────────────────────────────────────────── + const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => { + e.stopPropagation(); + const row = markets.find(m => m.market === market); + try { + const next = await toggleFavorite(market, { + koreanName: row?.korean_name, + englishName: row?.english_name, + }); + setFavs(new Set(next)); + } catch (err) { + console.warn('[MarketSearchPanel] watchlist toggle failed', err); + } + }, [markets]); + + // ── 4. 필터 + 정렬 ─────────────────────────────────────────────────────── + const q = query.trim(); + const filtered = markets.filter(m => { + if (!q) return true; + return ( + m.korean_name.includes(q) || + m.symbol.toUpperCase().includes(q.toUpperCase()) || + m.english_name.toLowerCase().includes(q.toLowerCase()) || + m.market.toUpperCase().includes(q.toUpperCase()) + ); + }); + + const sorted = [...filtered].sort((a, b) => { + const fa = favs.has(a.market) ? 1 : 0; + const fb = favs.has(b.market) ? 1 : 0; + if (fb !== fa) return fb - fa; + // 즐겨찾기 동순위: 24h 거래대금 내림차순 + const va = tickers[a.market]?.acc_trade_price_24h ?? 0; + const vb = tickers[b.market]?.acc_trade_price_24h ?? 0; + return vb - va; + }); + + // ── Render ──────────────────────────────────────────────────────────────── + // anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다. + const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px) + const MIN_W = 260; + + // LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로 + // 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다. + const PANEL_Z = 99999; + + const panelStyle: React.CSSProperties | undefined = anchorRect + ? { + position: 'fixed', + top: anchorRect.top + HEADER_H, + left: anchorRect.left, + width: Math.max(anchorRect.width, MIN_W), + height: anchorRect.height - HEADER_H, + maxWidth: anchorRect.width, + zIndex: PANEL_Z, + // document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지 + background: 'var(--bg2, #24283b)', + color: 'var(--text, #c0caf5)', + border: '1px solid var(--border, rgba(122,162,247,0.15))', + } + : undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용 + + // 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함 + const backdropStyle: React.CSSProperties | undefined = anchorRect + ? { + position: 'fixed', + top: anchorRect.top, + left: anchorRect.left, + width: anchorRect.width, + height: anchorRect.height, + zIndex: PANEL_Z - 1, + background: 'rgba(0,0,0,0.45)', + backdropFilter: 'none', + } + : undefined; + + return ( + <> + {/* 배경 오버레이 (클릭 시 닫기) */} +
+ + {/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */} +
+ + {/* 검색 입력 */} +
+ + + + + + setQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') onClose(); + if (e.key === 'Enter' && sorted.length > 0) { + onSelect(sorted[0].market); + onClose(); + } + }} + /> + {query && ( + + )} + +
+ + {/* 컬럼 헤더 */} +
+ + 한글명 + 현재가 + 전일대비 + 거래대금 +
+ + {/* 목록 */} +
+ {loading && ( +
마켓 목록 로딩 중...
+ )} + {!loading && sorted.length === 0 && ( +
검색 결과가 없습니다
+ )} + {sorted.map(m => { + const tk = tickers[m.market]; + const isFav = favs.has(m.market); + const isActive = m.market === currentMarket; + const rate = tk ? tk.signed_change_rate * 100 : null; + const isUp = rate !== null && rate >= 0; + + return ( +
{ onSelect(m.market); onClose(); }} + > + {/* 즐겨찾기 */} + toggleFav(m.market, e)} + title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} + > + {isFav ? '★' : '☆'} + + + {/* 이름 */} + + {m.korean_name} + {m.symbol} + + + {/* 현재가 */} + + {tk ? tk.trade_price.toLocaleString() : '-'} + + + {/* 전일대비 */} + + {rate === null + ? '-' + : `${isUp ? '+' : ''}${rate.toFixed(2)}%`} + + + {/* 거래대금 */} + + {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} + +
+ ); + })} +
+ +
+ + ); +}; + +export default MarketSearchPanel; diff --git a/frontend/src/components/MobileChartDock.tsx b/frontend/src/components/MobileChartDock.tsx new file mode 100644 index 0000000..de0e4fd --- /dev/null +++ b/frontend/src/components/MobileChartDock.tsx @@ -0,0 +1,92 @@ +/** + * 모바일 차트 화면 하단 고정 도크 — 종목·매매·호가·그리기·차트 도구 + */ +import React from 'react'; + +export type MobileDockPanel = 'none' | 'market' | 'trade' | 'orderbook'; + +interface Props { + marketOpen: boolean; + rightOpen: boolean; + rightTab: 'trade' | 'orderbook'; + drawingTool: string; + onToggleMarket: () => void; + onOpenTrade: () => void; + onOpenOrderbook: () => void; + onClosePanels: () => void; + onTool: (tool: string) => void; +} + +const MobileChartDock: React.FC = ({ + marketOpen, + rightOpen, + rightTab, + drawingTool, + onToggleMarket, + onOpenTrade, + onOpenOrderbook, + onClosePanels, + onTool, +}) => { + const tradeActive = rightOpen && rightTab === 'trade'; + const obActive = rightOpen && rightTab === 'orderbook'; + + return ( + + ); +}; + +export default MobileChartDock; diff --git a/frontend/src/components/NotifyUiBindings.tsx b/frontend/src/components/NotifyUiBindings.tsx new file mode 100644 index 0000000..c354e9d --- /dev/null +++ b/frontend/src/components/NotifyUiBindings.tsx @@ -0,0 +1,62 @@ +/** + * 알림 Context 연동 UI (Provider 하위에서만 사용) + */ +import React from 'react'; +import TopMenuBar, { type MenuPage } from './TopMenuBar'; +import type { Theme } from '../types'; +import type { AuthSession } from '../utils/auth'; +import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; +import { useTradeNotification } from '../contexts/TradeNotificationContext'; + +interface NotifyTopMenuBarProps { + activePage: MenuPage; + theme: Theme; + onPage: (page: MenuPage) => void; + onTheme: () => void; + authUser?: AuthSession | null; + onLoginClick?: () => void; + onLogout?: () => void; + menuPermissions?: Record; + guestMode?: boolean; + tradeAlertPopupPosition?: string; + tradeAlertPopupLayout?: string; + tradeAlertPopupGridCols?: number; + onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void; + onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void; + onTradeAlertPopupGridCols?: (v: number) => void; +} + +export const NotifyTopMenuBar: React.FC = props => { + const { unreadCount, toastNotifications, dismissAllToasts } = useTradeNotification(); + return ( + props.onPage('notifications')} + onDismissAllNotifyPopups={dismissAllToasts} + tradeAlertPopupPosition={props.tradeAlertPopupPosition} + tradeAlertPopupLayout={props.tradeAlertPopupLayout} + tradeAlertPopupGridCols={props.tradeAlertPopupGridCols} + onTradeAlertPopupPosition={props.onTradeAlertPopupPosition} + onTradeAlertPopupLayout={props.onTradeAlertPopupLayout} + onTradeAlertPopupGridCols={props.onTradeAlertPopupGridCols} + /> + ); +}; + +import Toolbar, { type ToolbarProps } from './Toolbar'; + +export const ChartToolbarNotify: React.FC void }> = ({ + onOpenNotifyList, + ...toolbarProps +}) => { + const { unreadCount } = useTradeNotification(); + return ( + + ); +}; diff --git a/frontend/src/components/NumericParamInput.tsx b/frontend/src/components/NumericParamInput.tsx new file mode 100644 index 0000000..2086857 --- /dev/null +++ b/frontend/src/components/NumericParamInput.tsx @@ -0,0 +1,207 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import type { NumericParamSpec } from '../utils/indicatorParamSpec'; + +interface NumericParamInputProps { + value: number; + spec: NumericParamSpec; + disabled?: boolean; + className?: string; + onChange: (v: number) => void; +} + +function formatOption(v: number, decimal: boolean): string { + return decimal + ? (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.?0+$/, '')) + : String(Math.round(v)); +} + +/** 입력 문자열을 숫자만(선택적 -, 소수점) 허용하도록 필터 */ +function filterTyping(raw: string, allowNegative: boolean, decimal: boolean): string { + let s = raw.replace(/[^\d.\-]/g, ''); + if (!allowNegative) { + s = s.replace(/-/g, ''); + } else { + const first = s.startsWith('-') ? '-' : ''; + s = first + s.slice(first ? 1 : 0).replace(/-/g, ''); + } + if (!decimal) { + s = s.replace(/\./g, ''); + } else { + const parts = s.replace('-', '').split('.'); + if (parts.length > 2) { + s = (s.startsWith('-') ? '-' : '') + parts[0] + '.' + parts.slice(1).join(''); + } + } + return s; +} + +function isPartialNumber(s: string, allowNegative: boolean, decimal: boolean): boolean { + if (s === '' || s === '-') return true; + if (decimal && (s === '.' || s === '-.')) return true; + if (decimal) { + return allowNegative ? /^-?\d*\.?\d*$/.test(s) : /^\d*\.?\d*$/.test(s); + } + return allowNegative ? /^-?\d*$/.test(s) : /^\d*$/.test(s); +} + +const NumericParamInput: React.FC = ({ + value, + spec, + disabled = false, + className = 'ism-input', + onChange, +}) => { + const wrapRef = useRef(null); + const inputRef = useRef(null); + const [str, setStr] = useState(() => formatOption(value, spec.decimal)); + const [open, setOpen] = useState(false); + const prevValueRef = useRef(value); + const editingRef = useRef(false); + + useEffect(() => { + if (editingRef.current) return; + if (value !== prevValueRef.current) { + prevValueRef.current = value; + setStr(formatOption(value, spec.decimal)); + } + }, [value, spec.decimal]); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (wrapRef.current?.contains(e.target as Node)) return; + setOpen(false); + }; + const tid = window.setTimeout(() => { + document.addEventListener('mousedown', onDoc, true); + }, 0); + return () => { + window.clearTimeout(tid); + document.removeEventListener('mousedown', onDoc, true); + }; + }, [open]); + + const commit = useCallback((raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '' || trimmed === '-' || trimmed === '-.') { + setStr(formatOption(prevValueRef.current, spec.decimal)); + return; + } + const v = parseFloat(trimmed); + if (isNaN(v)) { + setStr(formatOption(prevValueRef.current, spec.decimal)); + return; + } + let clamped = Math.max(spec.min, Math.min(spec.max, v)); + if (!spec.decimal) clamped = Math.round(clamped); + else clamped = roundTo(clamped, spec.step < 1 ? 2 : 1); + prevValueRef.current = clamped; + onChange(clamped); + setStr(formatOption(clamped, spec.decimal)); + }, [spec, onChange]); + + const handleChange = (raw: string) => { + const filtered = filterTyping(raw, spec.allowNegative, spec.decimal); + if (!isPartialNumber(filtered, spec.allowNegative, spec.decimal)) return; + setStr(filtered); + }; + + const selectOption = (opt: number) => { + prevValueRef.current = opt; + onChange(opt); + setStr(formatOption(opt, spec.decimal)); + setOpen(false); + inputRef.current?.focus(); + }; + + const toggleList = () => { + if (disabled) return; + setOpen(prev => !prev); + inputRef.current?.focus(); + }; + + return ( +
e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + > + handleChange(e.target.value)} + onFocus={() => { + editingRef.current = true; + }} + onBlur={e => { + const related = e.relatedTarget as Node | null; + window.setTimeout(() => { + if (document.activeElement === inputRef.current) return; + if (related && wrapRef.current?.contains(related)) return; + editingRef.current = false; + commit(e.target.value); + setOpen(false); + }, 0); + }} + onKeyDown={e => { + if (e.key === 'ArrowDown' && !open) { + e.preventDefault(); + if (!disabled) setOpen(true); + return; + } + if (e.key === 'Enter') { + commit((e.target as HTMLInputElement).value); + setOpen(false); + } + if (e.key === 'Escape') setOpen(false); + }} + /> + + {open && !disabled && ( +
    + {spec.options.map(opt => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; + +function roundTo(v: number, decimals: number): number { + const f = 10 ** decimals; + return Math.round(v * f) / f; +} + +export default NumericParamInput; diff --git a/frontend/src/components/ObjectTree.tsx b/frontend/src/components/ObjectTree.tsx new file mode 100644 index 0000000..cbb58e7 --- /dev/null +++ b/frontend/src/components/ObjectTree.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import type { Drawing, DrawingToolId } from '../types'; +import { useDraggablePanel } from '../hooks/useDraggablePanel'; + +interface ObjectTreeProps { + drawings: Drawing[]; + onToggleVisible: (id: string) => void; + onRemove: (id: string) => void; + onClose: () => void; +} + +// 드로잉 타입 → 한글 이름 +const TOOL_LABEL: Partial> = { + cursor: '커서', + hline: '수평선', + hline_ray: '수평 레이', + vline: '수직선', + cross_line: '십자선', + info_line: '정보선', + trendline: '추세선', + extended_line: '연장 추세선', + ray: '레이', + arrow: '화살표 추세선', + fib: '피보나치 되돌림', + fib_ext: '피보나치 확장', + fib_channel: '피보나치 채널', + fibtz: '피보나치 타임존', + fib_speed_fan: '스피드 리지스턴스 팬', + fib_trend_time: '추세기반 피보나치 시간', + fib_circles: '피보나치 서클', + fib_spiral: '피보나치 스파이럴', + fib_speed_arc: '스피드 리지스턴스 아크', + fib_wedge: '피보나치 웻지', + pitchfork: '앤드류스 피치포크', + pitchfork_schiff: '쉬프 피치포크', + pitchfork_inside: '인사이드 피치포크', + gann_box: '갠 박스', + gann_square: '갠 스퀘어', + gann_fan: '갠 팬', + xabcd: 'XABCD 패턴', + cypher: '사이퍼 패턴', + abcd: 'ABCD 패턴', + head_shoulders: '헤드 앤 숄더', + channel: '추세 채널', + parallel_channel: '평행 채널', + disjoint_channel: '분리 채널', + flat_top_bottom: '플랫 탑/바텀 채널', + rect: '사각형', + rotated_rect: '회전 사각형', + circle: '원', + ellipse: '타원', + triangle: '삼각형', + parallelogram: '평행사변형', + polyline: '다각형', + arc: '호', + text: '텍스트', + callout: '콜아웃', + price_note: '가격 주석', + arrow_mark_up: '위쪽 화살표', + arrow_mark_down: '아래쪽 화살표', + price_label: '가격 레이블', + pencil: '자유곡선', + highlight: '형광펜', + trend_angle: '추세각', + long_position: '매수 포지션', + short_position: '숏 포지션', + forecast: '예측', + bar_pattern: '봉패턴', + ghost_feed: '고스트피드', + projection_tool: '프로젝션', + anchored_vwap: '앵커드 VWAP', + fixed_volume_profile: '볼륨 프로파일', + price_range: '가격범위', + wedge_pattern: '세모 패턴', + three_drives: '쓰리 드라이브 패턴', + elliott_impulse: '엘리엇 임펄스 파동', + elliott_correction: '엘리엇 코렉션 파동', + elliott_triangle_wave: '엘리엇 트라이앵글 웨이브', + elliott_double_combo: '엘리엇 다블콤보 파동', + arrow_mark: '화살표', + anchored_arrow: '앵커 화살표', + arrow_mark_left: '왼화살표', + arrow_mark_right: '오른화살표', + fixed_text: '고정위치문자', + note_text: '노트', + pin_mark: '핀', + table_tool: '테이블', + comment_mark: '코멘트', + guide_line: '길잡이', + flag_mark: '플래그', + measure: '가격 측정', + date_range: '날짜 범위', + date_price_range: '날짜&가격 범위', + data_window: '데이터 창', + zoom: '확대/축소', + magnifier: '돋보기', +}; + +// 아이콘 기본값 +const DefaultIcon = () => ; + +// 타입별 아이콘 +const ToolIcon: Partial> = { + cursor: , + hline: , + vline: , + trendline: , + ray: , + fib: , + fibtz: , + rect: , + circle: , + triangle: , + channel: , + measure: , + text: T, + pencil: , + pitchfork: , +}; + +const IcEye = ({ visible }: { visible: boolean }) => visible ? ( + + + + +) : ( + + + + +); + +const IcTrash = () => ( + + + + + +); + +const ObjectTree: React.FC = ({ drawings, onToggleVisible, onRemove, onClose }) => { + const { + panelRef, + dragging, + onHeaderPointerDown, headerTouchStyle, + panelStyle, + headerCursor, + } = useDraggablePanel({ centerOnMount: true }); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
e.stopPropagation()} + > +
+ 오브젝트 트리 + +
+ + {drawings.length === 0 ? ( +
드로잉이 없습니다
+ ) : ( +
+ {[...drawings].reverse().map((d, idx) => { + const visible = d.visible !== false; + return ( +
+ {/* 색상 스와치 */} + + + {/* 타입 아이콘 */} + + {ToolIcon[d.type as DrawingToolId] ?? } + + + {/* 이름 */} + + {TOOL_LABEL[d.type as DrawingToolId] ?? d.type} + {d.text && "{d.text}"} + + + {/* 번호 (역순) */} + #{drawings.length - idx} + + {/* 가시성 토글 */} + + + {/* 삭제 */} + +
+ ); + })} +
+ )} +
+
+ ); +}; + +export default ObjectTree; diff --git a/frontend/src/components/OrderbookPanel.tsx b/frontend/src/components/OrderbookPanel.tsx new file mode 100644 index 0000000..c284e4f --- /dev/null +++ b/frontend/src/components/OrderbookPanel.tsx @@ -0,0 +1,362 @@ +/** + * 실시간 호가 — 증권앱형 통합 레이아웃 + * 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계 + */ +import React, { memo, useState, useEffect } from 'react'; +import { formatNowClock, useDisplayTimezone } from '../utils/timezone'; +import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook'; +import type { RecentTrade } from '../hooks/useUpbitRecentTrades'; +import { getKoreanName } from '../utils/marketNameCache'; + +function fmtPrice(p: number): string { + if (p == null || !isFinite(p)) return '-'; + if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (p >= 1) return p.toFixed(2); + return p.toFixed(6); +} + +function fmtSize(s: number): string { + if (s == null || !isFinite(s)) return '-'; + if (s >= 1_000_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); + if (s >= 1) return s.toFixed(4); + return s.toFixed(6); +} + +function fmtTradeVol(v: number): string { + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`; + if (v >= 1_000) return Math.round(v).toLocaleString('ko-KR'); + return v.toFixed(4); +} + +function fmtPct(price: number, prevClose: number): string { + if (!prevClose) return ''; + const rate = ((price - prevClose) / prevClose) * 100; + return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`; +} + +function fmtTotalSize(s: number): string { + if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (s >= 1) return s.toFixed(2); + return s.toFixed(4); +} + +function coinCode(market: string): string { + return market.replace(/^KRW-/, ''); +} + +function pctClass(pct: string): string { + if (pct.startsWith('+')) return 'ob-td-pct--up'; + if (pct.startsWith('-')) return 'ob-td-pct--dn'; + return ''; +} + +function useClock(): string { + const tz = useDisplayTimezone(); + const [t, setT] = useState(() => formatNowClock(tz, true)); + useEffect(() => { + const tick = () => setT(formatNowClock(tz, true)); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [tz]); + return t; +} + +const COLGROUP = ( + + + + + +); + +const AskTableRow = memo(function AskTableRow({ + unit, prevClose, onClick, +}: { + unit: OrderbookDisplayUnit; + prevClose: number; + onClick?: (price: number, type: 'ask' | 'bid') => void; +}) { + const pct = fmtPct(unit.price, prevClose); + return ( + onClick(unit.price, 'ask') : undefined}> + +
+
+ {fmtSize(unit.size)} +
+ + {fmtPrice(unit.price)} + {pct} + + ); +}); + +/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */ +const BidTableRow = memo(function BidTableRow({ + unit, prevClose, onClick, +}: { + unit: OrderbookDisplayUnit; + prevClose: number; + onClick?: (price: number, type: 'ask' | 'bid') => void; +}) { + const pct = fmtPct(unit.price, prevClose); + return ( + onClick(unit.price, 'bid') : undefined}> + {pct} + {fmtPrice(unit.price)} + +
+
+ {fmtSize(unit.size)} +
+ + + ); +}); + +function QuoteHeader({ + market, tickerInfo, prevClose, totalAskSize, totalBidSize, wsStatus, +}: { + market: string; + tickerInfo?: OrderbookTickerInfo; + prevClose: number; + totalAskSize: number; + totalBidSize: number; + wsStatus: WsStatus; +}) { + const price = tickerInfo?.tradePrice ?? 0; + const change = price > 0 && prevClose > 0 ? price - prevClose : (tickerInfo?.changePrice ?? 0); + const rate = tickerInfo?.changeRate != null + ? tickerInfo.changeRate * 100 + : (prevClose > 0 && price > 0 ? (change / prevClose) * 100 : 0); + const isUp = change >= 0; + const vol = tickerInfo?.accTradeVolume24 ?? 0; + const bidRatio = totalAskSize + totalBidSize > 0 + ? (totalBidSize / (totalAskSize + totalBidSize)) * 100 + : 50; + const code = coinCode(market); + + return ( +
+
+ {getKoreanName(market)} +
+ + {price > 0 ? fmtPrice(price) : '-'} + + + {change >= 0 ? '+' : ''}{Math.abs(change) >= 1000 + ? Math.round(change).toLocaleString('ko-KR') + : change.toFixed(change >= 1 ? 0 : 2)} + + + {rate >= 0 ? '+' : ''}{rate.toFixed(2)}% + +
+
+
+
+ 거래량 + + {vol > 0 ? `${Math.round(vol).toLocaleString('ko-KR')} ${code}` : '-'} + +
+
+ 매수비 + {bidRatio.toFixed(2)}% +
+ +
+
+ ); +} + +function WsDot({ status }: { status: WsStatus }) { + const cls = status === 'connected' ? 'ob-dot--ok' + : status === 'connecting' ? 'ob-dot--wait' : 'ob-dot--err'; + return ; +} + +function StatsRail({ tickerInfo, prevClose }: { + tickerInfo: OrderbookTickerInfo; + prevClose: number; +}) { + return ( + + ); +} + +function TradesPanel({ trades, strength }: { trades: RecentTrade[]; strength: number | null }) { + return ( + + ); +} + +export interface OrderbookTickerInfo { + tradePrice: number | null; + changeRate: number | null; + changePrice?: number | null; + accTradePrice24: number; + accTradeVolume24?: number | null; + highPrice: number | null; + lowPrice: number | null; + openingPrice: number | null; +} + +export interface OrderbookPanelProps { + market: string; + asks: OrderbookDisplayUnit[]; + bids: OrderbookDisplayUnit[]; + totalAskSize: number; + totalBidSize: number; + wsStatus: WsStatus; + bestAsk: number; + bestBid: number; + spread: number; + spreadPct: number; + prevClose: number; + tickerInfo?: OrderbookTickerInfo; + recentTrades?: RecentTrade[]; + tradeStrength?: number | null; + onRowClick?: (price: number, rowType: 'ask' | 'bid') => void; +} + +const SUB_TABS = ['호가', '체결', '일별', '차트'] as const; + +export const OrderbookPanel = memo(function OrderbookPanel({ + market, asks, bids, totalAskSize, totalBidSize, wsStatus, + prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick, +}: OrderbookPanelProps) { + const isEmpty = asks.length === 0 && bids.length === 0; + const midPrice = tickerInfo?.tradePrice ?? 0; + const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : ''; + const clock = useClock(); + const isUp = (tickerInfo?.changeRate ?? 0) >= 0; + + return ( +
+ + + + + {isEmpty ? ( +
+
+ 호가 데이터 로딩 중... +
+ ) : ( +
+ {/* 매도 구간 + 우측 시세 */} +
+
+
+ + {COLGROUP} + + {asks.map((unit, i) => ( + + ))} + +
+
+
+ {tickerInfo && } +
+ + {/* 현재가 (가운데 열 정렬) */} + + {COLGROUP} + + + + + +
+ + {midPrice > 0 ? fmtPrice(midPrice) : '-'} + {midPct && {midPct}} + +
+ + {/* 매수 구간 + 좌측 체결 */} +
+ +
+
+ + {COLGROUP} + + {bids.map((unit, i) => ( + + ))} + +
+
+
+
+
+ )} + + {!isEmpty && ( +
+
+ {fmtTotalSize(totalAskSize)} +
+
{clock}
+
+ {fmtTotalSize(totalBidSize)} +
+
+ )} +
+ ); +}); + +export default OrderbookPanel; diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx new file mode 100644 index 0000000..2a51181 --- /dev/null +++ b/frontend/src/components/PaneLegend.tsx @@ -0,0 +1,1024 @@ +/** + * PaneLegend + * 각 서브 pane 좌측 상단에 인디케이터 명칭 + 현재 크로스헤어 값 표시. + * 드래그 핸들(⠿) / 전체화면 / 닫기 버튼 포함. + * + * ─── 패인 인덱스 구조 ─────────────────────────────────────────────────────── + * Pane 0 : 메인 캔들차트 + * Pane 1 : 볼륨 (항상 고정, ChartManager._getNextSubPane 이 2부터 시작하는 이유) + * Pane 2+ : 보조지표 (RSI=2, MACD=3, ...) + * ──────────────────────────────────────────────────────────────────────────── + */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import type { MouseEventParams, Time } from 'lightweight-charts'; +import type { IndicatorConfig } from '../types'; +import { ChartManager } from '../utils/ChartManager'; +import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry'; +import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout'; +import { formatBbLegendLabel } from '../utils/bollingerConfig'; +import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag'; +import { getPaneHostId } from '../utils/indicatorPaneMerge'; + +// ── 타입 ───────────────────────────────────────────────────────────────────── +interface PaneItem { + id: string; + paneIndex: number; + label: string; + plotColors: string[]; + hidden?: boolean; + /** 화면에 보이는 sub-pane 위치 (orphan pane 인덱스와 분리) */ + layoutTopY?: number; + layoutHeight?: number; +} + +const MIN_SUB_PANE_HEIGHT = 12; + +interface DomPaneLayout { + topY: number; + height: number; +} + +/** + * LWC 테이블 DOM 에서 보조지표 pane 행만 읽음 (pane index 무관). + * orphan 빈 pane 행이 있으면 제외하고 하단 expectCount 개만 사용. + */ +function getDomSubIndicatorLayouts( + containerEl: HTMLElement, + expectCount: number, +): DomPaneLayout[] { + const containerRect = containerEl.getBoundingClientRect(); + const rows = Array.from(containerEl.querySelectorAll('tr')) + .map(tr => tr.getBoundingClientRect()) + .filter(rect => rect.height > MIN_SUB_PANE_HEIGHT) + .sort((a, b) => a.top - b.top); + + // pane 0=캔들(첫 행). 둘째 행이 낮으면 거래량 pane → 보조지표는 그 다음부터 + let startIdx = 1; + if (rows.length > 1 && rows[1].height < Math.min(100, rows[0].height * 0.35)) { + startIdx = 2; + } + + let subs = rows.slice(startIdx).map(rect => ({ + topY: Math.round(rect.top - containerRect.top), + height: Math.round(rect.height), + })); + + if (expectCount > 0 && subs.length > expectCount) { + subs = subs.slice(subs.length - expectCount); + } + return subs; +} + +/** 보조지표 1개 = 1 화면 슬롯 (병합 시 여러 item 이 한 슬롯) */ +interface VisualSlot { + items: PaneItem[]; +} + +interface PaneCapture { + dataUrl: string; + cssWidth: number; + cssHeight: number; +} + +type DropMode = 'reorder' | 'merge'; + +interface DragState { + draggingId: string; + ghostLabel: string; + ghostX: number; + ghostCursorY: number; + dropBeforeIndex: number; + dropMode: DropMode; + mergeTargetId: string | null; + paneCapture: PaneCapture | null; +} + +export interface PaneLegendProps { + manager: ChartManager; + indicators: IndicatorConfig[]; + containerEl: HTMLElement; + onHoverName: (id: string, sx: number, sy: number) => void; + onLeaveName: () => void; + onDoubleClick?: (id: string) => void; + onReorder?: (fromId: string, insertBeforeId: string | null) => void; + /** 보조지표 pane 병합 */ + onMerge?: (fromId: string, intoId: string) => void; + /** 병합 pane 분리 */ + onSplit?: (hostId: string) => void; + /** 지표 단독 전체화면 확장 */ + onExpand?: (id: string) => void; + /** 지표 제거 (X 버튼) */ + onRemove?: (id: string) => void; + /** 보조지표 pane 복사 */ + onDuplicate?: (id: string) => void; + /** 현재 단독 전체화면 중인 지표 id */ + focusedId?: string | null; + /** 전체화면 → 전체 보기 복원 */ + onRestore?: () => void; +} + +// ── 지그재그 배경색 — 홀짝 2단계로만 번갈아 적용 ─────────────────────────── +const PANE_BG_COLORS = [ + 'rgba(255, 255, 255, 0.030)', // 짝수(0,2,4…): 아주 살짝 밝음 + 'rgba(255, 255, 255, 0.000)', // 홀수(1,3,5…): 기본 배경(투명) +]; + +// ── 캔버스 캡처 ─────────────────────────────────────────────────────────────── +function capturePaneRegion( + containerEl: HTMLElement, + paneTopY: number, + paneHeight: number, +): PaneCapture | null { + const rows = Array.from(containerEl.querySelectorAll('tr')); + let targetRow: HTMLTableRowElement | null = null; + for (const row of rows) { + if (Math.abs(row.offsetTop - paneTopY) < 8) { targetRow = row; break; } + } + + const canvases = Array.from( + (targetRow ?? containerEl).querySelectorAll('canvas'), + ).filter(c => c.width > 0 && c.height > 0); + + if (canvases.length === 0) return null; + const cssWidth = containerEl.clientWidth; + + if (targetRow) { + const ref = canvases[0]; + const off = document.createElement('canvas'); + off.width = ref.width; off.height = ref.height; + const ctx = off.getContext('2d'); + if (!ctx) return null; + for (const c of canvases) { try { ctx.drawImage(c, 0, 0); } catch { /* skip */ } } + try { return { dataUrl: off.toDataURL('image/webp', 0.92), cssWidth, cssHeight: paneHeight }; } + catch { return null; } + } + + const ref = canvases[0]; + const scaleY = ref.height / (containerEl.clientHeight || 1); + const srcY = Math.round(paneTopY * scaleY); + const srcH = Math.round(paneHeight * scaleY); + if (srcH <= 0) return null; + const off = document.createElement('canvas'); + off.width = ref.width; off.height = srcH; + const ctx = off.getContext('2d'); + if (!ctx) return null; + for (const c of canvases) { + const cs = c.height / (containerEl.clientHeight || 1); + try { ctx.drawImage(c, 0, Math.round(paneTopY * cs), c.width, Math.round(paneHeight * cs), 0, 0, ref.width, srcH); } + catch { /* skip */ } + } + try { return { dataUrl: off.toDataURL('image/webp', 0.92), cssWidth, cssHeight: paneHeight }; } + catch { return null; } +} + +// ── 헬퍼 ───────────────────────────────────────────────────────────────────── + +/** + * 드래그 중 각 pane의 translateY 오프셋 계산. + * draggingIdx → dropBeforeIdx 방향으로 이동 시 중간 pane들을 반대 방향으로 밀어낸다. + */ +function computeShiftY( + itemIdx: number, // 이 pane의 현재 배열 인덱스 + draggingIdx: number, // 드래그 중인 pane의 인덱스 + dropBefore: number, // 드롭 목표: 이 인덱스 앞에 삽입 + dragHeight: number, // 드래그 중인 pane의 높이(px) +): number { + if (itemIdx === draggingIdx) return 0; // 드래그 중인 항목: 고스트로 대체 + if (draggingIdx < dropBefore) { + // 아래로 이동 → 사이 pane 들을 위로 + if (itemIdx > draggingIdx && itemIdx < dropBefore) return -dragHeight; + } else if (draggingIdx > dropBefore) { + // 위로 이동 → 사이 pane 들을 아래로 + if (itemIdx >= dropBefore && itemIdx < draggingIdx) return dragHeight; + } + return 0; +} + +function makeLabel(config: IndicatorConfig): string { + if (config.type === 'BollingerBands') { + return formatBbLegendLabel(config.params ?? {}); + } + const name = getIndicatorChartTitle(config.type); + const def = getIndicatorDef(config.type); + const params = (config.params ?? def?.defaultParams ?? {}) as Record; + const plots = config.plots ?? def?.plots ?? []; + + const orderedKeys: string[] = []; + for (let i = 0; i < plots.length; i++) { + for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) { + if (!orderedKeys.includes(k)) orderedKeys.push(k); + } + } + for (const k of getGlobalParamKeys(config.type, params, plots)) { + if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k); + } + + const nums = orderedKeys + .map(k => params[k]) + .filter((v): v is number => typeof v === 'number') + .slice(0, 3); + return nums.length ? `${name} ${nums.join(', ')}` : name; +} + +function buildVisualSlots(items: PaneItem[], indicators: IndicatorConfig[]): VisualSlot[] { + const slots: VisualSlot[] = []; + const hostSlotIdx = new Map(); + + for (const item of items) { + const ind = indicators.find(i => i.id === item.id); + if (ind?.mergedWith) { + const hostId = getPaneHostId(item.id, indicators); + const idx = hostSlotIdx.get(hostId); + if (idx !== undefined) slots[idx].items.push(item); + continue; + } + const idx = slots.length; + slots.push({ items: [item] }); + hostSlotIdx.set(item.id, idx); + hostSlotIdx.set(getPaneHostId(item.id, indicators), idx); + } + return slots; +} + +/** 각 슬롯(호스트 paneIndex) ↔ LWC pane 레이아웃 직접 매칭 — 배열 순서·orphan pane 무관 */ +function assignLayoutsByPaneIndex( + slots: VisualSlot[], + layouts: Array<{ paneIndex: number; topY: number; height: number }>, +): void { + const byPane = new Map(layouts.map(l => [l.paneIndex, l])); + for (const slot of slots) { + const host = slot.items[0]; + if (!host || host.paneIndex < 2) continue; + const lay = byPane.get(host.paneIndex); + if (!lay || lay.height < MIN_SUB_PANE_HEIGHT) continue; + for (const item of slot.items) { + item.layoutTopY = lay.topY; + item.layoutHeight = lay.height; + } + } +} + +function buildPaneItems( + manager: ChartManager, + containerEl: HTMLElement | null, + indicators: IndicatorConfig[], + optPaneMap?: Map, +): PaneItem[] { + const paneMap = new Map(optPaneMap ?? []); + for (const info of manager.getIndicatorPaneInfo()) { + paneMap.set(info.id, info.paneIndex); + } + + const items: PaneItem[] = []; + for (const ind of indicators) { + const def = getIndicatorDef(ind.type); + if (!def || def.overlay || def.returnsMarkers) continue; + const plotColors = (ind.plots ?? def.plots ?? []).map(p => (p.color ?? '#aaa').slice(0, 7)); + + let chartPane = paneMap.get(ind.id) ?? -1; + if (ind.mergedWith) { + const hostPane = paneMap.get(ind.mergedWith); + if (hostPane != null && hostPane >= 2) chartPane = hostPane; + } + + items.push({ + id: ind.id, + paneIndex: chartPane, + label: makeLabel(ind), + plotColors, + hidden: ind.hidden === true, + }); + } + + if (items.length === 0) return []; + + for (const item of items) { + const lay = manager.getIndicatorPaneScreenLayout(item.id); + if (lay) { + item.layoutTopY = lay.topY; + item.layoutHeight = lay.height; + } + } + + const slots = buildVisualSlots(items, indicators); + assignLayoutsByPaneIndex(slots, manager.getPaneLayouts()); + + if (containerEl?.isConnected) { + const missing = items.filter(i => i.layoutTopY == null); + if (missing.length > 0) { + const domSubs = getDomSubIndicatorLayouts(containerEl, slots.length); + for (let i = 0; i < slots.length && i < domSubs.length; i++) { + if (slots[i].items.every(it => it.layoutTopY != null)) continue; + const dom = domSubs[i]; + for (const item of slots[i].items) { + if (item.layoutTopY != null) continue; + item.layoutTopY = dom.topY; + item.layoutHeight = dom.height; + } + } + } + } + + return items.filter(i => i.layoutTopY != null && i.layoutHeight != null); +} + +function paneItemLayout( + item: PaneItem, + layouts: Array<{ paneIndex: number; topY: number; height: number }>, +): { topY: number; height: number } | null { + if (item.layoutTopY != null && item.layoutHeight != null) { + return { topY: item.layoutTopY, height: item.layoutHeight }; + } + const l = layouts.find( + x => x.paneIndex === item.paneIndex && x.height > MIN_SUB_PANE_HEIGHT, + ); + return l ? { topY: l.topY, height: l.height } : null; +} + +// ── 아이콘 SVG ──────────────────────────────────────────────────────────────── +/** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */ +const IconExpand = () => ( + + {/* 좌하단 → 좌하 */} + + + {/* 우상단 → 우상 */} + + + +); + +/** ⤢ 전체화면 복원 — 네 꼭지점 안쪽 방향 화살표 */ +const IconRestore = () => ( + + {/* 좌하단 → 중앙 */} + + + {/* 우상단 → 중앙 */} + + + +); + +const IconMerge = () => ( + + + + +); + +/** 그래프 복사 — 겹친 사각형 */ +const IconCopy = () => ( + + + + +); + +const IconSplit = () => ( + + + + + +); + +const IconClose = () => ( + + + + +); + +// ── 컴포넌트 ────────────────────────────────────────────────────────────────── +const PaneLegend: React.FC = ({ + manager, indicators, containerEl, + onHoverName, onLeaveName, onDoubleClick, + onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore, +}) => { + const [paneItems, setPaneItems] = useState([]); + const [paneLayouts, setPaneLayouts] = useState>([]); + const [values, setValues] = useState>({}); + const [dragState, setDragState] = useState(null); + const retryRef = useRef[]>([]); + const dragRef = useRef<{ + id: string; + dropBeforeIndex: number; + dropMode: DropMode; + mergeTargetId: string | null; + } | null>(null); + + const paneItemsRef = useRef(paneItems); + const paneLayoutsRef = useRef(paneLayouts); + const containerElRef = useRef(containerEl); + const onReorderRef = useRef(onReorder); + const onMergeRef = useRef(onMerge); + const onSplitRef = useRef(onSplit); + const onDuplicateRef = useRef(onDuplicate); + const indicatorsRef = useRef(indicators); + + useEffect(() => { paneItemsRef.current = paneItems; }, [paneItems]); + useEffect(() => { paneLayoutsRef.current = paneLayouts; }, [paneLayouts]); + useEffect(() => { containerElRef.current = containerEl; }, [containerEl]); + useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]); + useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]); + useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]); + useEffect(() => { onDuplicateRef.current = onDuplicate; }, [onDuplicate]); + useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); + + const syncPaneItems = useCallback(() => { + const el = containerElRef.current; + if (!el?.isConnected) return; + const map = manager.getIndicatorPaneMapping(); + setPaneLayouts(manager.getPaneLayouts()); + setPaneItems(buildPaneItems(manager, el, indicators, map)); + }, [indicators, manager]); + + const refreshLayouts = useCallback(() => { + setPaneLayouts(manager.getPaneLayouts()); + }, [manager]); + + const scheduleRetry = useCallback(() => { + syncPaneItems(); + retryRef.current.forEach(clearTimeout); + retryRef.current = [ + setTimeout(syncPaneItems, 0), + setTimeout(syncPaneItems, 16), + setTimeout(syncPaneItems, 50), + setTimeout(syncPaneItems, 120), + setTimeout(syncPaneItems, 250), + setTimeout(syncPaneItems, 500), + setTimeout(syncPaneItems, 900), + setTimeout(syncPaneItems, 1200), + setTimeout(syncPaneItems, 1800), + ]; + }, [syncPaneItems]); + + useEffect(() => { scheduleRetry(); return () => retryRef.current.forEach(clearTimeout); }, [scheduleRetry]); + + useEffect(() => { + const unsub = manager.subscribePaneLayout(() => scheduleRetry()); + return unsub; + }, [manager, scheduleRetry]); + + useEffect(() => { + const onResize = () => { + refreshLayouts(); + syncPaneItems(); + }; + const ro = new ResizeObserver(onResize); + ro.observe(containerEl); + return () => ro.disconnect(); + }, [containerEl, refreshLayouts, syncPaneItems]); + + useEffect(() => { + const unsub = manager.subscribeCrosshair((p: MouseEventParams