diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3a5bba9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.idea +.claude +.env +*.log +streamlit.log +streamlit.err.log +__pycache__ +*.pyc +*.pyo +.DS_Store +data/ diff --git a/.gitignore b/.gitignore index 63d6241..4844887 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ streamlit.log streamlit.err.log .env +__pycache__/ +*.pyc +data/*.db +data/*.db-* +data/pgdata/ diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..d3ff10a --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,15 @@ +[theme] +base = "light" +primaryColor = "#3b82f6" +backgroundColor = "#f3f4f6" +secondaryBackgroundColor = "#ffffff" +textColor = "#111827" +font = "sans-serif" + +[browser] +gatherUsageStats = false + +[server] +headless = true +enableCORS = false +enableXsrfProtection = false diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..7711b1b --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,118 @@ +# junggomoa.com 배포 가이드 (Traefik v2.11 + PostgreSQL) + +서버 (`183.99.177.40`) 에 이미 떠있는 Traefik 이 80/443 + Let's Encrypt HTTPS + 글로벌 +web→websecure redirect 까지 처리. 이 compose 는 **포트 직접 노출 안 하고** Traefik +labels 만 붙여 attach. + +## 0. 서버 환경 (이미 확인됨) + +- **OS**: Ubuntu (Linux 6.8.0) +- **k3s** 1대 + **docker compose** 혼합 운영 +- **Traefik** v2.11 — 컨테이너명 `traefik`, 네트워크 `traefik-net`, certresolver `le` +- **사설 docker registry** `localhost:5000` +- **Gitea** + **Act Runner** (CI 가능) +- 호스트 5432 는 `invyone-db` 가 사용 중 → 우리 Postgres 는 내부 네트워크로만 + +## 1. 소스 업로드 (로컬 → 서버) + +```bash +# 로컬 Mac 에서 +rsync -avz --exclude='.git' --exclude='.idea' --exclude='__pycache__' \ + --exclude='data/pgdata' --exclude='data/*.db' --exclude='*.log' \ + /Users/chpark/tradeing/ chpark@183.99.177.40:~/tradeing/ +``` + +## 2. 환경변수 (.env) + +서버 `~/tradeing/.env`: +``` +POSTGRES_PASSWORD=강한비밀번호로변경 +TELEGRAM_TOKEN=실제토큰 # 최초 시드용 (이후엔 설정 메뉴에서 변경) +TELEGRAM_CHAT_ID=실제chatid +``` + +## 3. 빌드 + 기동 + +```bash +cd ~/tradeing +chmod +x deploy.sh +./deploy.sh +# 또는: +docker compose build +docker compose up -d +docker compose ps +docker compose logs -f app +``` + +Traefik 이 자동으로: +- `http://junggomoa.com` → `https://` redirect +- `https://junggomoa.com` → 이 컨테이너 8501 로 프록시 +- Let's Encrypt 인증서 자동 발급/갱신 + +## 4. 동작 확인 + +```bash +# 컨테이너 안 +docker compose exec app curl -fsS http://127.0.0.1:8501/_stcore/health + +# Traefik 라우팅 등록 확인 +curl -I https://junggomoa.com +``` + +브라우저: + +## 5. 데이터베이스 (PostgreSQL) + +이번 컴포즈에 같이 올라가는 `tradeing-postgres` 컨테이너가: +- 진입 신호 → `trades` 테이블에 `status=open` 으로 INSERT +- 손절 hit → `status=stop_loss`, `pnl_pct` 자동 계산 +- 30m/1h 반대 신호 → `status=reversal` +- 캔들 forming 중 신호 사라짐 → `status=cancelled` +- 발사된 모든 raw signal → `signal_events` 에 누적 + +**볼륨**: `~/tradeing/data/pgdata` (서버 영속). 백업 권장: +```bash +docker compose exec postgres pg_dump -U tradeing tradeing > ~/backup/tradeing-$(date +%F).sql +``` + +**psql 직접 접근**: +```bash +docker compose exec postgres psql -U tradeing -d tradeing +\dt # 테이블 목록 +SELECT count(*), status FROM trades GROUP BY status; +``` + +## 6. 운영 + +| 작업 | 명령 | +|---|---| +| 로그 보기 | `docker compose logs -f app` | +| 재시작 | `docker compose restart app` | +| 정지 | `docker compose down` ( `-v` 붙이면 DB 볼륨까지 삭제, **주의**) | +| 코드 변경 후 재배포 | `docker compose build app && docker compose up -d app` | +| Postgres 진입 | `docker compose exec postgres psql -U tradeing -d tradeing` | +| settings DB 백업 | `cp ~/tradeing/data/settings.db ~/backup/settings-$(date +%F).db` | +| Postgres 덤프 | `docker compose exec postgres pg_dump -U tradeing tradeing > ~/backup/$(date +%F).sql` | + +## 7. 화면 메뉴 + +- **📊 대시보드** — 차트 +- **📈 트레이드 이력** — 진입/청산 이력 + 누적 PnL 차트 + 시간축/사유별 통계 +- **⚙️ 설정** — 텔레그램 토큰/Chat ID, 모니터링 심볼, 알림 시간축, 쿨다운, 손절가 비율, 알림/리포트 ON-OFF (저장 즉시 반영) + +## 8. 보안 권고 + +- SSH key 등록 (`ssh-copy-id`) 후 비밀번호 인증 disable 권장 +- 노출됐던 SSH 비밀번호 변경 +- `.env` 의 `POSTGRES_PASSWORD` 는 추측 불가능한 값으로 (compose 에서 외부 노출 안 되긴 하지만 컨테이너 escape 등 대비) + +## 9. 트러블슈팅 + +| 증상 | 확인 | +|---|---| +| `network "traefik" declared as external, but could not be found` | `docker network ls` 에 실제 이름 확인 → compose 파일 수정 | +| Traefik dashboard 에 라우터 없음 | `docker compose logs traefik` (다른 디렉토리) → label 오타 / certresolver 이름 | +| 인증서 발급 실패 | DNS A 레코드 / 80 포트 외부 접근 가능 / Traefik certresolver 설정 | +| WebSocket 끊김 | Traefik 은 기본적으로 WS 지원 — 별도 설정 불필요. 다른 미들웨어 (rate limit 등) 가 끊는지 확인 | +| trades 테이블에 데이터 안 쌓임 | `docker compose logs app | grep trades_db` → connect 실패 메시지 / 알림이 발사 자체가 안 되는 건지 | +| 텔레그램 안 옴 | 설정 메뉴에서 토큰 재입력 → 테스트 발송 버튼 / 컨테이너 로그 확인 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fadd82d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONIOENCODING=utf-8 \ + PIP_NO_CACHE_DIR=1 \ + TZ=Asia/Seoul + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata curl fonts-noto-cjk fonts-noto-cjk-extra \ + && ln -sf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --upgrade pip && pip install -r /app/requirements.txt + +COPY . /app + +RUN mkdir -p /app/data +VOLUME ["/app/data"] + +ENV SETTINGS_DB_PATH=/app/data/settings.db \ + STREAMLIT_SERVER_HEADLESS=true \ + STREAMLIT_SERVER_ADDRESS=0.0.0.0 \ + STREAMLIT_SERVER_PORT=8501 \ + STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + STREAMLIT_SERVER_ENABLE_CORS=false \ + STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION=false + +EXPOSE 8501 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -f http://127.0.0.1:8501/_stcore/health || exit 1 + +CMD ["streamlit", "run", "app_streamlit.py"] diff --git a/SOURCE_ANALYSIS.md b/SOURCE_ANALYSIS.md new file mode 100644 index 0000000..34002f0 --- /dev/null +++ b/SOURCE_ANALYSIS.md @@ -0,0 +1,230 @@ +# BTC/ETH Futures Dashboard — 소스 분석 + +Streamlit 기반 Binance Futures 실시간 모니터링 대시보드. 차트 시각화 + 다중 시간축 자동 알림 (Telegram) + 일일 신호 통계 리포트를 한 프로세스에서 처리한다. + +--- + +## 1. 파일 구성 + +| 파일 | 역할 | +|---|---| +| [app_streamlit.py](app_streamlit.py) | 메인 앱 — 데이터 수집 / 지표 계산 / 신호 생성 / 차트 빌드 / 알림 스레드 / 사이드바 메뉴 (대시보드 / 트레이드 이력 / 설정) | +| [alert_state.py](alert_state.py) | 멀티 rerun 환경에서 살아남는 mutable 상태 (`sys.modules` 캐싱 활용) | +| [settings_db.py](settings_db.py) | SQLite key-value 설정 영속 저장 (텔레그램 토큰, 심볼, 시간축, 쿨다운, 손절가 비율, 알림 ON/OFF) | +| [trades_db.py](trades_db.py) | PostgreSQL 트레이드 lifecycle — `trades` (진입→청산), `signal_events` (raw 신호 로그). DATABASE_URL 미설정 시 silent no-op | +| [Dockerfile](Dockerfile) / [docker-compose.yml](docker-compose.yml) | python:3.11-slim 컨테이너 + Postgres 16 + Traefik labels (junggomoa.com) | +| [DEPLOY.md](DEPLOY.md) | 서버 배포 절차 | +| [requirements.txt](requirements.txt) | streamlit, pandas, numpy, plotly, ta, requests, python-dotenv, urllib3, psycopg2-binary | +| [assets/override.css](assets/override.css) | 라이트모드 강제 CSS | +| [.env.example](.env.example) | 환경변수 템플릿 (`TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`) | + +--- + +## 2. 아키텍처 전체 흐름 + +``` +┌─────────────── Streamlit 메인 프로세스 ───────────────┐ +│ │ +│ ┌── UI Thread (main()) ──┐ ┌── Background Threads ─┐ +│ │ 매 rerun 마다 실행: │ │ daemon thread × 2 │ +│ │ - build_chart() │ │ │ +│ │ - st.plotly_chart │ │ _alert_loop │ +│ │ - time.sleep + rerun │ │ (30초 주기) │ +│ └────────────────────────┘ │ │ +│ │ │ _daily_report_loop │ +│ ▼ │ (60초 주기, KST 자정)│ +│ ┌─────────────┐ └───────┬───────────────┘ +│ │ alert_state │ ◀───────────────────┘ +│ │ (sys.modules│ +│ │ 캐싱 보존) │ +│ └─────────────┘ +│ │ +│ 외부 호출: Binance Futures API + Telegram Bot API │ +└─────────────────────────────────────────────────────────┘ +``` + +**핵심 설계 결정**: Streamlit 의 매 rerun 은 메인 스크립트를 새 namespace 에서 재실행해 모듈 최상단 globals 가 모두 초기화된다. mutable 상태 ( dedup, 진입 추적, 스레드 가드 ) 는 [alert_state.py](alert_state.py) 라는 별도 모듈에 두어 `sys.modules` 캐싱으로 process lifetime 동안 보존되도록 분리. + +--- + +## 3. 모듈 상세 + +### 3.1 `alert_state.py` — 보존 상태 + +| 변수 | 타입 | 용도 | +|---|---|---| +| `last_alert` | `dict[(interval, key), float]` | 알림 cooldown (default 600s) | +| `last_fired_candle` | `dict[(interval, key), Timestamp]` | per-candle dedup | +| `long_entry` / `short_entry` | `dict[interval, record]` | 진입 추적 (손절/청산 권고용) | +| `pending_groups` | `list[dict]` | forming candle 발사 후 신호 재검증 큐 | +| `synced_intervals` | `set[str]` | 재시작 직후 역사적 신호 burst 차단용 sync 플래그 | +| `signal_seen_count` | `dict[(interval, sig), {candle_time, count}]` | forming candle 의 연속 True polling 카운트 (false alert 차단) | +| `alert_lock` | `threading.Lock` | UI ↔ alert thread 공유 변수 보호 | +| `alert_started` / `daily_report_started` | `bool` | 스레드 중복 기동 가드 | + +### 3.2 `app_streamlit.py` 섹션 분해 + +#### (1) 환경 설정 & 페이지 셋업 — [L1–L70](app_streamlit.py#L1-L70) +- `st.set_page_config` (반드시 import 직후) +- 라이트모드 강제 CSS inline +- 텔레그램 토큰 / 채팅 ID 는 `os.getenv` 로 로드 +- 핵심 상수: + - `STOP_LOSS_PCT = 0.0075` — 10x 레버리지 기준 ROI -7.5% + - `LONG_SIGNALS` / `SHORT_SIGNALS` — 신호 분류 set + - `TF_LABEL_MAP` — 11개 시간축 한글 라벨 + +#### (2) 텔레그램 송신 + 알림 코어 — [L74–L263](app_streamlit.py#L74-L263) + +`SIG_DEFS` (9종 신호): +- `strong_long_signal` / `strong_short_signal` — 다중 필터 통과 +- `long_signal` / `short_signal` — 일반 진입 +- `vol_long_signal` / `vol_short_signal` — 볼륨 급등 +- `reversal_long_signal` / `reversal_short_signal` — 추세 꺾임 감지 +- `short_caution_signal` — 극단 펀딩비 + OI 하락 (숏 주의) + +`check_and_alert(df, symbol, interval)` — 알림 코어 로직: + +1. **Phase 0 — silent sync** (재시작 후 첫 polling): `synced_intervals` 미포함 시 모든 `last_fired_candle` 만 채우고 알림 skip → 역사적 신호 burst 방지 +2. **Phase 1 — pending 검증**: forming candle 에서 발사된 알림은 매 polling 마다 신호 잔존 여부 확인. 사라지면 즉시 `[취소 알림]` 발사 (캔들 마감까지 기다리지 않음) +3. **Phase 2 — 신호 검사**: + - `tail(3)` 만 검사 + - cooldown (10분) + per-candle dedup + - **forming candle 안정성**: 연속 2 polls True 만 발사 (`signal_seen_count`) — 깜빡임 false alert 차단 + - 닫힌 캔들은 즉시 발사 (data 확정) +4. **Phase 3 — group 발사**: long / short / caution 그룹별로 한번에 묶어서 송신. 진입가 / 손절가 표시 +5. **Phase 4 — 청산 권고**: 30m / 1h 의 새 진입 신호만 반대 방향 진입에 대한 `[반대 신호 감지 - 청산 권장]` 트리거 (5m / 15m 은 노이즈 多 → 폭주 방지) +6. **Phase 5 — 손절가 알림**: 현재가가 추적 중 진입의 stop 을 침범하면 `[손절가알림]` 송신 + +#### (3) Binance 데이터 수집 — [L267–L311](app_streamlit.py#L267-L311) + +| 함수 | 엔드포인트 | 반환 | +|---|---|---| +| `get_klines` | `/fapi/v1/klines` | OHLCV + taker buy/sell volume | +| `get_funding_rate` | `/fapi/v1/fundingRate` | FR (%, 100배 환산) | +| `get_open_interest_history` | `/futures/data/openInterestHist` | OI | +| `get_long_short_ratio` | `/futures/data/topLongShortPositionRatio` | 탑트레이더 L/S ratio | +| `get_taker_buy_sell_ratio` | `/futures/data/takerlongshortRatio` | (코드상 정의되나 차트엔 미사용) | + +모든 시각은 KST (`+9h`). + +#### (4) 지표 + 신호 계산 — [L315–L450](app_streamlit.py#L315-L450) + +`compute_indicators(df, interval)` — 표준 TA: +- MA 7 / 25 / 99 / 200 +- BB (20, 2σ) — mid / upper / lower +- RSI(14), StochRSI(14, 3, 3) +- MACD(12, 26, 9) — line / signal / histogram +- ATR(14) + +`compute_signals(df, interval)` — 신호 정의: + +| 신호 | 조건 | +|---|---| +| `long_signal` | `bull_ma_2` (close > MA7 & MA25) & RSI<75 & MACD_hist↑ & close > BB_mid & body% ≥ +0.2% | +| `short_signal` | `bear_ma_2` & RSI>25 & MACD_hist↓ & close < BB_mid & body% ≤ -0.2% | +| `strong_long_signal` | `bull_ma_2` & RSI<65 & MACD_hist↑ & oi_up_2 & taker_buy_2 & fr_long_favor & 양봉 | +| `strong_short_signal` | `bear_ma_2` & RSI>35 & MACD_hist↓ & oi_down_2 & taker_sell_2 & fr_short_favor & 음봉 | +| `vol_long_signal` | buy_net > avg×2 & taker_buy_vol > min × oi_active (interval 별 min 가변) | +| `vol_short_signal` | sell_net 동일 미러 | +| `short_caution_signal` | `oi_down_2` & FR ≤ -0.007% (극단 음수) | +| `reversal_long/short_signal` | 직전 3봉 추세 반대 + |body|≥0.3% + 거래량 1.3× | +| `exhaustion_long/short` | 직전봉 거래량 spike (vol > avg×3) + 매도/매수 우세 | + +쿨다운: 신호별로 `rolling(N, sum).shift(1)==0` 패턴으로 N봉 내 중복 차단. + +> **MA 정렬 요구 완화**: 추세 반전 직후엔 MA7>MA25 정렬이 늦게 형성되어 `bull_ma` (3중 정렬) 대신 `bull_ma_2` (2중 정렬) 만 요구. (커밋 [d49ac84](app_streamlit.py)) + +#### (5) 차트 빌드 — [L455–L741](app_streamlit.py#L455-L741) + +`build_chart(symbol, interval, candle_limit)`: +- 7-row Plotly subplot: + 1. **메인** — Candlestick + BB + MA7/25/99/200 + 모든 신호 마커 + Taker buy/sell 점 + L/S / FR / OI 오버레이 + 2. Taker Buy/Sell Volume (Net Bar) + 3. Open Interest + 4. Funding Rate Bar (±0.5% / -0.7% 가이드 라인) + 5. Long/Short Ratio (탑트레이더) + 6. RSI / StochRSI (20/50/80 가이드) + 7. MACD (Line + Signal + Histogram) + +데이터 머지: OI / FR / L/S 는 시간 정렬 후 `floor()` + `merge` + `ffill`. FR 은 1h 기준, 나머지는 interval (혹은 5m fallback) 기준. + +**1시간 추세 컨텍스트**: 비-1h 시간축에서 별도로 1h 캔들 가져와 `h1_bull/bear` 컬럼을 생성, 같은 일자 안에서 ffill 로 채움 (현재 코드에선 차트 표시 X — 보존만). + +신호 마커: +- 롱 → `low × 0.9998` 위치, 위 화살표 +- 숏 → `high × 1.0002` 위치, 아래 화살표 +- caution → diamond, exhaustion → star + +축 자동 스케일: `tight()` + 분위수 기반. + +#### (6) 알림 백그라운드 스레드 — [L774–L786](app_streamlit.py#L774-L786) + +```python +ALERT_TIMEFRAMES = ["5m", "15m", "30m", "1h"] +def _alert_loop(): + while True: + for interval in ALERT_TIMEFRAMES: + df = _build_signal_df(symbol, interval, 200) + check_and_alert(df, symbol, interval) + time.sleep(30) +``` + +UI 에서 선택한 symbol 만 추적 (`alert_state.alert_symbol`, lock 으로 보호). 4개 시간축 모두 동시 모니터링. + +#### (7) 일일 리포트 — [L791–L1005](app_streamlit.py#L791-L1005) + +KST 자정 통과 감지 시 4종 텔레그램 메시지 발송: + +1. **24h 신호 통계 (1배 시간)** — 다음 봉에 반대 신호 떴는지 `T/F` 카운트 + 승률 +2. **24h 신호 통계 (2배 시간)** — 2번째 봉 검증 +3. **손절가 터치 횟수** — 진입 시 `±STOP_LOSS_PCT` 가격이 이후 3봉 안에 터치됐는지 +4. **추세 꺾임 감지 통계** — 3봉 후 close 가 의도한 방향으로 갔는지 + +리포트 시간축: `["5m", "15m", "30m", "1h", "4h"]`. 각 시간축 별 `DAILY_REPORT_KLINES_LIMIT` 만큼 가져와 24h 윈도우 분석. + +#### (8) 메인 UI — [L1010–L1085](app_streamlit.py#L1010-L1085) + +- 5-column 헤더: 심볼 / 시간축 / 갱신주기(초) / 새로고침·자동갱신·범례·모바일 토글 +- **Candle 수**: 데스크톱 53, 모바일 14 (`mobile_mode` 토글) +- 펀딩비 경고 배너 (≤-0.7% 위험 / ≤-0.5% 경보 / ≥+0.5% 롱 과열) +- Plotly 차트: scrollZoom, doubleClick reset +- `auto` 활성화 시 `time.sleep(refresh_sec)` + `st.rerun()` + +--- + +## 4. 동시성 / 안전성 설계 + +| 위험 요소 | 대응 | +|---|---| +| Streamlit rerun 마다 globals 초기화 | `alert_state` 모듈 분리 (`sys.modules` 캐싱) | +| 스레드 중복 기동 | `alert_started` / `daily_report_started` bool 가드 | +| UI ↔ alert thread 변수 충돌 | `alert_state.alert_lock` | +| 재시작 직후 역사적 신호 burst | `synced_intervals` silent sync (첫 polling 은 dedup 만 채우고 alert 미발사) | +| forming candle 깜빡임 false alert | `signal_seen_count` 연속 2 polls True 요구 | +| forming candle 발사 후 신호 사라짐 | `pending_groups` Phase 1 검증 → `[취소 알림]` 즉시 발사 | +| 청산 권고 폭주 (변동성 큰 날) | 30m / 1h 만 트리거 | +| Binance API 일시 오류 | `try/except` 로 모든 보조 API 감싸고 `pass` (메인 klines 만 필수) | + +--- + +## 5. 환경 / 실행 + +```bash +pip install -r requirements.txt +cp .env.example .env # TELEGRAM_TOKEN, TELEGRAM_CHAT_ID 채우기 +streamlit run app_streamlit.py +``` + +기본 포트 `8501`. 환경변수 미설정 시 알림은 silent fail (콘솔에만 에러 출력). + +--- + +## 6. 개선 가능 지점 (관찰만) + +- **신호 컬럼 마커 분기 로직 (L604–L641)** — `pd.Series([False]*len(df))` fallback 패턴 반복. helper 추출 가능. +- **OI / FR / L/S 머지 로직 (L479–L510, L750–L770)** — 4번 비슷하게 반복. `merge_external_metric()` 함수로 추출 가능. +- **`get_taker_buy_sell_ratio`** — 정의돼 있으나 호출처 없음 (dead code). +- **`exhaustion_long/short`** — `compute_signals` 에서 계산되나 SIG_DEFS / 알림 대상엔 미포함 (차트 마커로만 표시). +- **`h1_bull/bear` 컨텍스트** — 계산되나 차트/신호에서 사용 X. +- **`requests.get(verify=False)` + `urllib3.disable_warnings`** — TLS 검증 비활성. 운영 환경에선 검토 필요. +- **`recent = df.tail(3)`** — 30초 폴링 + N분 캔들이라 충분하지만, 순간적 네트워크 지연 시 초과 시간축에서는 신호 누락 가능. diff --git a/app_streamlit.py b/app_streamlit.py index c64e6a5..867f2c2 100644 --- a/app_streamlit.py +++ b/app_streamlit.py @@ -25,14 +25,18 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # 페이지 설정 (반드시 최상단) # ────────────────────────────────────────────── st.set_page_config( - page_title="BTC/ETH Futures Dashboard", + page_title="중고모아 트레이딩 대시보드", layout="wide", - initial_sidebar_state="collapsed" + initial_sidebar_state="expanded" ) -# 라이트모드 강제 CSS +# 라이트모드 강제 + 한글 폰트 st.markdown(""" + + """, unsafe_allow_html=True) + + col1, col2, col3 = st.columns([1, 2, 1]) + with col2: + st.markdown('
', unsafe_allow_html=True) + st.markdown(f'
{load_logo_svg()}
', unsafe_allow_html=True) + st.markdown('

업무관리 시스템

', unsafe_allow_html=True) + st.markdown('
로그인하여 시작하세요
', unsafe_allow_html=True) + + with st.form("login_form", clear_on_submit=False): + username = st.text_input("아이디", placeholder="username", key="login_user") + password = st.text_input("비밀번호", type="password", placeholder="password", key="login_pw") + submitted = st.form_submit_button("로그인", use_container_width=True, type="primary") + if submitted: + user = users_db.authenticate(username.strip(), password) + if user: + st.session_state.user = user + st.rerun() + else: + st.error("아이디 또는 비밀번호가 올바르지 않습니다.") + st.markdown('
', unsafe_allow_html=True) + + +def render_my_info_page(): + st.markdown( + '
' + '
👤 개인정보 수정
' + '
비밀번호 변경
' + '
', + unsafe_allow_html=True, + ) + + user = st.session_state.get("user", {}) + col_l, col_r = st.columns([2, 1], gap="medium") + + with col_l: + with st.container(border=True): + st.markdown("###### 계정 정보") + cc1, cc2 = st.columns(2) + with cc1: + st.text_input("아이디", value=user.get("username", ""), disabled=True) + with cc2: + st.text_input("권한", value=user.get("role", ""), disabled=True) + st.caption(f"가입: {user.get('created_at', '-')} · 마지막 로그인: {user.get('last_login_at', '-')}") + + with st.container(border=True): + st.markdown("###### 비밀번호 변경") + with st.form("change_pw_form", clear_on_submit=True): + old_pw = st.text_input("현재 비밀번호", type="password") + new_pw = st.text_input("새 비밀번호 (6자 이상)", type="password") + new_pw2 = st.text_input("새 비밀번호 확인", type="password") + submitted = st.form_submit_button("비밀번호 변경", type="primary", + use_container_width=True) + if submitted: + if new_pw != new_pw2: + st.error("새 비밀번호가 일치하지 않습니다.") + elif len(new_pw) < 6: + st.error("새 비밀번호는 6자 이상이어야 합니다.") + elif users_db.change_password(user.get("username", ""), old_pw, new_pw): + st.success("✅ 비밀번호 변경 완료. 다음 로그인부터 새 비밀번호 사용.") + else: + st.error("현재 비밀번호가 올바르지 않습니다.") + + with col_r: + with st.container(border=True): + st.markdown("###### 등록된 사용자") + users = users_db.list_users() + if users: + df_u = pd.DataFrame(users)[["username", "role", "last_login_at"]] + st.dataframe(df_u, use_container_width=True, hide_index=True) + else: + st.info("사용자 목록을 불러오지 못함.") + + +def render_sidebar() -> str: + """fito 스타일 다크 사이드바. + - full 모드: 240px, 헤더 + 아이콘+텍스트 메뉴 + 푸터 + - mini 모드: 60px, 햄버거 + 정사각형 아이콘 버튼 (텍스트 없음, hover tooltip) + - Streamlit 기본 collapse 버튼은 숨김 (혼란 방지) + """ + try: + from streamlit_option_menu import option_menu + HAS_OPTION_MENU = True + except ImportError: + HAS_OPTION_MENU = False + + if "sidebar_mini" not in st.session_state: + st.session_state.sidebar_mini = False + if "current_page" not in st.session_state: + st.session_state.current_page = "dashboard" + + mini = st.session_state.sidebar_mini + sidebar_width = "64px" if mini else "260px" + + st.markdown(f""" + + """, unsafe_allow_html=True) + + labels = ["대시보드", "트레이드 이력", "거래소 API", "자동매매", "시스템 설정", "내 정보"] + keys = ["dashboard", "trades", "exchange_keys", "automation", "settings", "my_info"] + bs_icons = ["bar-chart-line", "graph-up-arrow", "key", "robot", "gear", "person-circle"] + emoji_icons = ["📊", "📈", "🔑", "🤖", "⚙️", "👤"] + + with st.sidebar: + if mini: + # ── mini 헤더: 햄버거 토글만 (가운데) ── + st.markdown('
', unsafe_allow_html=True) + if st.button("☰", key="sidebar_toggle_mini", + use_container_width=True, help="메뉴 펼치기"): + st.session_state.sidebar_mini = False + st.rerun() + + # ── mini 메뉴: 정사각형 아이콘 버튼 (텍스트 없음, hover tooltip) ── + for label, key, emoji in zip(labels, keys, emoji_icons): + active = (st.session_state.current_page == key) + if active: + st.markdown('
', unsafe_allow_html=True) + if st.button(emoji, key=f"mini_{key}", + use_container_width=True, help=label): + st.session_state.current_page = key + st.rerun() + if active: + st.markdown('
', unsafe_allow_html=True) + + else: + # ── full 헤더: SVG 로고 (좌) + 햄버거 (우) ── + head_col1, head_col2 = st.columns([5, 1], gap="small") + with head_col1: + logo_svg = load_logo_svg() + if logo_svg: + st.markdown( + f'
{logo_svg}
', + unsafe_allow_html=True, + ) + else: + st.markdown( + '
' + '
JUNGGOMOA
' + '
트레이딩 시스템
' + '
', + unsafe_allow_html=True, + ) + with head_col2: + st.markdown('
', unsafe_allow_html=True) + if st.button("☰", key="sidebar_toggle_full", help="메뉴 접기"): + st.session_state.sidebar_mini = True + st.rerun() + st.markdown('
', + unsafe_allow_html=True) + + # ── full 메뉴 ── + if HAS_OPTION_MENU: + try: + default_idx = keys.index(st.session_state.current_page) + except ValueError: + default_idx = 0 + choice = option_menu( + menu_title=None, + options=labels, + icons=bs_icons, + default_index=default_idx, + key="full_menu", + styles={ + "container": {"padding": "0", "background-color": "#1f2937"}, + "icon": {"color": "#60a5fa", "font-size": "16px"}, + "nav-link": { + "color": "#e5e7eb", + "font-size": "14px", + "text-align": "left", + "margin": "2px 6px", + "padding": "10px 12px", + "border-radius": "6px", + "--hover-color": "#374151", + "font-family": "'Noto Sans KR', sans-serif", + }, + "nav-link-selected": { + "background-color": "#2563eb", + "color": "#ffffff", + "font-weight": "600", + }, + }, + ) + st.session_state.current_page = keys[labels.index(choice)] if choice in labels else "dashboard" + else: + try: + idx = keys.index(st.session_state.current_page) + except ValueError: + idx = 0 + choice = st.radio("menu", labels, index=idx, label_visibility="collapsed") + st.session_state.current_page = keys[labels.index(choice)] + + # ── 푸터: 아바타 + username + 로그아웃 ── + user = st.session_state.get("user", {}) + uname = user.get("username", "guest") + initial = uname[0].upper() if uname else "?" + st.markdown( + f'
' + f'
{initial}
' + f'
' + f'
{uname}
' + f'
{user.get("role", "user")}
' + f'
' + f'
', + unsafe_allow_html=True, + ) + if st.button("로그아웃", key="sidebar_logout", use_container_width=True): + st.session_state.pop("user", None) + st.session_state.current_page = "dashboard" + st.rerun() + + return st.session_state.current_page + + +def render_trades_page(): + st.markdown("## 📈 트레이드 이력") + st.caption("DB 에 기록된 진입 → 청산 lifecycle. 손절(stop_loss) / 반대신호(reversal) / 취소(cancelled) 별로 분석.") + + if not trades_db._enabled(): + st.warning("DATABASE_URL 미설정. PostgreSQL 컨테이너가 떠있어야 트레이드 이력이 기록됩니다.") + st.code("docker compose up -d postgres", language="bash") + return + + rows = trades_db.fetch_trades(limit=500) + if not rows: + st.info("아직 기록된 트레이드 없음. 진입 신호가 발사되면 자동으로 누적됩니다.") + return + + df = pd.DataFrame(rows) + # 정렬 / 표시 컬럼 정리 + display_cols = ["entry_time", "symbol", "interval", "direction", "signal_types", + "entry_price", "stop_price", "status", "exit_time", "exit_price", + "exit_reason", "pnl_pct"] + display_cols = [c for c in display_cols if c in df.columns] + df_disp = df[display_cols].copy() + + # ── 요약 메트릭 ── + closed = df[df["status"].isin(["stop_loss", "reversal", "cancelled"])] + open_count = int((df["status"] == "open").sum()) + total = len(df) + if len(closed) > 0: + wins = int((closed["pnl_pct"] > 0).sum()) + losses = int((closed["pnl_pct"] <= 0).sum()) + win_rate = wins / len(closed) * 100 + avg_pnl = float(closed["pnl_pct"].mean()) + cum_pnl = float(closed["pnl_pct"].sum()) + else: + wins = losses = 0 + win_rate = avg_pnl = cum_pnl = 0.0 + + m1, m2, m3, m4, m5, m6 = st.columns(6) + m1.metric("총 트레이드", total) + m2.metric("진행 중", open_count) + m3.metric("종료", len(closed)) + m4.metric("승률", f"{win_rate:.1f}%", f"{wins}W / {losses}L") + m5.metric("평균 PnL%", f"{avg_pnl:+.2f}%") + m6.metric("누적 PnL%", f"{cum_pnl:+.2f}%") + + st.markdown("---") + + # ── 누적 PnL 차트 ── + if len(closed) > 0: + c2 = closed.sort_values("exit_time").copy() + c2["cum_pnl"] = c2["pnl_pct"].cumsum() + fig = go.Figure() + fig.add_trace(go.Scatter(x=c2["exit_time"], y=c2["cum_pnl"], + mode="lines+markers", + line=dict(color="#2962ff", width=2), + marker=dict( + color=["#26a69a" if v > 0 else "#ef5350" for v in c2["pnl_pct"]], + size=6), + name="누적 PnL%")) + fig.add_hline(y=0, line=dict(color="#888", width=0.6, dash="dash")) + fig.update_layout( + height=320, + paper_bgcolor="#ffffff", plot_bgcolor="#ffffff", + font=dict(color="#131722", size=11, + family="'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif"), + margin=dict(l=40, r=20, t=30, b=30), + xaxis_title="청산 시각", yaxis_title="누적 PnL %", + ) + st.plotly_chart(fig, use_container_width=True) + + # ── 시간축 / 신호별 승률 ── + c3, c4 = st.columns(2) + with c3: + st.markdown("##### 시간축별 승률") + by_iv = closed.groupby("interval").agg( + n=("pnl_pct", "size"), + wins=("pnl_pct", lambda s: int((s > 0).sum())), + avg_pnl=("pnl_pct", "mean"), + sum_pnl=("pnl_pct", "sum"), + ).reset_index() + by_iv["win_rate%"] = (by_iv["wins"] / by_iv["n"] * 100).round(1) + st.dataframe(by_iv, use_container_width=True, hide_index=True) + with c4: + st.markdown("##### 청산 사유별 분포") + by_reason = closed.groupby("exit_reason").agg( + n=("pnl_pct", "size"), + avg_pnl=("pnl_pct", "mean"), + sum_pnl=("pnl_pct", "sum"), + ).reset_index() + st.dataframe(by_reason, use_container_width=True, hide_index=True) + + # ── 시간축별 PnL 막대 ── + st.markdown("##### 시간축 × 방향별 누적 PnL%") + bar = closed.groupby(["interval", "direction"])["pnl_pct"].sum().reset_index() + fig2 = go.Figure() + for d, color in [("long", "#26a69a"), ("short", "#ef5350")]: + sub = bar[bar["direction"] == d] + fig2.add_trace(go.Bar(x=sub["interval"], y=sub["pnl_pct"], + name=d, marker_color=color)) + fig2.update_layout( + barmode="group", height=300, + paper_bgcolor="#ffffff", plot_bgcolor="#ffffff", + font=dict(color="#131722", size=11, + family="'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif"), + margin=dict(l=40, r=20, t=10, b=30), + yaxis_title="누적 PnL %", + ) + st.plotly_chart(fig2, use_container_width=True) + + st.markdown("---") + st.markdown("### 🧾 최근 트레이드 (최대 500건)") + st.dataframe(df_disp, use_container_width=True, hide_index=True) + + +def _section_header(emoji: str, title: str, subtitle: str = ""): + sub = f' {subtitle}' if subtitle else "" + st.markdown( + f'
' + f'
{emoji} {title}
' + f'{sub}' + f'
', + unsafe_allow_html=True, + ) + + +def render_settings_page(): + # 페이지 헤더 — 컴팩트 (한 줄) + st.markdown( + '
' + '
⚙️ 시스템 설정
' + '
DB 영속 저장 · 저장 즉시 반영
' + '
', + unsafe_allow_html=True, + ) + + cur = settings_db.all_settings() + + with st.form("settings_form", clear_on_submit=False): + # 5개 탭으로 분리 — 한 번에 한 섹션만 보여 한 화면(1080) fit + tab_tg, tab_alert, tab_signal, tab_vol, tab_chart = st.tabs( + ["📨 텔레그램", "🔔 알림 / 모니터링", "🎯 신호 임계값", "💧 거래량 / 펀딩비", "📊 차트"] + ) + + # ── 텔레그램 ── + with tab_tg: + st.markdown("###### Telegram Bot 설정") + col_a, col_b = st.columns(2) + with col_a: + # type=password 제거 — 사용자 요청대로 plain text 로 보이게 + token = st.text_input("Bot Token", value=cur.get("telegram_token", ""), + placeholder="예: 1234567890:ABCDEF...") + with col_b: + chat_id = st.text_input("Chat ID", value=cur.get("telegram_chat_id", ""), + placeholder="예: -1001234567890 또는 본인 user id") + st.caption("⚠️ Token 은 plain text 로 표시됩니다 (DB 저장 후엔 다시 보임). 노출 주의.") + + # ── 알림 / 모니터링 ── + with tab_alert: + col_c, col_d = st.columns(2) + with col_c: + symbol_default = cur.get("alert_symbol", "BTCUSDT") + symbol_options = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"] + if symbol_default not in symbol_options: + symbol_options.insert(0, symbol_default) + symbol = st.selectbox("모니터링 심볼", symbol_options, + index=symbol_options.index(symbol_default)) + with col_d: + tf_options = ["1m", "3m", "5m", "15m", "30m", "1h", "4h"] + tf_current = [t for t in cur.get("alert_timeframes", "5m,15m,30m,1h").split(",") if t.strip()] + tf_selected = st.multiselect("알림 시간축", tf_options, default=tf_current) + col_e, col_f, col_g, col_j = st.columns(4) + with col_e: + cooldown = st.number_input("쿨다운(초)", 30, 3600, + int(cur.get("alert_cooldown_sec", "600") or 600)) + with col_f: + sl_pct_pct = st.number_input("손절(%)", 0.05, 5.0, + float(cur.get("stop_loss_pct", "0.0075") or 0.0075) * 100, step=0.05) + with col_g: + poll_sec = st.number_input("폴링(초)", 10, 300, + int(cur.get("polling_interval_sec", "30") or 30)) + with col_j: + forming_polls = st.number_input("forming polls", 1, 10, + int(cur.get("forming_stable_polls", "2") or 2)) + col_h, col_i = st.columns(2) + with col_h: + alert_enabled = st.checkbox("✅ 알림 활성화", value=cur.get("alert_enabled", "1") == "1") + with col_i: + daily_enabled = st.checkbox("📅 일일 리포트 활성화", value=cur.get("daily_report_enabled", "1") == "1") + + # ── 신호 임계값 ── + with tab_signal: + st.markdown("###### RSI 임계값") + c1, c2, c3, c4 = st.columns(4) + with c1: + long_rsi_max = st.number_input("일반 롱 RSI ≤", 30.0, 100.0, + float(cur.get("long_rsi_max", "75"))) + with c2: + short_rsi_min = st.number_input("일반 숏 RSI ≥", 0.0, 70.0, + float(cur.get("short_rsi_min", "25"))) + with c3: + slong_rsi_max = st.number_input("강한 롱 RSI ≤", 30.0, 100.0, + float(cur.get("strong_long_rsi_max", "65"))) + with c4: + sshort_rsi_min = st.number_input("강한 숏 RSI ≥", 0.0, 70.0, + float(cur.get("strong_short_rsi_min", "35"))) + st.markdown("###### 캔들 body / 추세 꺾임") + c5, c6, c7 = st.columns(3) + with c5: + body_pct_min = st.number_input("body 최소(%)", 0.0, 5.0, + float(cur.get("body_pct_min", "0.002")) * 100, + step=0.05) / 100 + with c6: + rev_body_pct = st.number_input("추세 꺾임 body(%)", 0.0, 5.0, + float(cur.get("reversal_body_pct", "0.003")) * 100, + step=0.05) / 100 + with c7: + rev_vol_mult = st.number_input("추세 꺾임 vol 배수", 1.0, 10.0, + float(cur.get("reversal_vol_mult", "1.3")), step=0.1) + + # ── 거래량 / 펀딩비 ── + with tab_vol: + st.markdown("###### 거래량 배수") + c1, c2, c3 = st.columns(3) + with c1: + vol_exh = st.number_input("Exhaustion 배수", 1.5, 20.0, + float(cur.get("vol_exhaustion_mult", "3.0")), step=0.5) + with c2: + vol_net = st.number_input("vol Net 배수", 1.0, 10.0, + float(cur.get("vol_net_mult", "2.0")), step=0.1) + with c3: + oi_active = st.number_input("OI 활성도(%)", 0.0, 5.0, + float(cur.get("oi_active_pct", "0.001")) * 100, + step=0.05) / 100 + st.markdown("###### 펀딩비 임계 (단위: %)") + c4, c5, c6 = st.columns(3) + with c4: + fr_overheat = st.number_input("롱 과열 FR (≥)", 0.0, 1.0, + float(cur.get("fr_long_overheat", "0.005")), + step=0.001, format="%.4f") + with c5: + fr_caution = st.number_input("숏 경보 FR (≤)", -1.0, 0.0, + float(cur.get("fr_short_caution", "-0.005")), + step=0.001, format="%.4f") + with c6: + fr_extreme = st.number_input("숏 주의 FR (≤)", -1.0, 0.0, + float(cur.get("fr_short_extreme", "-0.007")), + step=0.001, format="%.4f") + + # ── 차트 ── + with tab_chart: + st.markdown("###### 한 화면 캔들 수") + c1, c2 = st.columns(2) + with c1: + cl_desktop = st.number_input("데스크톱", 10, 500, + int(cur.get("candle_limit_desktop", "53"))) + with c2: + cl_mobile = st.number_input("모바일", 5, 200, + int(cur.get("candle_limit_mobile", "14"))) + + # ── 저장 / 테스트 버튼 (탭 밖 하단) ── + st.markdown('
', unsafe_allow_html=True) + bcol1, bcol2, _ = st.columns([2, 1, 3]) + with bcol1: + submitted = st.form_submit_button("💾 전체 설정 저장", + use_container_width=True, type="primary") + with bcol2: + test_msg = st.form_submit_button("🧪 텔레그램 테스트", use_container_width=True) + + if submitted or test_msg: + saves = { + "telegram_token": token.strip(), + "telegram_chat_id": chat_id.strip(), + "alert_symbol": symbol, + "alert_timeframes": ",".join(tf_selected) if tf_selected else "5m,15m,30m,1h", + "alert_cooldown_sec": int(cooldown), + "stop_loss_pct": f"{sl_pct_pct/100:.6f}", + "polling_interval_sec": int(poll_sec), + "alert_enabled": "1" if alert_enabled else "0", + "daily_report_enabled": "1" if daily_enabled else "0", + "forming_stable_polls": int(forming_polls), + "long_rsi_max": long_rsi_max, + "short_rsi_min": short_rsi_min, + "strong_long_rsi_max": slong_rsi_max, + "strong_short_rsi_min": sshort_rsi_min, + "body_pct_min": f"{body_pct_min:.6f}", + "reversal_body_pct": f"{rev_body_pct:.6f}", + "reversal_vol_mult": rev_vol_mult, + "vol_exhaustion_mult": vol_exh, + "vol_net_mult": vol_net, + "oi_active_pct": f"{oi_active:.6f}", + "fr_long_overheat": f"{fr_overheat:.6f}", + "fr_short_caution": f"{fr_caution:.6f}", + "fr_short_extreme": f"{fr_extreme:.6f}", + "candle_limit_desktop": int(cl_desktop), + "candle_limit_mobile": int(cl_mobile), + } + for k, v in saves.items(): + settings_db.set_value(k, v) + with alert_state.alert_lock: + alert_state.alert_symbol = symbol + if test_msg: + send_telegram("✅ junggomoa.com 대시보드 — 설정 저장 + 테스트 메시지") + st.toast("텔레그램 테스트 발송 + 설정 저장", icon="📨") + else: + st.toast(f"{len(saves)}개 항목 저장됨", icon="✅") + + +def render_exchange_keys_page(): + st.markdown("## 🔑 거래소 API 키") + st.caption("거래소별 API Key / Secret 을 Fernet 으로 암호화하여 PostgreSQL 에 영속 저장. 자동매매 시 활성 키로 주문 발사.") + + if not exchange_keys._enabled(): + st.warning("DATABASE_URL 또는 cryptography 패키지 미설정. 컨테이너 재기동 / 의존성 확인 필요.") + return + + creds = exchange_keys.list_credentials() + + st.markdown("### ➕ 새 키 등록") + with st.form("new_cred", clear_on_submit=True): + c1, c2, c3 = st.columns([1, 1, 1]) + with c1: + ex = st.selectbox("거래소", exchange_keys.SUPPORTED_EXCHANGES) + with c2: + label = st.text_input("Label", placeholder="예: main / sub / strategy_A") + with c3: + testnet = st.checkbox("Testnet", value=False) + c4, c5 = st.columns(2) + with c4: + api_key = st.text_input("API Key", type="password") + with c5: + api_secret = st.text_input("API Secret", type="password") + passphrase = st.text_input("Passphrase (OKX/Bitget 만 필요)", type="password", + placeholder="해당 거래소가 아니면 비워두세요") + submitted = st.form_submit_button("등록", use_container_width=True, type="primary") + if submitted: + if not api_key or not api_secret: + st.error("API Key / Secret 둘 다 입력 필수") + else: + cid = exchange_keys.add_credential(ex, label, api_key, api_secret, + passphrase or None, testnet, True) + if cid: + st.success(f"✅ 등록 완료 (id={cid}). 페이지 새로고침으로 목록에 반영.") + else: + st.error("등록 실패. 컨테이너 로그 확인.") + + st.markdown("---") + st.markdown(f"### 📒 등록된 키 ({len(creds)})") + if not creds: + st.info("아직 등록된 키 없음.") + return + for c in creds: + with st.expander( + f"`#{c['id']}` **{c['exchange'].upper()}** [{c['label'] or '-'}] " + f"{'🧪TESTNET' if c['testnet'] else '🟢LIVE'} {'✅' if c['enabled'] else '⏸️'}", + ): + cc1, cc2 = st.columns(2) + with cc1: + st.code(f"API Key: {c['api_key_masked']}") + with cc2: + st.code(f"Secret: {c['api_secret_masked']}") + if c.get("passphrase_masked"): + st.code(f"Passphrase: {c['passphrase_masked']}") + st.caption(f"등록: {c['created_at']} / 수정: {c['updated_at']}") + + colx, coly, colz = st.columns(3) + with colx: + new_enabled = st.checkbox("활성", value=c["enabled"], key=f"en_{c['id']}") + if new_enabled != c["enabled"]: + if exchange_keys.update_credential(c["id"], enabled=new_enabled): + st.success("상태 변경 — 새로고침 시 반영") + with coly: + new_testnet = st.checkbox("Testnet", value=c["testnet"], key=f"tn_{c['id']}") + if new_testnet != c["testnet"]: + if exchange_keys.update_credential(c["id"], testnet=new_testnet): + st.success("Testnet 변경") + with colz: + if st.button("🗑️ 삭제", key=f"del_{c['id']}"): + if exchange_keys.delete_credential(c["id"]): + st.success("삭제 완료. 새로고침으로 목록 반영.") + + +def render_automation_page(): + st.markdown("## 🤖 자동매매 설정") + st.caption("⚠️ 현재 어댑터는 **DRY-RUN 더미** (실제 거래소 주문 미연결). 인터페이스 / 설정 / 키 관리만 갖춰진 상태. " + "실 주문 연결은 추후 거래소별 SDK 어댑터 추가 후 활성화.") + + if not exchange_keys._enabled(): + st.warning("DATABASE_URL 또는 cryptography 미설정.") + return + + cfg = exchange_keys.automation_all() + creds = exchange_keys.list_credentials() + cred_options = {f"#{c['id']} {c['exchange'].upper()} [{c['label'] or '-'}] {'🧪' if c['testnet'] else ''}": str(c["id"]) + for c in creds if c["enabled"]} + cred_labels = list(cred_options.keys()) + + with st.form("automation_form"): + c1, c2, c3 = st.columns(3) + with c1: + enabled = st.checkbox("자동매매 ON", value=cfg.get("enabled", "0") == "1", + help="글로벌 킬스위치. OFF 면 시그널만 기록.") + with c2: + dry_run = st.checkbox("DRY-RUN (실 주문 X)", value=cfg.get("dry_run", "1") == "1", + help="ON 권장. 어댑터가 stdout 으로만 출력.") + with c3: + allowed_dirs = st.selectbox("허용 방향", + ["long,short", "long", "short"], + index=["long,short", "long", "short"].index(cfg.get("allowed_directions", "long,short")) if cfg.get("allowed_directions", "long,short") in ["long,short", "long", "short"] else 0) + + st.markdown("##### 활성 키") + if cred_labels: + cur_id = cfg.get("active_credential", "") + cur_label = next((k for k, v in cred_options.items() if v == cur_id), cred_labels[0]) + active_label = st.selectbox("활성 거래소 키", cred_labels, index=cred_labels.index(cur_label)) + active_id = cred_options[active_label] + else: + st.info("등록된 활성 키가 없습니다. '🔑 거래소 API' 페이지에서 먼저 등록하세요.") + active_id = "" + + st.markdown("##### 포지션 / 리스크") + c4, c5, c6, c7 = st.columns(4) + with c4: + leverage = st.number_input("레버리지", 1, 125, int(cfg.get("leverage", "10"))) + with c5: + pos_pct = st.number_input("포지션 크기 (잔고%)", + 0.1, 100.0, float(cfg.get("position_size_pct", "1.0")), step=0.1) + with c6: + max_open = st.number_input("동시 진입 최대", 1, 20, int(cfg.get("max_open_trades", "3"))) + with c7: + min_score = st.number_input("최소 신호 score", 1, 5, int(cfg.get("min_signal_score", "1")), + help="동시 발사된 신호 수가 N 이상일 때만 진입 (예: 2 = 강한+일반 동시)") + + c8, _ = st.columns(2) + with c8: + tp_pct = st.number_input("Take Profit (%, 0=OFF)", + 0.0, 100.0, float(cfg.get("tp_pct", "0.0")), step=0.1) + + submitted = st.form_submit_button("💾 자동매매 설정 저장", use_container_width=True, type="primary") + if submitted: + settings = { + "enabled": "1" if enabled else "0", + "dry_run": "1" if dry_run else "0", + "active_credential": active_id, + "leverage": leverage, + "position_size_pct": pos_pct, + "max_open_trades": max_open, + "min_signal_score": min_score, + "allowed_directions": allowed_dirs, + "tp_pct": tp_pct, + } + for k, v in settings.items(): + exchange_keys.automation_set(k, v) + st.success("✅ 저장 완료.") + + st.markdown("---") + st.markdown("### 🧪 어댑터 테스트 (DRY-RUN)") + if st.button("get_balance 호출"): + if not active_id: + st.error("활성 키 미선택") + else: + cred = exchange_keys.get_credential(int(active_id)) + adapter = exchange_adapters.make_adapter(cred, dry_run=True) + bal = adapter.get_balance("USDT") + st.code(f"adapter.get_balance('USDT') -> {bal}") + + st.markdown("---") + st.markdown("### 📋 현재 자동매매 설정") + st.json(cfg) + + def main(): + # ── 로그인 게이트 (st.stop 으로 강제 중단 — 다른 위젯 렌더 차단) ── + if not st.session_state.get("user"): + render_login_page() + st.stop() + if not alert_state.alert_started: t = threading.Thread(target=_alert_loop, daemon=True) t.start() @@ -1024,9 +1901,31 @@ def main(): dr.start() alert_state.daily_report_started = True + page = render_sidebar() + + if page == "settings": + render_settings_page() + return + if page == "trades": + render_trades_page() + return + if page == "exchange_keys": + render_exchange_keys_page() + return + if page == "automation": + render_automation_page() + return + if page == "my_info": + render_my_info_page() + return + + # 대시보드 진입 시 DB 의 alert_symbol 을 기본 심볼로 사용 + if not alert_state.alert_symbol or alert_state.alert_symbol == "BTCUSDT": + alert_state.alert_symbol = settings_db.get("alert_symbol", "BTCUSDT") + col1, col2, col3, col4, col5 = st.columns([2, 1, 1, 1, 2]) with col1: - st.markdown("### 📊 Futures Dashboard") + st.markdown("### 📊 선물 대시보드") with col2: symbol = st.selectbox("심볼", ["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT"], index=0, label_visibility="collapsed") with col3: @@ -1046,8 +1945,9 @@ def main(): with col5d: mobile_mode = st.checkbox("모바일", value=False) - # 데스크톱: 53 (기존 모바일 캔들 수). 모바일: 14 (= 53 × 53/200, 기존 비율 유지) - candle_limit = 14 if mobile_mode else 53 + cl_desktop = settings_db.get_int("candle_limit_desktop", 53) + cl_mobile = settings_db.get_int("candle_limit_mobile", 14) + candle_limit = cl_mobile if mobile_mode else cl_desktop with alert_state.alert_lock: alert_state.alert_symbol = symbol @@ -1061,11 +1961,14 @@ def main(): fr_df = get_funding_rate(symbol, 1) if not fr_df.empty: rate = fr_df["fundingRate"].iloc[-1] - if rate <= -0.007: + fr_extreme = settings_db.get_float("fr_short_extreme", -0.007) + fr_caution = settings_db.get_float("fr_short_caution", -0.005) + fr_overheat = settings_db.get_float("fr_long_overheat", 0.005) + if rate <= fr_extreme: st.error(f"🚨 극단적 숏스퀴즈 위험 | FR: {rate:.4f}% | 숏 신규진입 절대 금지") - elif rate <= -0.005: + elif rate <= fr_caution: st.warning(f"⚠️ 숏스퀴즈 경보 구간 | FR: {rate:.4f}% | 숏 진입 시 청산가 재확인 필수") - elif rate >= 0.005: + elif rate >= fr_overheat: st.info(f"📈 롱 과열 구간 | FR: {rate:.4f}% | 롱스퀴즈 주의") else: st.success(f"✅ FR 정상 | {rate:.4f}%") diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..77154f0 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + JUNGGOMOA + 트레이딩 시스템 + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b37d130 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,37 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONIOENCODING=utf-8 \ + PIP_NO_CACHE_DIR=1 \ + TZ=Asia/Seoul + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata curl \ + && ln -sf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install deps +COPY backend/requirements.txt /app/requirements.txt +RUN pip install --upgrade pip && pip install -r /app/requirements.txt + +# 루트의 모든 .py 모듈 (users_db, settings_db, trades_db, exchange_keys, +# exchange_adapters, alert_state, core_logic) 그대로 복사 +COPY *.py /app/ + +# FastAPI 앱 +COPY backend/app /app/api + +RUN mkdir -p /app/data +VOLUME ["/app/data"] + +ENV SETTINGS_DB_PATH=/app/data/settings.db \ + PYTHONPATH=/app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -f http://127.0.0.1:8000/api/health || exit 1 + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..3fac45f --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,47 @@ +"""JWT 인증.""" +import os +from datetime import datetime, timedelta +from typing import Optional +from fastapi import HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError + +import users_db + +JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-in-production-please") +JWT_ALG = "HS256" +JWT_EXP_HOURS = 24 * 7 + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + + +def create_token(username: str, role: str) -> str: + payload = { + "sub": username, + "role": role, + "exp": datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS), + "iat": datetime.utcnow(), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + + +def decode_token(token: str) -> Optional[dict]: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + except JWTError: + return None + + +def require_user(token: Optional[str] = Depends(oauth2_scheme)) -> dict: + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not authenticated") + payload = decode_token(token) + if not payload: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") + return payload + + +def require_admin(payload: dict = Depends(require_user)) -> dict: + if payload.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only") + return payload diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5c02e5d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,54 @@ +""" +FastAPI 메인. 모든 라우터 + CORS + 백그라운드 알림 스레드 시작. +기존 Python 모듈 (users_db, settings_db, trades_db, exchange_keys, core_logic) 그대로 사용. +""" +import os +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +import users_db +import settings_db +import trades_db +import exchange_keys +import core_logic +import alert_state + +from .routes import auth_route, settings_route, market_route, trades_route, exchange_route, automation_route, users_route + +app = FastAPI(title="JUNGGOMOA Trading API", version="1.0", redirect_slashes=False) + +# CORS — 같은 도메인이라도 허용 (개발 편의) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.on_event("startup") +def on_startup(): + settings_db.init_db_with_env_defaults() + trades_db.init_db() + exchange_keys.init_db() + users_db.init_db() + # alert_symbol DB 에서 동기화 + alert_state.alert_symbol = settings_db.get("alert_symbol", "BTCUSDT") + # 백그라운드 알림 / 일일 리포트 시작 + core_logic.start_background_threads() + print("[FastAPI] 모든 모듈 init OK + 백그라운드 스레드 시작") + + +@app.get("/api/health") +def health(): + return {"ok": True} + + +# 라우터 등록 +app.include_router(auth_route.router, prefix="/api/auth", tags=["auth"]) +app.include_router(users_route.router, prefix="/api/users", tags=["users"]) +app.include_router(settings_route.router, prefix="/api/settings", tags=["settings"]) +app.include_router(market_route.router, prefix="/api/market", tags=["market"]) +app.include_router(trades_route.router, prefix="/api/trades", tags=["trades"]) +app.include_router(exchange_route.router, prefix="/api/exchange", tags=["exchange"]) +app.include_router(automation_route.router, prefix="/api/automation", tags=["automation"]) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/auth_route.py b/backend/app/routes/auth_route.py new file mode 100644 index 0000000..75ea84f --- /dev/null +++ b/backend/app/routes/auth_route.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +import users_db +from ..auth import create_token, require_user + +router = APIRouter() + + +class LoginIn(BaseModel): + username: str + password: str + + +class LoginOut(BaseModel): + access_token: str + token_type: str = "bearer" + user: dict + + +@router.post("/login", response_model=LoginOut) +def login(body: LoginIn): + user = users_db.authenticate(body.username.strip(), body.password) + if not user: + raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다.") + token = create_token(user["username"], user.get("role", "user")) + # datetime 직렬화 위해 string 변환 + user_safe = { + "id": user.get("id"), + "username": user.get("username"), + "role": user.get("role"), + "created_at": str(user.get("created_at")) if user.get("created_at") else None, + "last_login_at": str(user.get("last_login_at")) if user.get("last_login_at") else None, + } + return {"access_token": token, "user": user_safe} + + +@router.get("/me") +def me(payload: dict = Depends(require_user)): + return {"username": payload.get("sub"), "role": payload.get("role")} + + +class ChangePasswordIn(BaseModel): + old_password: str + new_password: str + + +@router.put("/password") +def change_password(body: ChangePasswordIn, payload: dict = Depends(require_user)): + username = payload.get("sub") + if len(body.new_password) < 6: + raise HTTPException(status_code=400, detail="새 비밀번호는 6자 이상") + if not users_db.change_password(username, body.old_password, body.new_password): + raise HTTPException(status_code=400, detail="현재 비밀번호 불일치") + return {"ok": True} diff --git a/backend/app/routes/automation_route.py b/backend/app/routes/automation_route.py new file mode 100644 index 0000000..a8fa26a --- /dev/null +++ b/backend/app/routes/automation_route.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Dict, Any +import exchange_keys +import exchange_adapters +from ..auth import require_user + +router = APIRouter() + + +@router.get("") +def get_config(_: dict = Depends(require_user)): + return exchange_keys.automation_all() + + +class UpdateIn(BaseModel): + values: Dict[str, Any] + + +@router.put("") +def update_config(body: UpdateIn, _: dict = Depends(require_user)): + for k, v in body.values.items(): + exchange_keys.automation_set(k, v) + return {"ok": True, "saved": len(body.values)} + + +@router.post("/test/balance") +def test_balance(_: dict = Depends(require_user)): + """활성 키로 DryRun 어댑터 호출 — 동작 검증.""" + cfg = exchange_keys.automation_all() + cred_id = cfg.get("active_credential", "") + if not cred_id: + return {"ok": False, "error": "활성 키 미선택"} + cred = exchange_keys.get_credential(int(cred_id)) + adapter = exchange_adapters.make_adapter(cred, dry_run=True) + bal = adapter.get_balance("USDT") + return {"ok": True, "balance": bal, "exchange": adapter.exchange} diff --git a/backend/app/routes/exchange_route.py b/backend/app/routes/exchange_route.py new file mode 100644 index 0000000..a3ee0c6 --- /dev/null +++ b/backend/app/routes/exchange_route.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +import exchange_keys +import exchange_adapters +from ..auth import require_user + +router = APIRouter() + + +class CredIn(BaseModel): + exchange: str + label: str = "" + api_key: str + api_secret: str + passphrase: Optional[str] = None + testnet: bool = False + + +class CredUpdateIn(BaseModel): + exchange: Optional[str] = None + label: Optional[str] = None + api_key: Optional[str] = None + api_secret: Optional[str] = None + passphrase: Optional[str] = None + testnet: Optional[bool] = None + enabled: Optional[bool] = None + + +def _serialize_cred(c): + d = dict(c) + for k in ["created_at", "updated_at"]: + if d.get(k) is not None: + d[k] = str(d[k]) + return d + + +@router.get("/credentials") +def list_creds(_: dict = Depends(require_user)): + return [_serialize_cred(c) for c in exchange_keys.list_credentials()] + + +@router.post("/credentials") +def add_cred(body: CredIn, _: dict = Depends(require_user)): + cid = exchange_keys.add_credential( + body.exchange, body.label, body.api_key, body.api_secret, + body.passphrase, body.testnet, True, + ) + if not cid: + raise HTTPException(status_code=400, detail="등록 실패") + return {"id": cid} + + +@router.put("/credentials/{cred_id}") +def update_cred(cred_id: int, body: CredUpdateIn, _: dict = Depends(require_user)): + fields = {k: v for k, v in body.dict().items() if v is not None} + if not fields: + raise HTTPException(status_code=400, detail="변경 항목 없음") + if not exchange_keys.update_credential(cred_id, **fields): + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + +@router.delete("/credentials/{cred_id}") +def delete_cred(cred_id: int, _: dict = Depends(require_user)): + if not exchange_keys.delete_credential(cred_id): + raise HTTPException(status_code=404, detail="not found") + return {"ok": True} + + +@router.get("/exchanges") +def supported_exchanges(_: dict = Depends(require_user)): + return exchange_keys.SUPPORTED_EXCHANGES diff --git a/backend/app/routes/market_route.py b/backend/app/routes/market_route.py new file mode 100644 index 0000000..ab6c63a --- /dev/null +++ b/backend/app/routes/market_route.py @@ -0,0 +1,94 @@ +""" +시장 데이터 + 신호 — 차트용. +프론트는 /api/market/dashboard?symbol=&interval=&limit= 한 번 호출로 +모든 데이터 (캔들 + 지표 + 신호 + OI + FR + L/S + 펀딩비 banner) 받음. +""" +from fastapi import APIRouter, Depends, HTTPException, Query +import math +import pandas as pd +from datetime import datetime +import core_logic +import settings_db +from ..auth import require_user + +router = APIRouter() + + +def _to_jsonable(v): + if v is None: + return None + if isinstance(v, float) and (math.isnan(v) or math.isinf(v)): + return None + if isinstance(v, (pd.Timestamp, datetime)): + return v.isoformat() + if isinstance(v, bool): + return bool(v) + return v + + +def _serialize_df(df: pd.DataFrame, columns: list) -> list: + out = [] + cols = [c for c in columns if c in df.columns] + for _, row in df.iterrows(): + out.append({c: _to_jsonable(row[c]) for c in cols}) + return out + + +@router.get("/dashboard") +def dashboard( + symbol: str = Query("BTCUSDT"), + interval: str = Query("5m"), + limit: int = Query(200, ge=10, le=500), + _: dict = Depends(require_user), +): + try: + df = core_logic.build_signal_df(symbol, interval, limit) + except Exception as e: + raise HTTPException(status_code=502, detail=f"upstream binance: {e}") + + # 펀딩비 배너 처리 + banner = None + try: + fr_df = core_logic.get_funding_rate(symbol, 1) + if not fr_df.empty: + rate = float(fr_df["fundingRate"].iloc[-1]) + fr_extreme = settings_db.get_float("fr_short_extreme", -0.007) + fr_caution = settings_db.get_float("fr_short_caution", -0.005) + fr_overheat = settings_db.get_float("fr_long_overheat", 0.005) + if rate <= fr_extreme: + banner = {"level": "danger", "text": f"극단적 숏스퀴즈 위험 | FR: {rate:.4f}% | 숏 신규진입 절대 금지", "rate": rate} + elif rate <= fr_caution: + banner = {"level": "warning", "text": f"숏스퀴즈 경보 | FR: {rate:.4f}% | 숏 진입 시 청산가 재확인", "rate": rate} + elif rate >= fr_overheat: + banner = {"level": "info", "text": f"롱 과열 | FR: {rate:.4f}% | 롱스퀴즈 주의", "rate": rate} + else: + banner = {"level": "success", "text": f"FR 정상 | {rate:.4f}%", "rate": rate} + except Exception: + pass + + cols = ["open_time", "open", "high", "low", "close", "volume", + "taker_buy_vol", "taker_sell_vol", + "MA7", "MA25", "MA99", "MA200", "BB_upper", "BB_lower", "BB_mid", + "RSI", "StochRSI_k", "StochRSI_d", "MACD", "MACD_signal", "MACD_hist", + "sumOpenInterest", "fundingRate", "longShortRatio", + "long_signal", "short_signal", "strong_long_signal", "strong_short_signal", + "vol_long_signal", "vol_short_signal", "reversal_long_signal", "reversal_short_signal", + "exhaustion_long", "exhaustion_short", "short_caution_signal"] + + rows = _serialize_df(df, cols) + last_price = float(df.iloc[-1]["close"]) if not df.empty else None + + return { + "symbol": symbol, + "interval": interval, + "rows": rows, + "last_price": last_price, + "banner": banner, + } + + +@router.get("/funding") +def funding(symbol: str = Query("BTCUSDT"), limit: int = Query(100, ge=1, le=500), + _: dict = Depends(require_user)): + df = core_logic.get_funding_rate(symbol, limit) + return _serialize_df(df, ["fundingTime", "fundingRate"]) diff --git a/backend/app/routes/settings_route.py b/backend/app/routes/settings_route.py new file mode 100644 index 0000000..33bf84c --- /dev/null +++ b/backend/app/routes/settings_route.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Dict, Any + +import settings_db +import alert_state +from ..auth import require_user + +router = APIRouter() + + +@router.get("") +def get_all(_: dict = Depends(require_user)): + safe = settings_db.all_settings() + return safe + + +class UpdateIn(BaseModel): + values: Dict[str, Any] + + +@router.put("") +def update_settings(body: UpdateIn, _: dict = Depends(require_user)): + for k, v in body.values.items(): + settings_db.set_value(k, v) + # alert symbol 동기화 + sym = settings_db.get("alert_symbol", "BTCUSDT") + with alert_state.alert_lock: + alert_state.alert_symbol = sym + return {"ok": True, "saved": len(body.values)} diff --git a/backend/app/routes/trades_route.py b/backend/app/routes/trades_route.py new file mode 100644 index 0000000..e22e2ab --- /dev/null +++ b/backend/app/routes/trades_route.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends, Query +import trades_db +from ..auth import require_user + +router = APIRouter() + + +def _serialize(rows): + out = [] + for r in rows: + d = dict(r) + for k in ["entry_time", "exit_time", "candle_time", "fired_at"]: + if d.get(k) is not None: + d[k] = str(d[k]) + out.append(d) + return out + + +@router.get("") +def list_trades(limit: int = Query(500, ge=1, le=2000), _: dict = Depends(require_user)): + return _serialize(trades_db.fetch_trades(limit=limit)) + + +@router.get("/signals") +def list_signal_events(limit: int = Query(1000, ge=1, le=5000), _: dict = Depends(require_user)): + return _serialize(trades_db.fetch_signal_events(limit=limit)) diff --git a/backend/app/routes/users_route.py b/backend/app/routes/users_route.py new file mode 100644 index 0000000..b9ed10c --- /dev/null +++ b/backend/app/routes/users_route.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +import users_db +from ..auth import require_user, require_admin + +router = APIRouter() + + +@router.get("") +def list_users(_: dict = Depends(require_admin)): + rows = users_db.list_users() + return [ + { + "id": r["id"], "username": r["username"], "role": r["role"], + "created_at": str(r["created_at"]) if r.get("created_at") else None, + "last_login_at": str(r["last_login_at"]) if r.get("last_login_at") else None, + } + for r in rows + ] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9a9b702 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.6.0 +python-jose[cryptography]>=3.3.0 +python-multipart>=0.0.9 +pandas>=2.0.0 +numpy>=1.24.0 +ta>=0.11.0 +requests>=2.31.0 +python-dotenv>=1.0.0 +urllib3>=2.0.0 +psycopg2-binary>=2.9.9 +cryptography>=42.0.0 +bcrypt>=4.0.0 diff --git a/core_logic.py b/core_logic.py new file mode 100644 index 0000000..864e565 --- /dev/null +++ b/core_logic.py @@ -0,0 +1,488 @@ +""" +Streamlit 의존이 없는 핵심 비즈니스 로직. +- Binance Futures API 데이터 수집 +- 지표 / 신호 계산 +- 알림 (텔레그램) + 트레이드 lifecycle 기록 +- 알림 / 일일 리포트 백그라운드 루프 + +기존 app_streamlit.py 에서 그대로 추출. FastAPI 에서도 그대로 import. +""" +import os +import time +import threading +import requests +import pandas as pd +import numpy as np +from datetime import datetime, timezone, timedelta +import ta +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +import alert_state +import settings_db +import trades_db + + +BASE = "https://fapi.binance.com" +KST = timedelta(hours=9) +STOP_LOSS_PCT = 0.0075 + +LONG_SIGNALS = {"strong_long_signal", "long_signal", "vol_long_signal", "reversal_long_signal"} +SHORT_SIGNALS = {"strong_short_signal", "short_signal", "vol_short_signal", "reversal_short_signal"} +TF_LABEL_MAP = { + "1m": "1분봉", "3m": "3분봉", "5m": "5분봉", + "15m": "15분봉", "30m": "30분봉", + "1h": "1시간봉", "4h": "4시간봉", "12h": "12시간봉", + "1d": "1일봉", "3d": "3일봉", "1M": "1개월봉", +} + +SIG_DEFS = [ + ("strong_long_signal", "strong_long", "🟢 강한 롱", "long"), + ("strong_short_signal", "strong_short", "🔴 강한 숏", "short"), + ("long_signal", "long", "🔼 일반 롱", "long"), + ("short_signal", "short", "🔽 일반 숏", "short"), + ("vol_long_signal", "vol_long", "🔼 볼륨 롱", "long"), + ("vol_short_signal", "vol_short", "🔽 볼륨 숏", "short"), + ("reversal_long_signal", "rev_long", "🔄 롱 추세 꺾임 감지", "long"), + ("reversal_short_signal","rev_short", "🔄 숏 추세 꺾임 감지", "short"), + ("short_caution_signal", "short_caution","⚠️ 숏 주의", "caution"), +] + + +# ── 설정 helpers ── +def TELEGRAM_TOKEN(): return settings_db.get("telegram_token", "") +def TELEGRAM_CHAT_ID(): return settings_db.get("telegram_chat_id", "") +def ALERT_COOLDOWN(): return settings_db.get_int("alert_cooldown_sec", 600) +def STOP_LOSS_PCT_v(): return settings_db.get_float("stop_loss_pct", 0.0075) + + +# ── 텔레그램 ── +def send_telegram(message: str): + token = TELEGRAM_TOKEN() + chat_id = TELEGRAM_CHAT_ID() + if not token or not chat_id: + print("[텔레그램] 토큰/chat_id 미설정 — skip") + return + try: + url = f"https://api.telegram.org/bot{token}/sendMessage" + requests.post(url, data={"chat_id": chat_id, "text": message}, timeout=10) + except Exception as e: + print(f"[텔레그램 오류] {e}") + + +# ── Binance Futures fetch ── +def get_klines(symbol="BTCUSDT", interval="5m", limit=375): + url = f"{BASE}/fapi/v1/klines" + r = requests.get(url, params={"symbol": symbol, "interval": interval, "limit": limit}, timeout=10, verify=False) + df = pd.DataFrame(r.json(), columns=[ + "open_time","open","high","low","close","volume", + "close_time","quote_vol","trades","taker_buy_vol","taker_sell_vol","ignore" + ]) + for c in ["open","high","low","close","volume","taker_buy_vol","taker_sell_vol"]: + df[c] = df[c].astype(float) + df["taker_sell_vol"] = df["volume"] - df["taker_buy_vol"] + df["open_time"] = pd.to_datetime(df["open_time"], unit="ms") + KST + return df + + +def get_funding_rate(symbol="BTCUSDT", limit=100): + url = f"{BASE}/fapi/v1/fundingRate" + r = requests.get(url, params={"symbol": symbol, "limit": limit}, timeout=10, verify=False) + df = pd.DataFrame(r.json()) + if df.empty: + return df + df["fundingRate"] = df["fundingRate"].astype(float) * 100 + df["fundingTime"] = pd.to_datetime(df["fundingTime"], unit="ms") + KST + return df + + +def get_open_interest_history(symbol="BTCUSDT", period="5m", limit=100): + url = f"{BASE}/futures/data/openInterestHist" + r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False) + df = pd.DataFrame(r.json()) + if df.empty: + return df + df["sumOpenInterest"] = df["sumOpenInterest"].astype(float) + df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST + return df + + +def get_long_short_ratio(symbol="BTCUSDT", period="5m", limit=500): + url = f"{BASE}/futures/data/topLongShortPositionRatio" + r = requests.get(url, params={"symbol": symbol, "period": period, "limit": limit}, timeout=10, verify=False) + df = pd.DataFrame(r.json()) + if df.empty: + return df + df["longShortRatio"] = df["longShortRatio"].astype(float) + df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + KST + return df + + +def _to_floor_freq(period): + return {"1m":"1min","3m":"3min","5m":"5min","15m":"15min","30m":"30min","1h":"1h","4h":"4h","12h":"12h","1d":"1D","3d":"3D","1M":"1ME"}.get(period, period) + + +# ── 지표 + 신호 ── +def compute_indicators(df, interval="5m"): + c = df["close"] + df["MA7"] = c.rolling(7).mean() + df["MA25"] = c.rolling(25).mean() + df["MA99"] = c.rolling(99).mean() + df["MA200"] = c.rolling(200).mean() + df["BB_mid"] = c.rolling(20).mean() + df["BB_std"] = c.rolling(20).std() + df["BB_upper"] = df["BB_mid"] + 2 * df["BB_std"] + df["BB_lower"] = df["BB_mid"] - 2 * df["BB_std"] + df["RSI"] = ta.momentum.RSIIndicator(c, window=14).rsi() + macd = ta.trend.MACD(c, window_slow=26, window_fast=12, window_sign=9) + df["MACD"] = macd.macd() + df["MACD_signal"] = macd.macd_signal() + df["MACD_hist"] = macd.macd_diff() + stoch = ta.momentum.StochRSIIndicator(c, window=14, smooth1=3, smooth2=3) + df["StochRSI_k"] = stoch.stochrsi_k() * 100 + df["StochRSI_d"] = stoch.stochrsi_d() * 100 + df["ATR"] = ta.volatility.AverageTrueRange(df["high"], df["low"], df["close"], window=14).average_true_range() + df = compute_signals(df, interval) + return df + + +def compute_signals(df, interval="5m"): + LONG_RSI_MAX = settings_db.get_float("long_rsi_max", 75.0) + SHORT_RSI_MIN = settings_db.get_float("short_rsi_min", 25.0) + SLONG_RSI_MAX = settings_db.get_float("strong_long_rsi_max", 65.0) + SSHORT_RSI_MIN = settings_db.get_float("strong_short_rsi_min", 35.0) + BODY_PCT_MIN = settings_db.get_float("body_pct_min", 0.002) + REV_BODY_PCT = settings_db.get_float("reversal_body_pct", 0.003) + REV_VOL_MULT = settings_db.get_float("reversal_vol_mult", 1.3) + VOL_EXH_MULT = settings_db.get_float("vol_exhaustion_mult", 3.0) + VOL_NET_MULT = settings_db.get_float("vol_net_mult", 2.0) + OI_ACTIVE_PCT = settings_db.get_float("oi_active_pct", 0.001) + FR_SHORT_EXTREME = settings_db.get_float("fr_short_extreme", -0.007) + + df["bull_ma_2"] = (df["close"] > df["MA7"]) & (df["close"] > df["MA25"]) + df["bear_ma_2"] = (df["close"] < df["MA7"]) & (df["close"] < df["MA25"]) + df["bull_ma"] = (df["close"] > df["MA7"]) & (df["MA7"] > df["MA25"]) + df["bear_ma"] = (df["close"] < df["MA7"]) & (df["MA7"] < df["MA25"]) + bb_range = (df["BB_upper"] - df["BB_lower"]).replace(0, float("nan")) + df["bb_pos"] = (df["close"] - df["BB_lower"]) / bb_range + body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan")) + df["long_signal"] = df["bull_ma_2"] & (df["RSI"] < LONG_RSI_MAX) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & (df["close"] > df["BB_mid"]) & (body_pct >= BODY_PCT_MIN) + df["short_signal"] = df["bear_ma_2"] & (df["RSI"] > SHORT_RSI_MIN) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & (df["close"] < df["BB_mid"]) & (body_pct <= -BODY_PCT_MIN) + df["long_signal"] = df["long_signal"] & (df["long_signal"].rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) + df["short_signal"] = df["short_signal"] & (df["short_signal"].rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0) + + if "sumOpenInterest" in df.columns and df["sumOpenInterest"].notna().sum() > 5: + oi_series = df["sumOpenInterest"].ffill() + else: + oi_series = df["close"] * df["volume"] + df["oi_up"] = oi_series > oi_series.shift(1) + df["oi_down"] = oi_series < oi_series.shift(1) + df["oi_up_2"] = df["oi_up"] & df["oi_up"].shift(1).fillna(False) + df["oi_down_2"] = df["oi_down"] & df["oi_down"].shift(1).fillna(False) + df["oi_active"] = oi_series.pct_change().abs() > OI_ACTIVE_PCT + + df["taker_buy_dom"] = df["taker_buy_vol"] > df["taker_sell_vol"] + df["taker_sell_dom"] = df["taker_sell_vol"] > df["taker_buy_vol"] + df["taker_buy_2"] = df["taker_buy_dom"] & df["taker_buy_dom"].shift(1).fillna(False) + df["taker_sell_2"] = df["taker_sell_dom"] & df["taker_sell_dom"].shift(1).fillna(False) + + df["fr_long_favor"] = df["taker_buy_vol"].rolling(3).mean() > df["taker_sell_vol"].rolling(3).mean() + df["fr_short_favor"] = df["taker_sell_vol"].rolling(3).mean() > df["taker_buy_vol"].rolling(3).mean() + + df["strong_long_signal"] = df["bull_ma_2"] & (df["RSI"] < SLONG_RSI_MAX) & (df["MACD_hist"] > df["MACD_hist"].shift(1)) & df["oi_up_2"] & df["taker_buy_2"] & df["fr_long_favor"] & (df["close"] > df["open"]) + df["strong_short_signal"] = df["bear_ma_2"] & (df["RSI"] > SSHORT_RSI_MIN) & (df["MACD_hist"] < df["MACD_hist"].shift(1)) & df["oi_down_2"] & df["taker_sell_2"] & df["fr_short_favor"] & (df["close"] < df["open"]) + df["strong_long_signal"] = df["strong_long_signal"] & (df["strong_long_signal"].rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0) + df["strong_short_signal"] = df["strong_short_signal"] & (df["strong_short_signal"].rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0) + + vol_avg = df["volume"].rolling(10).mean() + spike = df["volume"] > vol_avg * VOL_EXH_MULT + buy_spike = spike & (df["taker_buy_vol"] > df["taker_sell_vol"]) + sell_spike = spike & (df["taker_sell_vol"] > df["taker_buy_vol"]) + df["exhaustion_short"] = buy_spike.shift(1).fillna(False) + df["exhaustion_long"] = sell_spike.shift(1).fillna(False) + + _vol_min_map = {"1m": 33, "3m": 100, "5m": 100, "15m": 300, "30m": 600, "1h": 1200, "2h": 2400, "4h": 4800, "12h": 14400, "1d": 28800, "3d": 86400, "1M": 864000} + _vol_min = _vol_min_map.get(interval, 100) + + df["sell_net"] = df["taker_sell_vol"] - df["taker_buy_vol"] + sell_net_avg = df["sell_net"].rolling(10).mean() + sell_spike_strong = ( + (df["sell_net"] > sell_net_avg * VOL_NET_MULT) & + (df["sell_net"] > 0) & + (df["taker_sell_vol"] > _vol_min) & + df["oi_active"] + ) + cooldown_vol_short = sell_spike_strong.rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0 + df["vol_short_signal"] = sell_spike_strong & cooldown_vol_short + + df["buy_net"] = df["taker_buy_vol"] - df["taker_sell_vol"] + buy_net_avg = df["buy_net"].rolling(10).mean() + buy_spike_strong = ( + (df["buy_net"] > buy_net_avg * VOL_NET_MULT) & + (df["buy_net"] > 0) & + (df["taker_buy_vol"] > _vol_min) & + df["oi_active"] + ) + cooldown_vol_long = buy_spike_strong.rolling(10, min_periods=1).sum().shift(1).fillna(0) == 0 + df["vol_long_signal"] = buy_spike_strong & cooldown_vol_long + + if "fundingRate" in df.columns and "sumOpenInterest" in df.columns: + fr_extreme = df["fundingRate"] <= FR_SHORT_EXTREME + raw_signal = df["oi_down_2"] & fr_extreme + cooldown_mask = raw_signal.rolling(5, min_periods=1).sum().shift(1).fillna(0) == 0 + df["short_caution_signal"] = raw_signal & cooldown_mask + else: + df["short_caution_signal"] = False + + prior_close = df["close"].shift(1) + prior_close_3 = df["close"].shift(3) + was_up = prior_close > prior_close_3 + was_down = prior_close < prior_close_3 + candle_body_pct = (df["close"] - df["open"]) / df["open"].replace(0, float("nan")) + vol_avg3 = df["volume"].rolling(3).mean().shift(1) + vol_strong = df["volume"] > vol_avg3 * REV_VOL_MULT + rev_short_raw = was_up & (candle_body_pct < -REV_BODY_PCT) & vol_strong + rev_long_raw = was_down & (candle_body_pct > REV_BODY_PCT) & vol_strong + df["reversal_short_signal"] = rev_short_raw & (rev_short_raw.rolling(3, min_periods=1).sum().shift(1).fillna(0) == 0) + df["reversal_long_signal"] = rev_long_raw & (rev_long_raw.rolling(3, min_periods=1).sum().shift(1).fillna(0) == 0) + + return df + + +def build_signal_df(symbol, interval, klines_limit=200): + """알림 / API 공용 - klines + OI + FR 머지 + 지표/신호 계산""" + df = get_klines(symbol, interval, klines_limit) + oi_period = interval if interval in ["5m","15m","30m","1h","4h","12h","1d","3d","1M"] else "5m" + try: + oi = get_open_interest_history(symbol, oi_period, min(klines_limit, 500)) + if not oi.empty: + oi_m = oi[["timestamp","sumOpenInterest"]].rename(columns={"timestamp":"open_time"}) + df["open_time_r"] = df["open_time"].dt.floor(_to_floor_freq(oi_period)) + oi_m["open_time"] = oi_m["open_time"].dt.floor(_to_floor_freq(oi_period)) + df = df.merge(oi_m, left_on="open_time_r", right_on="open_time", how="left", suffixes=("","_oi")) + df = df.drop(columns=["open_time_r","open_time_oi"], errors="ignore") + df["sumOpenInterest"] = df["sumOpenInterest"].ffill() + except Exception: pass + try: + fr = get_funding_rate(symbol, 200) + if not fr.empty: + fr_m = fr[["fundingTime","fundingRate"]].rename(columns={"fundingTime":"open_time"}) + fr_m["open_time"] = fr_m["open_time"].dt.floor(_to_floor_freq("1h")) + df["open_time_r2"] = df["open_time"].dt.floor(_to_floor_freq("1h")) + df = df.merge(fr_m, left_on="open_time_r2", right_on="open_time", how="left", suffixes=("","_fr")) + df = df.drop(columns=["open_time_r2","open_time_fr"], errors="ignore") + df["fundingRate"] = df["fundingRate"].ffill().fillna(0) + except Exception: pass + df = compute_indicators(df, interval) + return df + + +# ── 알림 코어 ── +def check_and_alert(df, symbol, interval): + now = time.time() + if df is None or df.empty: + return + forming_ct = df.iloc[-1]["open_time"] + + if interval not in alert_state.synced_intervals: + for sig, key, _, _ in SIG_DEFS: + if sig not in df.columns: + continue + triggered = df[df[sig].fillna(False)] + if not triggered.empty: + alert_state.last_fired_candle[(interval, key)] = triggered.iloc[-1]["open_time"] + alert_state.synced_intervals.add(interval) + print(f"[알림스레드] {interval} 초기 sync 완료") + return + + new_pending = [] + for p in alert_state.pending_groups: + if p["interval"] != interval: + new_pending.append(p) + continue + ct = p["candle_time"] + row_match = df[df["open_time"] == ct] + if row_match.empty: + continue + row = row_match.iloc[0] + any_still_true = any(bool(row.get(s, False)) for s in p["sig_cols"]) + if any_still_true: + if ct == forming_ct: + new_pending.append(p) + else: + send_telegram(f"[취소 알림]\n{p['msg']}") + le = alert_state.long_entry.get(interval) + se = alert_state.short_entry.get(interval) + if p["direction"] == "long" and le is not None and le.get("open_time") == ct: + trades_db.record_exit(symbol, interval, "long", ct, float(row["close"]), "cancelled") + alert_state.long_entry[interval] = None + elif p["direction"] == "short" and se is not None and se.get("open_time") == ct: + trades_db.record_exit(symbol, interval, "short", ct, float(row["close"]), "cancelled") + alert_state.short_entry[interval] = None + alert_state.pending_groups = new_pending + + recent = df.tail(3) + eligible = [] + for sig, key, sub_label, direction in SIG_DEFS: + if sig not in recent.columns: + continue + triggered = recent[recent[sig].fillna(False)] + seen_key = (interval, sig) + prev_seen = alert_state.signal_seen_count.get(seen_key) + if triggered.empty: + if prev_seen: + alert_state.signal_seen_count[seen_key] = {"candle_time": prev_seen["candle_time"], "count": 0} + continue + candle_time = triggered.iloc[-1]["open_time"] + state_key = (interval, key) + if candle_time == alert_state.last_fired_candle.get(state_key): + continue + if now - alert_state.last_alert.get(state_key, 0) <= ALERT_COOLDOWN(): + continue + if prev_seen is None or prev_seen["candle_time"] != candle_time: + alert_state.signal_seen_count[seen_key] = {"candle_time": candle_time, "count": 1} + else: + alert_state.signal_seen_count[seen_key] = {"candle_time": candle_time, "count": prev_seen["count"] + 1} + count = alert_state.signal_seen_count[seen_key]["count"] + stable_min = settings_db.get_int("forming_stable_polls", 2) + if candle_time == forming_ct and count < stable_min: + continue + eligible.append({ + "sig": sig, "key": key, "sub_label": sub_label, + "direction": direction, "candle_time": candle_time, "row": triggered.iloc[-1], + }) + + groups = {"long": [], "short": [], "caution": []} + for e in eligible: + groups[e["direction"]].append(e) + + tf_label = TF_LABEL_MAP.get(interval, interval) + + def _send_group(group): + if not group: + return + candle_time = group[0]["candle_time"] + candle_time_str = pd.Timestamp(candle_time).strftime("%Y-%m-%d %H:%M") + sub_labels = " + ".join(e["sub_label"] for e in group) + direction = group[0]["direction"] + trades_db.log_signal_events(symbol, interval, group) + if direction == "caution": + msg = f"{sub_labels} 신호\n{symbol} {tf_label}\n시간: {candle_time_str}" + send_telegram(msg) + else: + entry_price = float(group[0]["row"]["open"]) + sl_pct = STOP_LOSS_PCT_v() + stop_price = entry_price * (1 - sl_pct) if direction == "long" else entry_price * (1 + sl_pct) + msg = ( + f"{sub_labels} 진입 신호\n{symbol} {tf_label}\n" + f"시간: {candle_time_str}\n진입가: {entry_price:,.2f}\n손절가: {stop_price:,.2f}" + ) + entry_record = {"price": entry_price, "stop": stop_price, "open_time": candle_time, "entry_msg": msg} + if interval in ("30m", "1h"): + opposite_dict = alert_state.short_entry if direction == "long" else alert_state.long_entry + opposite_label = "숏" if direction == "long" else "롱" + opposite_direction = "short" if direction == "long" else "long" + for opp_interval, opp_rec in list(opposite_dict.items()): + if opp_rec is None: + continue + send_telegram( + f"[반대 신호 감지 - {opposite_label} 청산 권장]\n" + f"--- 기존 진입 ---\n{opp_rec['entry_msg']}\n" + f"--- 반대 신호 ---\n{msg}" + ) + trades_db.record_exit(symbol, opp_interval, opposite_direction, + opp_rec.get("open_time"), entry_price, "reversal") + opposite_dict[opp_interval] = None + if direction == "long": + alert_state.long_entry[interval] = entry_record + else: + alert_state.short_entry[interval] = entry_record + trades_db.record_entry(symbol, interval, direction, + [e["sig"] for e in group], + candle_time, entry_price, stop_price) + send_telegram(msg) + for e in group: + alert_state.last_alert[(interval, e["key"])] = now + alert_state.last_fired_candle[(interval, e["key"])] = e["candle_time"] + if candle_time == forming_ct: + alert_state.pending_groups.append({ + "interval": interval, "direction": direction, "candle_time": candle_time, + "msg": msg, "sig_cols": [e["sig"] for e in group], + }) + + _send_group(groups.get("long", [])) + _send_group(groups.get("short", [])) + _send_group(groups.get("caution", [])) + + current_price = float(df.iloc[-1]["close"]) + le = alert_state.long_entry.get(interval) + se = alert_state.short_entry.get(interval) + if le is not None and current_price <= le["stop"]: + send_telegram(f"[손절가알림]\n{le['entry_msg']}\n현재가: {current_price:,.2f}") + trades_db.record_exit(symbol, interval, "long", le.get("open_time"), current_price, "stop_loss") + alert_state.long_entry[interval] = None + if se is not None and current_price >= se["stop"]: + send_telegram(f"[손절가알림]\n{se['entry_msg']}\n현재가: {current_price:,.2f}") + trades_db.record_exit(symbol, interval, "short", se.get("open_time"), current_price, "stop_loss") + alert_state.short_entry[interval] = None + + +# ── 백그라운드 루프 (FastAPI startup 에서 호출) ── +def alert_timeframes(): + return settings_db.get_list("alert_timeframes", default=["5m", "15m", "30m", "1h"]) + + +def alert_loop(): + while True: + poll = max(10, settings_db.get_int("polling_interval_sec", 30)) + if not settings_db.get_bool("alert_enabled", True): + time.sleep(poll) + continue + with alert_state.alert_lock: + symbol = alert_state.alert_symbol + for interval in alert_timeframes(): + try: + df = build_signal_df(symbol, interval, 200) + check_and_alert(df, symbol, interval) + except Exception as e: + print(f"[알림스레드 오류] {interval}: {e}") + time.sleep(poll) + + +def daily_report_loop(): + while True: + try: + if not settings_db.get_bool("daily_report_enabled", True): + time.sleep(60) + continue + now_kst = (datetime.now(timezone.utc) + KST).replace(tzinfo=None) + today_str = now_kst.strftime("%Y-%m-%d") + if alert_state.last_report_date is None: + alert_state.last_report_date = today_str + print(f"[일일리포트] 스레드 기동 -- 다음 자정({today_str} 24:00 KST) 까지 대기") + elif alert_state.last_report_date != today_str: + print(f"[일일리포트] 자정 통과 감지 -> 발송 ({today_str})") + # send_daily_report 는 app_streamlit.py 안에 있는 그대로 사용 (없어도 silent skip) + try: + from app_streamlit import send_daily_report + with alert_state.alert_lock: + symbol = alert_state.alert_symbol + send_daily_report(symbol) + except Exception as e: + print(f"[일일리포트 호출 실패] {e}") + alert_state.last_report_date = today_str + except Exception as e: + print(f"[일일리포트 스레드 오류] {e}") + time.sleep(60) + + +def start_background_threads(): + """FastAPI startup 에서 호출. 한 번만 시작.""" + if not alert_state.alert_started: + t = threading.Thread(target=alert_loop, daemon=True, name="alert_loop") + t.start() + alert_state.alert_started = True + if not alert_state.daily_report_started: + dr = threading.Thread(target=daily_report_loop, daemon=True, name="daily_report_loop") + dr.start() + alert_state.daily_report_started = True diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..014427e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 서버에서 직접 실행하는 배포 스크립트. +# 1. (Docker / docker compose 가 설치되어 있다고 가정) +# 2. 이미지 빌드 + 컨테이너 기동 +# 3. 80 포트로 nginx 가 junggomoa.com 트래픽을 8501 streamlit 으로 프록시 +set -euo pipefail + +cd "$(dirname "$0")" + +if ! command -v docker >/dev/null 2>&1; then + echo "[!] docker 가 설치되어 있지 않습니다. 먼저 설치하세요." >&2 + exit 1 +fi + +# docker compose v2 (`docker compose`) 우선, 없으면 docker-compose v1 fallback +if docker compose version >/dev/null 2>&1; then + DC="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + DC="docker-compose" +else + echo "[!] docker compose / docker-compose 둘 다 없습니다. 설치 후 재시도." >&2 + exit 1 +fi + +echo "[+] 이미지 빌드..." +$DC build + +echo "[+] 컨테이너 기동..." +$DC up -d + +echo +echo "[+] 상태:" +$DC ps + +echo +echo "[+] 로그 (앱 마지막 30줄):" +$DC logs --tail=30 app || true + +echo +echo "[OK] 배포 완료. 외부에서 http://junggomoa.com 으로 접속 확인하세요." +echo " (DNS 가 서버 IP 를 가리키고 있어야 함. 80 포트가 방화벽에서 열려있어야 함.)" +echo " HTTPS 는 별도 단계 — README.md 의 'HTTPS 발급' 섹션 참조." diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4860ebb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +# 운영 배포 (Traefik + junggomoa.com) +# - frontend (Next.js) → junggomoa.com (UI) +# - backend (FastAPI) → junggomoa.com/api/* (REST + 알림스레드) +# - postgres → 내부 (5432) +# - app (Streamlit) → 임시 보존 (legacy.junggomoa.com 등 별도 운영 가능, 일단 비활성) +services: + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + image: junggomoa-frontend:latest + container_name: junggomoa-frontend + restart: always + environment: + - TZ=Asia/Seoul + # 클라이언트는 같은 도메인이라 빈 값. SSR 시 backend 호출용. + - BACKEND_URL=http://backend:8000 + networks: + - traefik-net + - tradeing-internal + depends_on: + - backend + labels: + - traefik.enable=true + - traefik.docker.network=traefik-net + - traefik.http.routers.junggomoa-front.rule=Host(`junggomoa.com`) || Host(`www.junggomoa.com`) + - traefik.http.routers.junggomoa-front.entrypoints=websecure,web + - traefik.http.routers.junggomoa-front.tls=true + - traefik.http.routers.junggomoa-front.tls.certresolver=le + - traefik.http.routers.junggomoa-front.priority=10 + - traefik.http.services.junggomoa-front.loadbalancer.server.port=3000 + + backend: + build: + context: . + dockerfile: backend/Dockerfile + image: junggomoa-backend:latest + container_name: junggomoa-backend + restart: always + environment: + - TZ=Asia/Seoul + - SETTINGS_DB_PATH=/app/data/settings.db + - DATABASE_URL=postgresql://tradeing:${POSTGRES_PASSWORD:-tradeing_pw}@tradeing-postgres:5432/tradeing + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - TELEGRAM_TOKEN=${TELEGRAM_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} + volumes: + - ./data:/app/data + depends_on: + postgres: + condition: service_healthy + networks: + - traefik-net + - tradeing-internal + labels: + - traefik.enable=true + - traefik.docker.network=traefik-net + - traefik.http.routers.junggomoa-api.rule=(Host(`junggomoa.com`) || Host(`www.junggomoa.com`)) && PathPrefix(`/api`) + - traefik.http.routers.junggomoa-api.entrypoints=websecure,web + - traefik.http.routers.junggomoa-api.tls=true + - traefik.http.routers.junggomoa-api.tls.certresolver=le + - traefik.http.routers.junggomoa-api.priority=100 + - traefik.http.services.junggomoa-api.loadbalancer.server.port=8000 + + postgres: + image: postgres:16-alpine + container_name: tradeing-postgres + restart: always + environment: + - POSTGRES_USER=tradeing + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-tradeing_pw} + - POSTGRES_DB=tradeing + - TZ=Asia/Seoul + volumes: + - ./data/pgdata:/var/lib/postgresql/data + networks: + - tradeing-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tradeing -d tradeing"] + interval: 10s + timeout: 5s + retries: 5 + +networks: + traefik-net: + external: true + tradeing-internal: + driver: bridge diff --git a/exchange_adapters.py b/exchange_adapters.py new file mode 100644 index 0000000..8ab80d3 --- /dev/null +++ b/exchange_adapters.py @@ -0,0 +1,93 @@ +""" +거래소 어댑터 인터페이스 placeholder. + +자동매매 본격 구현 시 각 거래소 SDK 를 wrap 한 ExchangeAdapter 구현체를 +이 파일에 추가. 지금은 인터페이스 + 더미 구현 (DRY-RUN 으로 로그만 출력) 만 잡아둔다. + +사용 흐름: + cred = exchange_keys.get_credential(active_id) + adapter = make_adapter(cred) + adapter.place_market_order(symbol="BTCUSDT", side="long", qty=0.01) +""" +from dataclasses import dataclass, asdict +from typing import Optional, Dict, Any, List + + +@dataclass +class OrderResult: + ok: bool + order_id: Optional[str] = None + filled_qty: float = 0.0 + avg_price: float = 0.0 + raw: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + +class ExchangeAdapter: + """모든 거래소 어댑터의 베이스. dry_run=True 면 실제 거래소에 보내지 않는다.""" + def __init__(self, exchange: str, api_key: str, api_secret: str, + passphrase: Optional[str] = None, testnet: bool = False, dry_run: bool = True): + self.exchange = exchange + self.api_key = api_key + self.api_secret = api_secret + self.passphrase = passphrase + self.testnet = testnet + self.dry_run = dry_run + + # 하위 클래스에서 override + def get_balance(self, asset: str = "USDT") -> float: + raise NotImplementedError + + def get_position(self, symbol: str) -> Optional[Dict[str, Any]]: + raise NotImplementedError + + def place_market_order(self, symbol: str, side: str, qty: float, + reduce_only: bool = False) -> OrderResult: + raise NotImplementedError + + def close_position(self, symbol: str) -> OrderResult: + raise NotImplementedError + + def set_leverage(self, symbol: str, leverage: int) -> bool: + raise NotImplementedError + + +class DryRunAdapter(ExchangeAdapter): + """모든 메서드를 stdout 으로만 시뮬레이션. 실제 SDK 가 연결되기 전 사용.""" + def get_balance(self, asset: str = "USDT") -> float: + print(f"[DRY {self.exchange}] get_balance({asset}) -> 1000.0 (stub)") + return 1000.0 + + def get_position(self, symbol: str): + print(f"[DRY {self.exchange}] get_position({symbol}) -> None") + return None + + def place_market_order(self, symbol: str, side: str, qty: float, reduce_only: bool = False) -> OrderResult: + msg = f"[DRY {self.exchange}] MARKET {side.upper()} {symbol} qty={qty} reduce_only={reduce_only}" + print(msg) + return OrderResult(ok=True, order_id="dry-" + symbol, filled_qty=qty, avg_price=0.0, + raw={"note": msg}) + + def close_position(self, symbol: str) -> OrderResult: + msg = f"[DRY {self.exchange}] CLOSE {symbol}" + print(msg) + return OrderResult(ok=True, order_id="dry-close-" + symbol, raw={"note": msg}) + + def set_leverage(self, symbol: str, leverage: int) -> bool: + print(f"[DRY {self.exchange}] set_leverage({symbol}, {leverage}) -> True") + return True + + +def make_adapter(credential: Dict[str, Any], dry_run: bool = True) -> ExchangeAdapter: + """exchange_keys.get_credential() 결과로부터 어댑터 생성. + 실제 거래소별 구현이 추가되기 전엔 모두 DryRunAdapter 반환.""" + if not credential: + return DryRunAdapter("none", "", "", dry_run=True) + return DryRunAdapter( + exchange=credential.get("exchange", ""), + api_key=credential.get("api_key") or "", + api_secret=credential.get("api_secret") or "", + passphrase=credential.get("passphrase"), + testnet=bool(credential.get("testnet")), + dry_run=dry_run, + ) diff --git a/exchange_keys.py b/exchange_keys.py new file mode 100644 index 0000000..49f05a0 --- /dev/null +++ b/exchange_keys.py @@ -0,0 +1,371 @@ +""" +거래소 API 키 영속 저장 (PostgreSQL + Fernet 대칭 암호화). + +마스터 키는 ENCRYPTION_KEY 환경변수 (또는 /app/data/.encryption_key 자동 생성). +컨테이너 재기동 / 백업 시 마스터 키 분실하면 저장된 API 키들 복호화 불가하므로 +.env 에 명시 보관 권장 (docker-compose 의 .env). + +테이블: exchange_credentials + id, exchange, label, api_key_enc, api_secret_enc, passphrase_enc, + testnet, enabled, created_at, updated_at + +자동매매 설정도 단일 row 로 자동 저장 (settings_db 와 분리: PostgreSQL 일원화). +테이블: automation_config (key, value) +""" +import os +import threading +from typing import List, Dict, Optional, Any + +try: + import psycopg2 + import psycopg2.extras + HAS_PG = True +except ImportError: + HAS_PG = False + +try: + from cryptography.fernet import Fernet, InvalidToken + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + +DATABASE_URL = os.environ.get("DATABASE_URL", "") +KEY_FILE = "/app/data/.encryption_key" +_lock = threading.RLock() +_conn = None +_init_done = False +_fernet: Optional["Fernet"] = None + + +def _enabled() -> bool: + return HAS_PG and HAS_CRYPTO and bool(DATABASE_URL) + + +def _get_fernet(): + global _fernet + if _fernet is not None: + return _fernet + if not HAS_CRYPTO: + return None + key = os.environ.get("ENCRYPTION_KEY", "").strip() + if not key: + if os.path.exists(KEY_FILE): + with open(KEY_FILE, "rb") as f: + key = f.read().strip().decode() + else: + os.makedirs(os.path.dirname(KEY_FILE), exist_ok=True) + new_key = Fernet.generate_key() + with open(KEY_FILE, "wb") as f: + f.write(new_key) + os.chmod(KEY_FILE, 0o600) + key = new_key.decode() + print(f"[exchange_keys] 마스터 키 자동 생성됨: {KEY_FILE}. .env 의 ENCRYPTION_KEY 로 옮겨 보관 권장.") + _fernet = Fernet(key.encode() if isinstance(key, str) else key) + return _fernet + + +def _get_conn(): + global _conn + if not _enabled(): + return None + if _conn is not None: + try: + with _conn.cursor() as cur: + cur.execute("SELECT 1") + return _conn + except Exception: + try: _conn.close() + except: pass + _conn = None + try: + _conn = psycopg2.connect(DATABASE_URL, connect_timeout=5) + _conn.autocommit = True + except Exception as e: + print(f"[exchange_keys] connect 실패: {e}") + _conn = None + return _conn + + +def init_db(): + global _init_done + if _init_done or not _enabled(): + return + with _lock: + conn = _get_conn() + if conn is None: + return + try: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS exchange_credentials ( + id BIGSERIAL PRIMARY KEY, + exchange TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + api_key_enc TEXT NOT NULL, + api_secret_enc TEXT NOT NULL, + passphrase_enc TEXT, + testnet BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_excred_exchange ON exchange_credentials(exchange)") + + cur.execute(""" + CREATE TABLE IF NOT EXISTS automation_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + # 기본 자동매매 설정 (없으면 시드) + _seed_automation(cur) + _init_done = True + print("[exchange_keys] init OK") + except Exception as e: + print(f"[exchange_keys] init 실패: {e}") + + +AUTOMATION_DEFAULTS = { + "enabled": "0", # 자동매매 ON/OFF (글로벌 킬스위치) + "dry_run": "1", # 1 = 실제 주문 X, 시그널만 기록 (안전) + "active_credential": "", # exchange_credentials.id (활성 키) + "leverage": "10", + "position_size_pct": "1.0", # 잔고 대비 % + "max_open_trades": "3", + "min_signal_score": "1", # 이 신호 수 이상일 때만 진입 (예: 강한 + 일반 동시) + "allowed_directions": "long,short", # long_only / short_only / long,short + "tp_pct": "0.0", # take profit (%, 0 = OFF) +} + + +def _seed_automation(cur): + for k, v in AUTOMATION_DEFAULTS.items(): + cur.execute( + "INSERT INTO automation_config(key, value) VALUES (%s, %s) ON CONFLICT (key) DO NOTHING", + (k, v), + ) + + +# ────────────────────────────────────────────── +# Encryption helpers +# ────────────────────────────────────────────── +def _encrypt(plaintext: Optional[str]) -> Optional[str]: + if plaintext is None or plaintext == "": + return None + f = _get_fernet() + if f is None: + return None + return f.encrypt(plaintext.encode()).decode() + + +def _decrypt(ciphertext: Optional[str]) -> Optional[str]: + if not ciphertext: + return None + f = _get_fernet() + if f is None: + return None + try: + return f.decrypt(ciphertext.encode()).decode() + except InvalidToken: + return None + + +def _mask(s: Optional[str]) -> str: + if not s: + return "" + if len(s) <= 8: + return "*" * len(s) + return s[:4] + "…" + s[-4:] + + +# ────────────────────────────────────────────── +# Exchange credentials CRUD +# ────────────────────────────────────────────── +SUPPORTED_EXCHANGES = ["binance", "bybit", "okx", "bitget", "upbit", "bithumb"] + + +def list_credentials() -> List[Dict[str, Any]]: + if not _enabled(): + return [] + with _lock: + conn = _get_conn() + if conn is None: + return [] + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + "SELECT id, exchange, label, testnet, enabled, created_at, updated_at, " + "api_key_enc, api_secret_enc, passphrase_enc " + "FROM exchange_credentials ORDER BY id DESC" + ) + rows = cur.fetchall() + for r in rows: + r["api_key_masked"] = _mask(_decrypt(r.pop("api_key_enc", None))) + r["api_secret_masked"] = _mask(_decrypt(r.pop("api_secret_enc", None))) + pp = _decrypt(r.pop("passphrase_enc", None)) + r["passphrase_masked"] = _mask(pp) if pp else "" + return rows + except Exception as e: + print(f"[exchange_keys] list_credentials 실패: {e}") + return [] + + +def get_credential(cred_id: int) -> Optional[Dict[str, Any]]: + """복호화된 키를 그대로 반환. 자동매매 어댑터에서 호출.""" + if not _enabled(): + return None + with _lock: + conn = _get_conn() + if conn is None: + return None + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SELECT * FROM exchange_credentials WHERE id=%s", (cred_id,)) + row = cur.fetchone() + if row is None: + return None + row["api_key"] = _decrypt(row.pop("api_key_enc", None)) + row["api_secret"] = _decrypt(row.pop("api_secret_enc", None)) + row["passphrase"] = _decrypt(row.pop("passphrase_enc", None)) + return row + except Exception as e: + print(f"[exchange_keys] get_credential 실패: {e}") + return None + + +def add_credential(exchange: str, label: str, api_key: str, api_secret: str, + passphrase: Optional[str] = None, testnet: bool = False, enabled: bool = True) -> Optional[int]: + if not _enabled(): + return None + if not api_key or not api_secret: + return None + with _lock: + conn = _get_conn() + if conn is None: + return None + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO exchange_credentials(exchange, label, api_key_enc, api_secret_enc, " + "passphrase_enc, testnet, enabled) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id", + ( + exchange, label or "", + _encrypt(api_key), _encrypt(api_secret), _encrypt(passphrase), + bool(testnet), bool(enabled), + ), + ) + return cur.fetchone()[0] + except Exception as e: + print(f"[exchange_keys] add_credential 실패: {e}") + return None + + +def update_credential(cred_id: int, **fields) -> bool: + if not _enabled() or not fields: + return False + set_parts = [] + values = [] + for k, v in fields.items(): + if k in ("api_key", "api_secret", "passphrase"): + set_parts.append(f"{k}_enc=%s") + values.append(_encrypt(v) if v else None) + elif k in ("exchange", "label"): + set_parts.append(f"{k}=%s") + values.append(v) + elif k in ("testnet", "enabled"): + set_parts.append(f"{k}=%s") + values.append(bool(v)) + if not set_parts: + return False + set_parts.append("updated_at=now()") + values.append(cred_id) + with _lock: + conn = _get_conn() + if conn is None: + return False + try: + with conn.cursor() as cur: + cur.execute(f"UPDATE exchange_credentials SET {', '.join(set_parts)} WHERE id=%s", tuple(values)) + return cur.rowcount > 0 + except Exception as e: + print(f"[exchange_keys] update_credential 실패: {e}") + return False + + +def delete_credential(cred_id: int) -> bool: + if not _enabled(): + return False + with _lock: + conn = _get_conn() + if conn is None: + return False + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM exchange_credentials WHERE id=%s", (cred_id,)) + return cur.rowcount > 0 + except Exception as e: + print(f"[exchange_keys] delete_credential 실패: {e}") + return False + + +# ────────────────────────────────────────────── +# Automation config +# ────────────────────────────────────────────── +def automation_get(key: str, default: str = "") -> str: + if not _enabled(): + return AUTOMATION_DEFAULTS.get(key, default) + with _lock: + conn = _get_conn() + if conn is None: + return AUTOMATION_DEFAULTS.get(key, default) + try: + with conn.cursor() as cur: + cur.execute("SELECT value FROM automation_config WHERE key=%s", (key,)) + row = cur.fetchone() + if row is None: + return AUTOMATION_DEFAULTS.get(key, default) + return row[0] + except Exception as e: + print(f"[exchange_keys] automation_get 실패: {e}") + return AUTOMATION_DEFAULTS.get(key, default) + + +def automation_set(key: str, value: Any) -> bool: + if not _enabled(): + return False + with _lock: + conn = _get_conn() + if conn is None: + return False + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO automation_config(key, value) VALUES (%s, %s) " + "ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()", + (key, str(value)), + ) + return True + except Exception as e: + print(f"[exchange_keys] automation_set 실패: {e}") + return False + + +def automation_all() -> Dict[str, str]: + if not _enabled(): + return dict(AUTOMATION_DEFAULTS) + with _lock: + conn = _get_conn() + if conn is None: + return dict(AUTOMATION_DEFAULTS) + try: + with conn.cursor() as cur: + cur.execute("SELECT key, value FROM automation_config") + rows = cur.fetchall() + d = dict(AUTOMATION_DEFAULTS) + d.update(dict(rows)) + return d + except Exception as e: + print(f"[exchange_keys] automation_all 실패: {e}") + return dict(AUTOMATION_DEFAULTS) diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..381dd6a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,24 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY frontend/package.json ./ +RUN npm install --no-audit --no-fund + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY frontend/ ./ +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 \ + TZ=Asia/Seoul +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -q -O- http://127.0.0.1:3000/login >/dev/null 2>&1 || exit 1 +CMD ["node", "server.js"] diff --git a/frontend/app/automation/page.tsx b/frontend/app/automation/page.tsx new file mode 100644 index 0000000..64dcf8d --- /dev/null +++ b/frontend/app/automation/page.tsx @@ -0,0 +1,84 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { Card, PageHeader, Input, Select, Button, Toggle, Banner } from '@/components/ui'; +import { Bot, FlaskConical } from 'lucide-react'; + +export default function AutomationPage() { + const [cfg, setCfg] = useState({}); + const [creds, setCreds] = useState([]); + const [msg, setMsg] = useState(null); + + async function load() { + const [a, c] = await Promise.all([api.get('/api/automation'), api.get('/api/exchange/credentials')]); + setCfg(a); setCreds(c.filter((x: any) => x.enabled)); + } + useEffect(() => { load(); }, []); + + async function save() { + await api.put('/api/automation', { values: cfg }); + setMsg('✅ 저장 완료'); + } + + async function testBalance() { + try { + const r = await api.post('/api/automation/test/balance', {}); + setMsg(r.ok ? `🧪 DryRun balance=${r.balance} USDT (${r.exchange})` : `❌ ${r.error}`); + } catch (e: any) { setMsg('❌ ' + e.message); } + } + + return ( +
+ + + ⚠️ 실 주문은 거래소별 SDK 어댑터 추가 후 활성화. 지금은 신호 발생 시 stdout 로 시뮬레이션. + + +
+ 자동매매 설정 +
+ +
+ setCfg({ ...cfg, enabled: v ? '1' : '0' })} label="자동매매 ON (글로벌 킬스위치)" /> + setCfg({ ...cfg, dry_run: v ? '1' : '0' })} label="DRY-RUN (실 주문 X)" /> + +
+ +
+ +
+ +
+ setCfg({ ...cfg, leverage: e.target.value })} /> + setCfg({ ...cfg, position_size_pct: e.target.value })} /> + setCfg({ ...cfg, max_open_trades: e.target.value })} /> + setCfg({ ...cfg, min_signal_score: e.target.value })} /> +
+ +
+ setCfg({ ...cfg, tp_pct: e.target.value })} /> +
+ +
+ + +
+ {msg &&
{msg}
} +
+ + +
현재 설정 (raw)
+
{JSON.stringify(cfg, null, 2)}
+
+
+ ); +} diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx new file mode 100644 index 0000000..87ff1c5 --- /dev/null +++ b/frontend/app/exchange/page.tsx @@ -0,0 +1,100 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { Card, PageHeader, Input, Select, Button, Toggle } from '@/components/ui'; +import { Trash2, Plus, KeyRound } from 'lucide-react'; + +export default function ExchangePage() { + const [creds, setCreds] = useState([]); + const [exchanges, setExchanges] = useState([]); + const [form, setForm] = useState({ exchange: 'binance', label: '', api_key: '', api_secret: '', passphrase: '', testnet: false }); + const [msg, setMsg] = useState(null); + + async function load() { + const [c, e] = await Promise.all([api.get('/api/exchange/credentials'), api.get('/api/exchange/exchanges')]); + setCreds(c); setExchanges(e); + } + useEffect(() => { load(); }, []); + + async function add(e: React.FormEvent) { + e.preventDefault(); + if (!form.api_key || !form.api_secret) { setMsg('API Key / Secret 필수'); return; } + try { + await api.post('/api/exchange/credentials', { ...form, passphrase: form.passphrase || null }); + setMsg('✅ 등록 완료'); + setForm({ exchange: 'binance', label: '', api_key: '', api_secret: '', passphrase: '', testnet: false }); + load(); + } catch (err: any) { setMsg('❌ ' + err.message); } + } + + async function toggle(c: any, field: 'enabled' | 'testnet', val: boolean) { + await api.put(`/api/exchange/credentials/${c.id}`, { [field]: val }); + load(); + } + async function del(id: number) { + if (!confirm('삭제하시겠습니까?')) return; + await api.delete(`/api/exchange/credentials/${id}`); + load(); + } + + return ( +
+ + +
+ +
+ 새 키 등록 +
+
+ + setForm({ ...form, label: e.target.value })} placeholder="예: main / sub" /> + setForm({ ...form, api_key: e.target.value })} /> + setForm({ ...form, api_secret: e.target.value })} /> + setForm({ ...form, passphrase: e.target.value })} /> + setForm({ ...form, testnet: v })} label="Testnet" /> + + {msg &&
{msg}
} + +
+ + +
+ 등록된 키 ({creds.length}) +
+ {creds.length === 0 &&
등록된 키 없음
} +
+ {creds.map(c => ( +
+
+
+ {c.exchange.toUpperCase()} + [{c.label || '-'}] + + {c.testnet ? 'TESTNET' : 'LIVE'} + + + {c.enabled ? '활성' : '비활성'} + +
+ +
+
+
Key: {c.api_key_masked}
+
Secret: {c.api_secret_masked}
+
+
+ toggle(c, 'enabled', v)} label="활성" /> + toggle(c, 'testnet', v)} label="Testnet" /> + id #{c.id} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..2050865 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,11 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css'); + +html, body { font-family: 'Pretendard', 'Noto Sans KR', system-ui, sans-serif; } +body { background: #f3f4f6; color: #111827; } + +.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; } +.scrollbar-thin::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..506c515 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import AuthGate from '@/components/AuthGate'; + +export const metadata: Metadata = { + title: 'JUNGGOMOA — 트레이딩 시스템', + description: 'BTC/ETH Futures 자동매매 대시보드', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..9dd5396 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,84 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth'; +import { getToken } from '@/lib/api'; + +export default function LoginPage() { + const router = useRouter(); + const { login, error, loading } = useAuth(); + const [u, setU] = useState(''); + const [p, setP] = useState(''); + + useEffect(() => { if (getToken()) router.replace('/'); }, []); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const ok = await login(u, p); + if (ok) router.replace('/'); + } + + return ( +
+
+
+ + + + + + + + + + + + + + + + + + JUNGGOMOA + 트레이딩 시스템 + +

업무관리 시스템

+

로그인하여 시작하세요

+
+ +
+
+ + setU(e.target.value)} + autoFocus required autoComplete="username" + className="w-full px-3 py-2.5 text-sm rounded-lg border border-slate-300 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white" + placeholder="username" + /> +
+
+ + setP(e.target.value)} + required autoComplete="current-password" + className="w-full px-3 py-2.5 text-sm rounded-lg border border-slate-300 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white" + placeholder="••••••••" + /> +
+ {error &&
{error}
} + +
+ +
+

© 2026 junggomoa.com · all rights reserved

+
+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..7026c85 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,86 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import Chart from '@/components/Chart'; +import { Banner, Card, PageHeader, Select, Toggle } from '@/components/ui'; +import { RefreshCw } from 'lucide-react'; + +const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT']; +const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d']; + +export default function DashboardPage() { + const [symbol, setSymbol] = useState('BTCUSDT'); + const [interval, setIntervalV] = useState('5m'); + const [auto, setAuto] = useState(true); + const [refresh, setRefresh] = useState(30); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tick, setTick] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + api.get(`/api/market/dashboard?symbol=${symbol}&interval=${interval}&limit=200`) + .then(d => { if (!cancelled) setData(d); }) + .catch(e => { if (!cancelled) setError(e.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [symbol, interval, tick]); + + useEffect(() => { + if (!auto) return; + const id = setInterval(() => setTick(t => t + 1), refresh * 1000); + return () => clearInterval(id); + }, [auto, refresh]); + + const now = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); + + return ( +
+ setTick(t => t + 1)} className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-md shadow-sm"> + 새로고침 + + } + /> + + +
+ + +
+ 갱신(초) + setRefresh(parseInt(e.target.value))} + className="w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50" /> +
+
+
+ {loading ? '⏳ 로딩 중...' : data?.last_price ? `현재가: ${data.last_price.toLocaleString()}` : ''} +
+
+
+ + {data?.banner && ( +
+ {data.banner.text} +
+ )} + + {error && {error}} + + + + +
+ ); +} diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx new file mode 100644 index 0000000..9686d73 --- /dev/null +++ b/frontend/app/profile/page.tsx @@ -0,0 +1,63 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { Card, PageHeader, Input, Button, Banner } from '@/components/ui'; +import { User, KeyRound } from 'lucide-react'; + +export default function ProfilePage() { + const { user, fetchMe } = useAuth(); + const [oldPw, setOldPw] = useState(''); + const [newPw, setNewPw] = useState(''); + const [newPw2, setNewPw2] = useState(''); + const [msg, setMsg] = useState<{ level: any; text: string } | null>(null); + + useEffect(() => { fetchMe(); }, []); + + async function change(e: React.FormEvent) { + e.preventDefault(); + setMsg(null); + if (newPw !== newPw2) { setMsg({ level: 'danger', text: '새 비밀번호가 일치하지 않습니다' }); return; } + if (newPw.length < 6) { setMsg({ level: 'danger', text: '새 비밀번호는 6자 이상' }); return; } + try { + await api.put('/api/auth/password', { old_password: oldPw, new_password: newPw }); + setMsg({ level: 'success', text: '✅ 비밀번호 변경 완료' }); + setOldPw(''); setNewPw(''); setNewPw2(''); + } catch (e: any) { setMsg({ level: 'danger', text: e.message }); } + } + + return ( +
+ + +
+ +
+ 계정 정보 +
+
+ + +
+
+ 가입: {user?.created_at || '-'}
+ 마지막 로그인: {user?.last_login_at || '-'} +
+
+ + +
+ 비밀번호 변경 +
+
+ setOldPw(e.target.value)} required /> + setNewPw(e.target.value)} required /> + setNewPw2(e.target.value)} required /> + {msg && {msg.text}} + +
+
+
+
+ ); +} diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx new file mode 100644 index 0000000..da7b719 --- /dev/null +++ b/frontend/app/settings/page.tsx @@ -0,0 +1,152 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { Card, PageHeader, Input, Select, Button, Toggle, Banner } from '@/components/ui'; +import { Send, Bell, Target, Droplet, BarChart3 } from 'lucide-react'; + +const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT']; +const TFS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h']; + +const TABS = [ + { key: 'tg', label: '텔레그램', icon: Send }, + { key: 'alert', label: '알림 / 모니터링', icon: Bell }, + { key: 'signal', label: '신호 임계값', icon: Target }, + { key: 'vol', label: '거래량 / 펀딩비', icon: Droplet }, + { key: 'chart', label: '차트', icon: BarChart3 }, +]; + +export default function SettingsPage() { + const [s, setS] = useState({}); + const [tab, setTab] = useState('tg'); + const [msg, setMsg] = useState(null); + + useEffect(() => { api.get('/api/settings').then(setS); }, []); + + function set(k: string, v: any) { setS({ ...s, [k]: v }); } + function setTfs(arr: string[]) { setS({ ...s, alert_timeframes: arr.join(',') }); } + function tfList() { return (s.alert_timeframes || '').split(',').filter(Boolean); } + function toggleTf(tf: string) { + const list = tfList(); + setTfs(list.includes(tf) ? list.filter(x => x !== tf) : [...list, tf]); + } + + async function save() { + await api.put('/api/settings', { values: s }); + setMsg('✅ 저장 완료. 다음 폴링부터 반영'); + setTimeout(() => setMsg(null), 3000); + } + + return ( +
+ 💾 전체 저장} /> + + {msg &&
{msg}
} + + +
+ {TABS.map(t => ( + + ))} +
+ + {tab === 'tg' && ( +
+
+ set('telegram_token', e.target.value)} placeholder="예: 1234567890:ABCDEF..." /> + set('telegram_chat_id', e.target.value)} placeholder="예: -1001234567890" /> +
+

⚠️ Token 은 plain text 표시. DB 에 저장됨 — 노출 주의.

+
+ )} + + {tab === 'alert' && ( +
+
+ +
+ +
+ {TFS.map(tf => { + const on = tfList().includes(tf); + return ( + + ); + })} +
+
+
+
+ set('alert_cooldown_sec', e.target.value)} /> + set('stop_loss_pct', (parseFloat(e.target.value) / 100).toFixed(6))} /> + set('polling_interval_sec', e.target.value)} /> + set('forming_stable_polls', e.target.value)} /> +
+
+ set('alert_enabled', v ? '1' : '0')} label="알림 활성화" /> + set('daily_report_enabled', v ? '1' : '0')} label="일일 리포트 활성화" /> +
+
+ )} + + {tab === 'signal' && ( +
+
+
RSI 임계값
+
+ set('long_rsi_max', e.target.value)} /> + set('short_rsi_min', e.target.value)} /> + set('strong_long_rsi_max', e.target.value)} /> + set('strong_short_rsi_min', e.target.value)} /> +
+
+
+
캔들 body / 추세 꺾임
+
+ set('body_pct_min', (parseFloat(e.target.value) / 100).toFixed(6))} /> + set('reversal_body_pct', (parseFloat(e.target.value) / 100).toFixed(6))} /> + set('reversal_vol_mult', e.target.value)} /> +
+
+
+ )} + + {tab === 'vol' && ( +
+
+
거래량 배수
+
+ set('vol_exhaustion_mult', e.target.value)} /> + set('vol_net_mult', e.target.value)} /> + set('oi_active_pct', (parseFloat(e.target.value) / 100).toFixed(6))} /> +
+
+
+
펀딩비 임계 (단위: %)
+
+ set('fr_long_overheat', e.target.value)} /> + set('fr_short_caution', e.target.value)} /> + set('fr_short_extreme', e.target.value)} /> +
+
+
+ )} + + {tab === 'chart' && ( +
+ set('candle_limit_desktop', e.target.value)} /> + set('candle_limit_mobile', e.target.value)} /> +
+ )} +
+
+ ); +} diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx new file mode 100644 index 0000000..95e80a7 --- /dev/null +++ b/frontend/app/trades/page.tsx @@ -0,0 +1,117 @@ +'use client'; +import { useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { api } from '@/lib/api'; +import { Card, PageHeader, Stat } from '@/components/ui'; +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +export default function TradesPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.get('/api/trades?limit=500').then(setRows).finally(() => setLoading(false)); + }, []); + + const closed = rows.filter(r => ['stop_loss', 'reversal', 'cancelled'].includes(r.status)); + const open = rows.filter(r => r.status === 'open').length; + const wins = closed.filter(r => (r.pnl_pct ?? 0) > 0).length; + const losses = closed.length - wins; + const winRate = closed.length ? (wins / closed.length * 100).toFixed(1) : '0.0'; + const cumPnl = closed.reduce((s, r) => s + (r.pnl_pct ?? 0), 0).toFixed(2); + const avgPnl = closed.length ? (closed.reduce((s, r) => s + (r.pnl_pct ?? 0), 0) / closed.length).toFixed(2) : '0.00'; + + // 누적 PnL 시계열 + const sorted = [...closed].sort((a, b) => (a.exit_time ?? '').localeCompare(b.exit_time ?? '')); + let cum = 0; + const cumX: any[] = []; const cumY: any[] = []; const colors: any[] = []; + for (const r of sorted) { + cum += r.pnl_pct ?? 0; + cumX.push(r.exit_time); + cumY.push(cum); + colors.push((r.pnl_pct ?? 0) > 0 ? '#26a69a' : '#ef5350'); + } + + return ( +
+ + +
+ + + + + = 0 ? '+' : ''}${avgPnl}%`} /> + = 0 ? '+' : ''}${cumPnl}%`} /> +
+ + {sorted.length > 0 && ( + +
누적 PnL %
+ +
+ )} + + +
최근 트레이드
+
+ + + + {['진입시간', '심볼', 'TF', '방향', '신호', '진입가', '손절가', '청산시간', '청산가', '사유', 'PnL%', '상태'].map(h => ( + + ))} + + + + {loading && } + {!loading && rows.length === 0 && } + {rows.map(r => ( + + + + + + + + + + + + + + + ))} + +
{h}
로딩 중...
아직 기록된 트레이드 없음
{(r.entry_time || '').slice(0, 19).replace('T', ' ')}{r.symbol}{r.interval}{r.direction}{r.signal_types}{r.entry_price?.toLocaleString()}{r.stop_price?.toLocaleString()}{(r.exit_time || '').slice(0, 19).replace('T', ' ')}{r.exit_price?.toLocaleString()}{r.exit_reason || '-'} 0 ? 'text-green-600' : (r.pnl_pct ?? 0) < 0 ? 'text-red-600' : 'text-slate-500'}`}> + {r.pnl_pct != null ? `${r.pnl_pct > 0 ? '+' : ''}${r.pnl_pct.toFixed(2)}%` : '-'} + + + {r.status} + +
+
+
+
+ ); +} diff --git a/frontend/components/AuthGate.tsx b/frontend/components/AuthGate.tsx new file mode 100644 index 0000000..892a918 --- /dev/null +++ b/frontend/components/AuthGate.tsx @@ -0,0 +1,35 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useAuth } from '@/lib/auth'; +import { getToken } from '@/lib/api'; +import Sidebar from './sidebar'; + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const { user, fetchMe } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + const isLogin = pathname === '/login'; + + useEffect(() => { + if (!getToken() && !isLogin) { + router.replace('/login'); + return; + } + if (getToken() && !user) { + fetchMe(); + } + }, [pathname]); + + if (isLogin) return <>{children}; + if (!getToken()) return null; + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/frontend/components/Chart.tsx b/frontend/components/Chart.tsx new file mode 100644 index 0000000..f0c88f3 --- /dev/null +++ b/frontend/components/Chart.tsx @@ -0,0 +1,156 @@ +'use client'; +import dynamic from 'next/dynamic'; +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +interface Row { + open_time: string; open: number; high: number; low: number; close: number; volume: number; + taker_buy_vol: number; taker_sell_vol: number; + MA7?: number; MA25?: number; MA99?: number; MA200?: number; + BB_upper?: number; BB_lower?: number; BB_mid?: number; + RSI?: number; StochRSI_k?: number; StochRSI_d?: number; + MACD?: number; MACD_signal?: number; MACD_hist?: number; + sumOpenInterest?: number; fundingRate?: number; longShortRatio?: number; + long_signal?: boolean; short_signal?: boolean; + strong_long_signal?: boolean; strong_short_signal?: boolean; + vol_long_signal?: boolean; vol_short_signal?: boolean; + reversal_long_signal?: boolean; reversal_short_signal?: boolean; + exhaustion_long?: boolean; exhaustion_short?: boolean; + short_caution_signal?: boolean; +} + +const C = { + green: '#26a69a', red: '#ef5350', yellow: '#f5ce05', blue: '#2962ff', + purple: '#9c27b0', orange: '#ff9800', + MA7: '#f5ce05', MA25: '#ef5350', MA99: '#9c27b0', MA200: '#2962ff', + grid: '#e0e3eb', text: '#131722', +}; + +const SIG_MARKER = [ + { col: 'strong_long_signal', sym: 'triangle-up', color: C.green, name: '강한 롱', side: 'low' }, + { col: 'strong_short_signal', sym: 'triangle-down', color: C.red, name: '강한 숏', side: 'high' }, + { col: 'long_signal', sym: 'triangle-up', color: C.blue, name: '롱', side: 'low' }, + { col: 'short_signal', sym: 'triangle-down', color: C.orange, name: '숏', side: 'high' }, + { col: 'vol_long_signal', sym: 'triangle-up', color: '#00bfff',name: '볼륨 롱', side: 'low' }, + { col: 'vol_short_signal', sym: 'triangle-down', color: C.orange, name: '볼륨 숏', side: 'high' }, + { col: 'short_caution_signal', sym: 'diamond', color: '#ff00ff',name: '숏 주의', side: 'high' }, + { col: 'exhaustion_long', sym: 'star', color: C.green, name: '매도소진', side: 'low' }, + { col: 'exhaustion_short', sym: 'star', color: C.red, name: '매수소진', side: 'high' }, +]; + +export default function Chart({ rows, lastPrice }: { rows: Row[]; lastPrice?: number | null }) { + if (!rows || rows.length === 0) return
데이터 로딩 중...
; + + const t = rows.map(r => r.open_time); + const data: any[] = []; + + // 캔들 + data.push({ + type: 'candlestick', + x: t, + open: rows.map(r => r.open), + high: rows.map(r => r.high), + low: rows.map(r => r.low), + close: rows.map(r => r.close), + increasing: { line: { color: C.green }, fillcolor: C.green }, + decreasing: { line: { color: C.red }, fillcolor: C.red }, + name: '캔들', + yaxis: 'y', + xaxis: 'x', + showlegend: false, + }); + + // BB + data.push({ type: 'scatter', x: t, y: rows.map(r => r.BB_upper), line: { color: 'rgba(41,98,255,0.6)', width: 0.8 }, name: 'BB상단', showlegend: false, yaxis: 'y' }); + data.push({ type: 'scatter', x: t, y: rows.map(r => r.BB_lower), line: { color: 'rgba(41,98,255,0.6)', width: 0.8 }, fill: 'tonexty', fillcolor: 'rgba(41,98,255,0.10)', name: 'BB하단', showlegend: false, yaxis: 'y' }); + + // MA + for (const [k, color] of [['MA200', C.MA200], ['MA99', C.MA99], ['MA25', C.MA25], ['MA7', C.MA7]] as const) { + data.push({ type: 'scatter', x: t, y: rows.map(r => (r as any)[k]), line: { color, width: 1.2 }, name: k, yaxis: 'y' }); + } + + // 신호 마커 + for (const m of SIG_MARKER) { + const xs: any[] = []; const ys: any[] = []; + for (const r of rows) { + if ((r as any)[m.col]) { + xs.push(r.open_time); + ys.push(m.side === 'low' ? r.low * 0.9998 : r.high * 1.0002); + } + } + if (xs.length > 0) { + data.push({ type: 'scatter', mode: 'markers', x: xs, y: ys, marker: { symbol: m.sym, color: m.color, size: 10 }, name: m.name, yaxis: 'y' }); + } + } + + // Taker Net (subplot 2) + const takerNet = rows.map(r => (r.taker_buy_vol || 0) - (r.taker_sell_vol || 0)); + data.push({ + type: 'bar', x: t, y: takerNet, + marker: { color: takerNet.map(v => v >= 0 ? C.green : C.red) }, + name: 'Taker Net', yaxis: 'y2', xaxis: 'x', showlegend: false, + }); + + // OI (subplot 3) + if (rows.some(r => r.sumOpenInterest != null)) { + data.push({ type: 'scatter', x: t, y: rows.map(r => r.sumOpenInterest), line: { color: C.purple, width: 1.5 }, fill: 'tozeroy', fillcolor: 'rgba(156,39,176,0.15)', name: 'OI', yaxis: 'y3', showlegend: false }); + } + + // FR (subplot 4) + if (rows.some(r => r.fundingRate != null)) { + data.push({ type: 'bar', x: t, y: rows.map(r => r.fundingRate), marker: { color: rows.map(r => (r.fundingRate ?? 0) < 0 ? C.red : C.green) }, name: 'FR', yaxis: 'y4', showlegend: false }); + } + + // L/S (subplot 5) + if (rows.some(r => r.longShortRatio != null)) { + data.push({ type: 'scatter', x: t, y: rows.map(r => r.longShortRatio), line: { color: C.orange, width: 1.5 }, name: 'L/S', yaxis: 'y5', showlegend: false }); + } + + // RSI / StochRSI (subplot 6) + data.push({ type: 'scatter', x: t, y: rows.map(r => r.RSI), line: { color: C.blue, width: 1.5 }, name: 'RSI', yaxis: 'y6', showlegend: false }); + data.push({ type: 'scatter', x: t, y: rows.map(r => r.StochRSI_k), line: { color: C.red, width: 1.2 }, name: 'StochRSI K', yaxis: 'y6', showlegend: false }); + + // MACD (subplot 7) + data.push({ type: 'bar', x: t, y: rows.map(r => r.MACD_hist), marker: { color: rows.map(r => (r.MACD_hist ?? 0) >= 0 ? C.green : C.red) }, name: 'MACD H', yaxis: 'y7', showlegend: false }); + data.push({ type: 'scatter', x: t, y: rows.map(r => r.MACD), line: { color: C.blue, width: 1.2 }, name: 'MACD', yaxis: 'y7', showlegend: false }); + data.push({ type: 'scatter', x: t, y: rows.map(r => r.MACD_signal), line: { color: C.orange, width: 1.2 }, name: 'Signal', yaxis: 'y7', showlegend: false }); + + const layout: any = { + height: 1400, + paper_bgcolor: '#ffffff', + plot_bgcolor: '#ffffff', + font: { color: C.text, size: 11, family: 'Pretendard, Noto Sans KR, sans-serif' }, + margin: { l: 60, r: 70, t: 20, b: 20 }, + hovermode: 'x unified', + dragmode: 'pan', + showlegend: false, + xaxis: { rangeslider: { visible: false }, gridcolor: C.grid, showspikes: false }, + yaxis: { domain: [0.62, 1.0], gridcolor: C.grid, title: { text: '가격' } }, + yaxis2: { domain: [0.52, 0.61], gridcolor: C.grid, title: { text: 'Taker' } }, + yaxis3: { domain: [0.42, 0.51], gridcolor: C.grid, title: { text: 'OI' } }, + yaxis4: { domain: [0.32, 0.41], gridcolor: C.grid, title: { text: 'FR' } }, + yaxis5: { domain: [0.22, 0.31], gridcolor: C.grid, title: { text: 'L/S' } }, + yaxis6: { domain: [0.11, 0.21], gridcolor: C.grid, title: { text: 'RSI' }, range: [0, 100] }, + yaxis7: { domain: [0.0, 0.10], gridcolor: C.grid, title: { text: 'MACD' } }, + shapes: lastPrice ? [{ + type: 'line', xref: 'paper', yref: 'y', + x0: 0, x1: 1, y0: lastPrice, y1: lastPrice, + line: { color: C.yellow, width: 1, dash: 'dash' }, + }] : [], + annotations: lastPrice ? [{ + x: t[t.length - 1], y: lastPrice, yref: 'y', + text: `▶ ${lastPrice.toLocaleString(undefined, { maximumFractionDigits: 1 })}`, + showarrow: false, xanchor: 'left', font: { color: C.yellow, size: 12 }, + bgcolor: 'rgba(245,206,5,0.15)', bordercolor: C.yellow, + }] : [], + }; + + return ( + + ); +} diff --git a/frontend/components/sidebar.tsx b/frontend/components/sidebar.tsx new file mode 100644 index 0000000..771045c --- /dev/null +++ b/frontend/components/sidebar.tsx @@ -0,0 +1,165 @@ +'use client'; +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useAuth } from '@/lib/auth'; +import { + LayoutDashboard, TrendingUp, KeyRound, Bot, Settings, User, LogOut, Menu, +} from 'lucide-react'; +import { cn } from '@/lib/cn'; + +const NAV = [ + { href: '/', label: '대시보드', icon: LayoutDashboard }, + { href: '/trades', label: '트레이드 이력', icon: TrendingUp }, + { href: '/exchange', label: '거래소 API', icon: KeyRound }, + { href: '/automation', label: '자동매매', icon: Bot }, + { href: '/settings', label: '시스템 설정', icon: Settings }, + { href: '/profile', label: '내 정보', icon: User }, +]; + +const Logo = ({ mini = false }: { mini?: boolean }) => ( + + + + + + + + + + + + + + + + + + {!mini && ( + <> + JUNGGOMOA + 트레이딩 시스템 + + )} + +); + +export default function Sidebar() { + const pathname = usePathname(); + const { user, logout } = useAuth(); + const [mini, setMini] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { setMobileOpen(false); }, [pathname]); + + const initial = (user?.username?.[0] || '?').toUpperCase(); + const w = mini ? 'w-[68px]' : 'w-[260px]'; + + return ( + <> + {/* 모바일 햄버거 (사이드바 밖) */} + + + {/* 모바일 오버레이 */} + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + {/* 사이드바 */} + + + {/* 모바일 슬라이드 사이드바 */} + + + ); +} + +function SidebarInner({ mini, setMini, pathname, initial, username, role, logout }: any) { + return ( + <> + {/* 헤더: 로고(좌) + 햄버거(우) */} +
+ {!mini && } + {mini && } + {!mini && ( + + )} +
+ {mini && ( + + )} + + {/* 메뉴 */} + + + {/* 푸터: 사용자 + 로그아웃 */} +
+ {!mini ? ( + <> +
+
+ {initial} +
+
+
{username || 'guest'}
+
{role}
+
+
+ + + ) : ( + + )} +
+ + ); +} diff --git a/frontend/components/ui.tsx b/frontend/components/ui.tsx new file mode 100644 index 0000000..3d85e71 --- /dev/null +++ b/frontend/components/ui.tsx @@ -0,0 +1,128 @@ +'use client'; +import { cn } from '@/lib/cn'; + +export function PageHeader({ title, subtitle, right }: { title: string; subtitle?: string; right?: React.ReactNode }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {right &&
{right}
} +
+ ); +} + +export function Card({ className, children }: { className?: string; children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function CardHeader({ icon: Icon, title, hint }: any) { + return ( +
+ {Icon && } + {title} + {hint && · {hint}} +
+ ); +} + +export function Input({ label, ...props }: any) { + return ( + + ); +} + +export function Select({ label, children, ...props }: any) { + return ( + + ); +} + +export function Button({ variant = 'primary', size = 'md', className, children, ...props }: any) { + const variants: Record = { + primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm', + secondary: 'bg-slate-100 hover:bg-slate-200 text-slate-800 border border-slate-300', + ghost: 'bg-transparent hover:bg-slate-100 text-slate-700', + danger: 'bg-red-600 hover:bg-red-700 text-white', + }; + const sizes: Record = { + sm: 'px-2.5 py-1 text-xs', + md: 'px-4 py-2 text-sm', + lg: 'px-5 py-2.5 text-base', + }; + return ( + + ); +} + +export function Toggle({ checked, onChange, label }: any) { + return ( +