React + FastAPI 풀 마이그레이션 — Streamlit 제거
- backend/ — FastAPI + JWT + 모든 REST 엔드포인트 - frontend/ — Next.js 14 + Tailwind + 7페이지 (대시보드/트레이드/거래소/자동매매/설정/내정보/로그인) - core_logic.py — 신호계산/알림 로직 분리 (기존 app_streamlit.py 에서 추출) - users_db.py + bcrypt 인증, exchange_keys.py + Fernet 암호화 - trades_db.py — 진입/청산 lifecycle 추적, signal_events raw 로그 - settings_db.py — 모든 운영 파라미터 DB 영속 저장 (RSI/거래량/펀딩비 임계값 포함) - docker-compose: frontend / backend / postgres + Traefik 라우팅 - assets/logo.svg — JUNGGOMOA 그라디언트 로고 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.claude
|
||||
.env
|
||||
*.log
|
||||
streamlit.log
|
||||
streamlit.err.log
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
data/
|
||||
@@ -2,3 +2,8 @@
|
||||
streamlit.log
|
||||
streamlit.err.log
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/pgdata/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
브라우저: <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 실패 메시지 / 알림이 발사 자체가 안 되는 건지 |
|
||||
| 텔레그램 안 옴 | 설정 메뉴에서 토큰 재입력 → 테스트 발송 버튼 / 컨테이너 로그 확인 |
|
||||
+36
@@ -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"]
|
||||
@@ -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분 캔들이라 충분하지만, 순간적 네트워크 지연 시 초과 시간축에서는 신호 누락 가능.
|
||||
+949
-46
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 60" width="220" height="60">
|
||||
<defs>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 캔들차트 모티프 -->
|
||||
<g transform="translate(6, 10)">
|
||||
<!-- 첫 캔들 (양봉) -->
|
||||
<line x1="4" y1="6" x2="4" y2="40" stroke="#26a69a" stroke-width="1.4"/>
|
||||
<rect x="1" y="20" width="6" height="14" fill="#26a69a" rx="1"/>
|
||||
<!-- 둘째 캔들 (음봉) -->
|
||||
<line x1="14" y1="2" x2="14" y2="34" stroke="#ef5350" stroke-width="1.4"/>
|
||||
<rect x="11" y="8" width="6" height="18" fill="#ef5350" rx="1"/>
|
||||
<!-- 셋째 캔들 (양봉) -->
|
||||
<line x1="24" y1="10" x2="24" y2="42" stroke="#26a69a" stroke-width="1.4"/>
|
||||
<rect x="21" y="14" width="6" height="22" fill="#26a69a" rx="1"/>
|
||||
<!-- 추세선 -->
|
||||
<polyline points="2,28 14,18 24,10" fill="none" stroke="url(#brand)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="24" cy="10" r="2.2" fill="#60a5fa"/>
|
||||
</g>
|
||||
<!-- 텍스트 -->
|
||||
<text x="46" y="28" font-family="'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif" font-weight="800" font-size="18" fill="url(#brand)" letter-spacing="1.2">JUNGGOMOA</text>
|
||||
<text x="46" y="44" font-family="'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif" font-weight="500" font-size="10" fill="#9ca3af" letter-spacing="0.5">트레이딩 시스템</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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)}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
+488
@@ -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
|
||||
@@ -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 발급' 섹션 참조."
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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<any>({});
|
||||
const [creds, setCreds] = useState<any[]>([]);
|
||||
const [msg, setMsg] = useState<string | null>(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 (
|
||||
<div>
|
||||
<PageHeader title="🤖 자동매매 설정" subtitle="현재 어댑터 — DRY-RUN 더미 (실 주문 X). 인터페이스/설정만 갖춰진 상태." />
|
||||
|
||||
<Banner level="warning">⚠️ 실 주문은 거래소별 SDK 어댑터 추가 후 활성화. 지금은 신호 발생 시 stdout 로 시뮬레이션.</Banner>
|
||||
|
||||
<Card className="mt-5">
|
||||
<div className="flex items-center gap-2 mb-4 text-blue-600">
|
||||
<Bot size={16} /> <span className="font-bold text-slate-800 text-sm">자동매매 설정</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Toggle checked={cfg.enabled === '1'} onChange={(v: boolean) => setCfg({ ...cfg, enabled: v ? '1' : '0' })} label="자동매매 ON (글로벌 킬스위치)" />
|
||||
<Toggle checked={cfg.dry_run === '1'} onChange={(v: boolean) => setCfg({ ...cfg, dry_run: v ? '1' : '0' })} label="DRY-RUN (실 주문 X)" />
|
||||
<Select label="허용 방향" value={cfg.allowed_directions || 'long,short'} onChange={(e: any) => setCfg({ ...cfg, allowed_directions: e.target.value })}>
|
||||
<option value="long,short">long + short</option>
|
||||
<option value="long">long only</option>
|
||||
<option value="short">short only</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||
<Select label="활성 거래소 키" value={cfg.active_credential || ''} onChange={(e: any) => setCfg({ ...cfg, active_credential: e.target.value })}>
|
||||
<option value="">(미선택)</option>
|
||||
{creds.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>#{c.id} {c.exchange.toUpperCase()} [{c.label || '-'}] {c.testnet ? '🧪' : '🟢'}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<Input label="레버리지" type="number" min="1" max="125" value={cfg.leverage || '10'} onChange={(e: any) => setCfg({ ...cfg, leverage: e.target.value })} />
|
||||
<Input label="포지션(잔고%)" type="number" step="0.1" value={cfg.position_size_pct || '1.0'} onChange={(e: any) => setCfg({ ...cfg, position_size_pct: e.target.value })} />
|
||||
<Input label="동시 진입 최대" type="number" value={cfg.max_open_trades || '3'} onChange={(e: any) => setCfg({ ...cfg, max_open_trades: e.target.value })} />
|
||||
<Input label="최소 신호 score" type="number" value={cfg.min_signal_score || '1'} onChange={(e: any) => setCfg({ ...cfg, min_signal_score: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<Input label="Take Profit (%, 0=OFF)" type="number" step="0.1" value={cfg.tp_pct || '0.0'} onChange={(e: any) => setCfg({ ...cfg, tp_pct: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-200">
|
||||
<Button onClick={save}>💾 저장</Button>
|
||||
<Button variant="secondary" onClick={testBalance}><FlaskConical size={14} className="inline mr-1" /> DryRun balance 테스트</Button>
|
||||
</div>
|
||||
{msg && <div className="mt-3 text-sm text-slate-600">{msg}</div>}
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<div className="text-sm font-bold text-slate-800 mb-2">현재 설정 (raw)</div>
|
||||
<pre className="text-xs bg-slate-50 p-3 rounded overflow-x-auto">{JSON.stringify(cfg, null, 2)}</pre>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<any[]>([]);
|
||||
const [exchanges, setExchanges] = useState<string[]>([]);
|
||||
const [form, setForm] = useState({ exchange: 'binance', label: '', api_key: '', api_secret: '', passphrase: '', testnet: false });
|
||||
const [msg, setMsg] = useState<string | null>(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 (
|
||||
<div>
|
||||
<PageHeader title="🔑 거래소 API 키" subtitle="API Key / Secret 은 Fernet 암호화로 PostgreSQL 에 저장. 자동매매 시 활성 키로 주문." />
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-5 mb-5">
|
||||
<Card className="lg:col-span-1">
|
||||
<div className="flex items-center gap-2 mb-3 text-blue-600">
|
||||
<Plus size={16} /> <span className="font-bold text-slate-800 text-sm">새 키 등록</span>
|
||||
</div>
|
||||
<form onSubmit={add} className="space-y-3">
|
||||
<Select label="거래소" value={form.exchange} onChange={(e: any) => setForm({ ...form, exchange: e.target.value })}>
|
||||
{exchanges.map(x => <option key={x} value={x}>{x.toUpperCase()}</option>)}
|
||||
</Select>
|
||||
<Input label="Label" value={form.label} onChange={(e: any) => setForm({ ...form, label: e.target.value })} placeholder="예: main / sub" />
|
||||
<Input label="API Key" type="text" value={form.api_key} onChange={(e: any) => setForm({ ...form, api_key: e.target.value })} />
|
||||
<Input label="API Secret" type="password" value={form.api_secret} onChange={(e: any) => setForm({ ...form, api_secret: e.target.value })} />
|
||||
<Input label="Passphrase (OKX/Bitget 만)" type="password" value={form.passphrase} onChange={(e: any) => setForm({ ...form, passphrase: e.target.value })} />
|
||||
<Toggle checked={form.testnet} onChange={(v: boolean) => setForm({ ...form, testnet: v })} label="Testnet" />
|
||||
<Button type="submit" className="w-full">등록</Button>
|
||||
{msg && <div className="text-xs text-slate-600 pt-2">{msg}</div>}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3 text-blue-600">
|
||||
<KeyRound size={16} /> <span className="font-bold text-slate-800 text-sm">등록된 키 ({creds.length})</span>
|
||||
</div>
|
||||
{creds.length === 0 && <div className="text-slate-400 text-sm py-6 text-center">등록된 키 없음</div>}
|
||||
<div className="space-y-3">
|
||||
{creds.map(c => (
|
||||
<div key={c.id} className="border border-slate-200 rounded-lg p-3 hover:border-slate-300">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm text-slate-800">{c.exchange.toUpperCase()}</span>
|
||||
<span className="text-xs text-slate-500">[{c.label || '-'}]</span>
|
||||
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${c.testnet ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}`}>
|
||||
{c.testnet ? 'TESTNET' : 'LIVE'}
|
||||
</span>
|
||||
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${c.enabled ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{c.enabled ? '활성' : '비활성'}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="danger" onClick={() => del(c.id)}><Trash2 size={12} /></Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs font-mono mb-2">
|
||||
<div className="bg-slate-50 px-2 py-1 rounded">Key: {c.api_key_masked}</div>
|
||||
<div className="bg-slate-50 px-2 py-1 rounded">Secret: {c.api_secret_masked}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<Toggle checked={c.enabled} onChange={(v: boolean) => toggle(c, 'enabled', v)} label="활성" />
|
||||
<Toggle checked={c.testnet} onChange={(v: boolean) => toggle(c, 'testnet', v)} label="Testnet" />
|
||||
<span className="text-slate-400">id #{c.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 (
|
||||
<html lang="ko">
|
||||
<body>
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%)' }}>
|
||||
<div className="w-full max-w-sm mx-4 bg-white rounded-2xl shadow-2xl p-8">
|
||||
<div className="flex flex-col items-center mb-6 pb-5 border-b border-slate-200">
|
||||
<svg viewBox="0 0 220 60" width="200" height="50" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6"/>
|
||||
<stop offset="100%" stopColor="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(6, 10)">
|
||||
<line x1="4" y1="6" x2="4" y2="40" stroke="#26a69a" strokeWidth="1.4"/>
|
||||
<rect x="1" y="20" width="6" height="14" fill="#26a69a" rx="1"/>
|
||||
<line x1="14" y1="2" x2="14" y2="34" stroke="#ef5350" strokeWidth="1.4"/>
|
||||
<rect x="11" y="8" width="6" height="18" fill="#ef5350" rx="1"/>
|
||||
<line x1="24" y1="10" x2="24" y2="42" stroke="#26a69a" strokeWidth="1.4"/>
|
||||
<rect x="21" y="14" width="6" height="22" fill="#26a69a" rx="1"/>
|
||||
<polyline points="2,28 14,18 24,10" fill="none" stroke="url(#b)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="24" cy="10" r="2.2" fill="#60a5fa"/>
|
||||
</g>
|
||||
<text x="46" y="28" fontFamily="Pretendard, sans-serif" fontWeight="800" fontSize="18" fill="url(#b)" letterSpacing="1.2">JUNGGOMOA</text>
|
||||
<text x="46" y="44" fontFamily="Pretendard, sans-serif" fontWeight="500" fontSize="10" fill="#9ca3af" letterSpacing="0.5">트레이딩 시스템</text>
|
||||
</svg>
|
||||
<h1 className="text-base font-bold text-slate-900 mt-3">업무관리 시스템</h1>
|
||||
<p className="text-xs text-slate-500 mt-1">로그인하여 시작하세요</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1.5">아이디</label>
|
||||
<input
|
||||
value={u} onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1.5">비밀번호</label>
|
||||
<input
|
||||
type="password" value={p} onChange={(e) => 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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</div>}
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm shadow-md transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-100 text-center">
|
||||
<p className="text-[10px] text-slate-400">© 2026 junggomoa.com · all rights reserved</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="📊 선물 대시보드"
|
||||
subtitle={`${symbol} · ${interval} · 마지막 갱신 ${now} KST`}
|
||||
right={
|
||||
<button onClick={() => 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">
|
||||
<RefreshCw size={14} /> 새로고침
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="mb-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
|
||||
<Select label="심볼" value={symbol} onChange={(e: any) => setSymbol(e.target.value)}>
|
||||
{SYMBOLS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</Select>
|
||||
<Select label="시간축" value={interval} onChange={(e: any) => setIntervalV(e.target.value)}>
|
||||
{INTERVALS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</Select>
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-slate-600 mb-1">갱신(초)</span>
|
||||
<input type="number" value={refresh} min={10} max={300}
|
||||
onChange={(e) => setRefresh(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50" />
|
||||
</div>
|
||||
<div className="flex items-center"><Toggle checked={auto} onChange={setAuto} label="자동 갱신" /></div>
|
||||
<div className="text-xs text-slate-500 text-right">
|
||||
{loading ? '⏳ 로딩 중...' : data?.last_price ? `현재가: ${data.last_price.toLocaleString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data?.banner && (
|
||||
<div className="mb-4">
|
||||
<Banner level={data.banner.level}>{data.banner.text}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Banner level="danger">{error}</Banner>}
|
||||
|
||||
<Card className="p-2 md:p-3">
|
||||
<Chart rows={data?.rows || []} lastPrice={data?.last_price} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<PageHeader title="👤 개인정보 수정" subtitle="비밀번호 변경" />
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-3 text-blue-600">
|
||||
<User size={16} /> <span className="font-bold text-slate-800 text-sm">계정 정보</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="아이디" value={user?.username || ''} disabled />
|
||||
<Input label="권한" value={user?.role || ''} disabled />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-3 pt-3 border-t border-slate-100">
|
||||
가입: {user?.created_at || '-'}<br />
|
||||
마지막 로그인: {user?.last_login_at || '-'}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-3 text-blue-600">
|
||||
<KeyRound size={16} /> <span className="font-bold text-slate-800 text-sm">비밀번호 변경</span>
|
||||
</div>
|
||||
<form onSubmit={change} className="space-y-3">
|
||||
<Input label="현재 비밀번호" type="password" value={oldPw} onChange={(e: any) => setOldPw(e.target.value)} required />
|
||||
<Input label="새 비밀번호 (6자 이상)" type="password" value={newPw} onChange={(e: any) => setNewPw(e.target.value)} required />
|
||||
<Input label="새 비밀번호 확인" type="password" value={newPw2} onChange={(e: any) => setNewPw2(e.target.value)} required />
|
||||
{msg && <Banner level={msg.level}>{msg.text}</Banner>}
|
||||
<Button type="submit" className="w-full">비밀번호 변경</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<any>({});
|
||||
const [tab, setTab] = useState('tg');
|
||||
const [msg, setMsg] = useState<string | null>(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 (
|
||||
<div>
|
||||
<PageHeader title="⚙️ 시스템 설정" subtitle="DB 영속 저장 · 저장 즉시 알림 스레드 / 차트에 반영"
|
||||
right={<Button onClick={save}>💾 전체 저장</Button>} />
|
||||
|
||||
{msg && <div className="mb-4"><Banner level="success">{msg}</Banner></div>}
|
||||
|
||||
<Card>
|
||||
<div className="flex gap-1 border-b border-slate-200 mb-5 -mx-1">
|
||||
{TABS.map(t => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition ${tab === t.key ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>
|
||||
<t.icon size={14} /> {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'tg' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label="Bot Token" value={s.telegram_token || ''} onChange={(e: any) => set('telegram_token', e.target.value)} placeholder="예: 1234567890:ABCDEF..." />
|
||||
<Input label="Chat ID" value={s.telegram_chat_id || ''} onChange={(e: any) => set('telegram_chat_id', e.target.value)} placeholder="예: -1001234567890" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">⚠️ Token 은 plain text 표시. DB 에 저장됨 — 노출 주의.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'alert' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Select label="모니터링 심볼" value={s.alert_symbol || 'BTCUSDT'} onChange={(e: any) => set('alert_symbol', e.target.value)}>
|
||||
{SYMBOLS.map(x => <option key={x} value={x}>{x}</option>)}
|
||||
</Select>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">알림 시간축 (multi)</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TFS.map(tf => {
|
||||
const on = tfList().includes(tf);
|
||||
return (
|
||||
<button key={tf} type="button" onClick={() => toggleTf(tf)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition ${on ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}>
|
||||
{tf}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Input label="쿨다운(초)" type="number" value={s.alert_cooldown_sec || '600'} onChange={(e: any) => set('alert_cooldown_sec', e.target.value)} />
|
||||
<Input label="손절(%)" type="number" step="0.05" value={parseFloat(s.stop_loss_pct || '0.0075') * 100} onChange={(e: any) => set('stop_loss_pct', (parseFloat(e.target.value) / 100).toFixed(6))} />
|
||||
<Input label="폴링(초)" type="number" value={s.polling_interval_sec || '30'} onChange={(e: any) => set('polling_interval_sec', e.target.value)} />
|
||||
<Input label="forming polls" type="number" min="1" max="10" value={s.forming_stable_polls || '2'} onChange={(e: any) => set('forming_stable_polls', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-6 pt-2">
|
||||
<Toggle checked={s.alert_enabled === '1'} onChange={(v: boolean) => set('alert_enabled', v ? '1' : '0')} label="알림 활성화" />
|
||||
<Toggle checked={s.daily_report_enabled === '1'} onChange={(v: boolean) => set('daily_report_enabled', v ? '1' : '0')} label="일일 리포트 활성화" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'signal' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-700 mb-2">RSI 임계값</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Input label="일반 롱 RSI ≤" type="number" value={s.long_rsi_max || '75'} onChange={(e: any) => set('long_rsi_max', e.target.value)} />
|
||||
<Input label="일반 숏 RSI ≥" type="number" value={s.short_rsi_min || '25'} onChange={(e: any) => set('short_rsi_min', e.target.value)} />
|
||||
<Input label="강한 롱 RSI ≤" type="number" value={s.strong_long_rsi_max || '65'} onChange={(e: any) => set('strong_long_rsi_max', e.target.value)} />
|
||||
<Input label="강한 숏 RSI ≥" type="number" value={s.strong_short_rsi_min || '35'} onChange={(e: any) => set('strong_short_rsi_min', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-700 mb-2">캔들 body / 추세 꺾임</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Input label="body 최소(%)" type="number" step="0.05" value={parseFloat(s.body_pct_min || '0.002') * 100} onChange={(e: any) => set('body_pct_min', (parseFloat(e.target.value) / 100).toFixed(6))} />
|
||||
<Input label="추세 꺾임 body(%)" type="number" step="0.05" value={parseFloat(s.reversal_body_pct || '0.003') * 100} onChange={(e: any) => set('reversal_body_pct', (parseFloat(e.target.value) / 100).toFixed(6))} />
|
||||
<Input label="추세 꺾임 vol 배수" type="number" step="0.1" value={s.reversal_vol_mult || '1.3'} onChange={(e: any) => set('reversal_vol_mult', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'vol' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-700 mb-2">거래량 배수</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Input label="Exhaustion 배수" type="number" step="0.5" value={s.vol_exhaustion_mult || '3.0'} onChange={(e: any) => set('vol_exhaustion_mult', e.target.value)} />
|
||||
<Input label="vol Net 배수" type="number" step="0.1" value={s.vol_net_mult || '2.0'} onChange={(e: any) => set('vol_net_mult', e.target.value)} />
|
||||
<Input label="OI 활성도(%)" type="number" step="0.05" value={parseFloat(s.oi_active_pct || '0.001') * 100} onChange={(e: any) => set('oi_active_pct', (parseFloat(e.target.value) / 100).toFixed(6))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-700 mb-2">펀딩비 임계 (단위: %)</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Input label="롱 과열 FR (≥)" type="number" step="0.001" value={s.fr_long_overheat || '0.005'} onChange={(e: any) => set('fr_long_overheat', e.target.value)} />
|
||||
<Input label="숏 경보 FR (≤)" type="number" step="0.001" value={s.fr_short_caution || '-0.005'} onChange={(e: any) => set('fr_short_caution', e.target.value)} />
|
||||
<Input label="숏 주의 FR (≤)" type="number" step="0.001" value={s.fr_short_extreme || '-0.007'} onChange={(e: any) => set('fr_short_extreme', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'chart' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="데스크톱 캔들 수" type="number" value={s.candle_limit_desktop || '200'} onChange={(e: any) => set('candle_limit_desktop', e.target.value)} />
|
||||
<Input label="모바일 캔들 수" type="number" value={s.candle_limit_mobile || '60'} onChange={(e: any) => set('candle_limit_mobile', e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<any[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<PageHeader title="📈 트레이드 이력" subtitle={`총 ${rows.length}건 · 종료 ${closed.length} · 진행 중 ${open}`} />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-5">
|
||||
<Stat label="총 트레이드" value={rows.length} />
|
||||
<Stat label="진행 중" value={open} />
|
||||
<Stat label="종료" value={closed.length} />
|
||||
<Stat label="승률" value={`${winRate}%`} hint={`${wins}W / ${losses}L`} />
|
||||
<Stat label="평균 PnL%" value={`${parseFloat(avgPnl) >= 0 ? '+' : ''}${avgPnl}%`} />
|
||||
<Stat label="누적 PnL%" value={`${parseFloat(cumPnl) >= 0 ? '+' : ''}${cumPnl}%`} />
|
||||
</div>
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<Card className="mb-5">
|
||||
<div className="text-sm font-bold text-slate-800 mb-2">누적 PnL %</div>
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
type: 'scatter', mode: 'lines+markers',
|
||||
x: cumX, y: cumY,
|
||||
line: { color: '#2962ff', width: 2 },
|
||||
marker: { color: colors, size: 6 },
|
||||
name: '누적 PnL',
|
||||
},
|
||||
]}
|
||||
layout={{
|
||||
height: 280, margin: { l: 50, r: 20, t: 10, b: 30 },
|
||||
paper_bgcolor: '#ffffff', plot_bgcolor: '#ffffff',
|
||||
font: { family: 'Pretendard, sans-serif', size: 11 },
|
||||
xaxis: { gridcolor: '#e5e7eb' }, yaxis: { gridcolor: '#e5e7eb', title: { text: 'PnL %' } },
|
||||
showlegend: false,
|
||||
}}
|
||||
config={{ displayModeBar: false }}
|
||||
style={{ width: '100%' }}
|
||||
useResizeHandler
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="text-sm font-bold text-slate-800 mb-3">최근 트레이드</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
{['진입시간', '심볼', 'TF', '방향', '신호', '진입가', '손절가', '청산시간', '청산가', '사유', 'PnL%', '상태'].map(h => (
|
||||
<th key={h} className="px-3 py-2 text-left font-semibold border-b border-slate-200">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={12} className="p-4 text-center text-slate-400">로딩 중...</td></tr>}
|
||||
{!loading && rows.length === 0 && <tr><td colSpan={12} className="p-4 text-center text-slate-400">아직 기록된 트레이드 없음</td></tr>}
|
||||
{rows.map(r => (
|
||||
<tr key={r.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 font-mono">{(r.entry_time || '').slice(0, 19).replace('T', ' ')}</td>
|
||||
<td className="px-3 py-2">{r.symbol}</td>
|
||||
<td className="px-3 py-2">{r.interval}</td>
|
||||
<td className={`px-3 py-2 font-semibold ${r.direction === 'long' ? 'text-green-600' : 'text-red-600'}`}>{r.direction}</td>
|
||||
<td className="px-3 py-2">{r.signal_types}</td>
|
||||
<td className="px-3 py-2 font-mono text-right">{r.entry_price?.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 font-mono text-right">{r.stop_price?.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 font-mono">{(r.exit_time || '').slice(0, 19).replace('T', ' ')}</td>
|
||||
<td className="px-3 py-2 font-mono text-right">{r.exit_price?.toLocaleString()}</td>
|
||||
<td className="px-3 py-2">{r.exit_reason || '-'}</td>
|
||||
<td className={`px-3 py-2 font-mono text-right font-bold ${(r.pnl_pct ?? 0) > 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)}%` : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 text-[10px] rounded ${r.status === 'open' ? 'bg-blue-100 text-blue-800' : r.status === 'stop_loss' ? 'bg-red-100 text-red-800' : 'bg-slate-100 text-slate-700'}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-screen p-4 lg:p-6 overflow-x-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className="text-slate-400 text-sm py-8 text-center">데이터 로딩 중...</div>;
|
||||
|
||||
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 (
|
||||
<Plot
|
||||
data={data}
|
||||
layout={layout}
|
||||
config={{ scrollZoom: true, displayModeBar: true, modeBarButtonsToRemove: ['lasso2d', 'select2d'], displaylogo: false }}
|
||||
style={{ width: '100%' }}
|
||||
useResizeHandler
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<svg viewBox="0 0 220 60" width={mini ? 36 : 200} height={mini ? 36 : 50} xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6"/>
|
||||
<stop offset="100%" stopColor="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(6, 10)">
|
||||
<line x1="4" y1="6" x2="4" y2="40" stroke="#26a69a" strokeWidth="1.4"/>
|
||||
<rect x="1" y="20" width="6" height="14" fill="#26a69a" rx="1"/>
|
||||
<line x1="14" y1="2" x2="14" y2="34" stroke="#ef5350" strokeWidth="1.4"/>
|
||||
<rect x="11" y="8" width="6" height="18" fill="#ef5350" rx="1"/>
|
||||
<line x1="24" y1="10" x2="24" y2="42" stroke="#26a69a" strokeWidth="1.4"/>
|
||||
<rect x="21" y="14" width="6" height="22" fill="#26a69a" rx="1"/>
|
||||
<polyline points="2,28 14,18 24,10" fill="none" stroke="url(#brand)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="24" cy="10" r="2.2" fill="#60a5fa"/>
|
||||
</g>
|
||||
{!mini && (
|
||||
<>
|
||||
<text x="46" y="28" fontFamily="Pretendard, sans-serif" fontWeight="800" fontSize="18" fill="url(#brand)" letterSpacing="1.2">JUNGGOMOA</text>
|
||||
<text x="46" y="44" fontFamily="Pretendard, sans-serif" fontWeight="500" fontSize="10" fill="#9ca3af" letterSpacing="0.5">트레이딩 시스템</text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
{/* 모바일 햄버거 (사이드바 밖) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden fixed top-3 left-3 z-40 bg-slate-800 text-white p-2 rounded-md shadow-md"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사이드바 */}
|
||||
<aside className={cn(
|
||||
'bg-slate-900 text-slate-200 flex flex-col h-screen sticky top-0 transition-all duration-200 z-50',
|
||||
'hidden lg:flex',
|
||||
w,
|
||||
)}>
|
||||
<SidebarInner mini={mini} setMini={setMini} pathname={pathname} initial={initial} username={user?.username || ''} role={user?.role || ''} logout={logout} />
|
||||
</aside>
|
||||
|
||||
{/* 모바일 슬라이드 사이드바 */}
|
||||
<aside className={cn(
|
||||
'lg:hidden fixed top-0 left-0 h-screen w-[260px] bg-slate-900 text-slate-200 flex flex-col z-50 transition-transform',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}>
|
||||
<SidebarInner mini={false} setMini={() => setMobileOpen(false)} pathname={pathname} initial={initial} username={user?.username || ''} role={user?.role || ''} logout={logout} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInner({ mini, setMini, pathname, initial, username, role, logout }: any) {
|
||||
return (
|
||||
<>
|
||||
{/* 헤더: 로고(좌) + 햄버거(우) */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-slate-700">
|
||||
{!mini && <Logo mini={false} />}
|
||||
{mini && <Logo mini={true} />}
|
||||
{!mini && (
|
||||
<button onClick={() => setMini(true)} className="p-2 rounded hover:bg-slate-700">
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mini && (
|
||||
<button onClick={() => setMini(false)} className="mx-2 my-2 p-2 rounded hover:bg-slate-700 text-slate-300">
|
||||
<Menu size={18} className="mx-auto" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 메뉴 */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2">
|
||||
{NAV.map((n) => {
|
||||
const active = pathname === n.href || (n.href !== '/' && pathname.startsWith(n.href));
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 mx-2 my-1 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-300 hover:bg-slate-700/60 hover:text-white',
|
||||
mini && 'justify-center px-2',
|
||||
)}
|
||||
title={n.label}
|
||||
>
|
||||
<n.icon size={18} className={cn(active ? 'text-white' : 'text-blue-400')} />
|
||||
{!mini && <span>{n.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 푸터: 사용자 + 로그아웃 */}
|
||||
<div className="border-t border-slate-700 p-3">
|
||||
{!mini ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg,#3b82f6,#60a5fa)' }}>
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-100 truncate">{username || 'guest'}</div>
|
||||
<div className="text-xs text-slate-400">{role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={logout} className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm">
|
||||
<LogOut size={14}/> 로그아웃
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={logout} className="w-full p-2 rounded hover:bg-slate-700" title={`${username} 로그아웃`}>
|
||||
<LogOut size={18} className="mx-auto text-slate-300"/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-end justify-between border-b border-slate-200 pb-3 mb-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="text-xs text-slate-500 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{right && <div>{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('bg-white rounded-xl border border-slate-200 shadow-sm p-5', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ icon: Icon, title, hint }: any) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{Icon && <Icon size={16} className="text-blue-600" />}
|
||||
<span className="text-sm font-bold text-slate-800">{title}</span>
|
||||
{hint && <span className="text-xs text-slate-400">· {hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ label, ...props }: any) {
|
||||
return (
|
||||
<label className="block">
|
||||
{label && <span className="block text-xs font-medium text-slate-600 mb-1">{label}</span>}
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white',
|
||||
'placeholder:text-slate-400 disabled:bg-slate-100 disabled:text-slate-500',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({ label, children, ...props }: any) {
|
||||
return (
|
||||
<label className="block">
|
||||
{label && <span className="block text-xs font-medium text-slate-600 mb-1">{label}</span>}
|
||||
<select {...props} className={cn(
|
||||
'w-full px-3 py-2 text-sm rounded-md border border-slate-300 bg-slate-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white',
|
||||
props.className,
|
||||
)}>
|
||||
{children}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: any) {
|
||||
const variants: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
sm: 'px-2.5 py-1 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, label }: any) {
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<span className="relative">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="sr-only peer" />
|
||||
<div className="w-9 h-5 bg-slate-300 rounded-full peer-checked:bg-blue-600 transition" />
|
||||
<div className="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full shadow transition peer-checked:translate-x-4" />
|
||||
</span>
|
||||
<span className="text-sm text-slate-700">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Banner({ level = 'info', children }: { level: 'info' | 'success' | 'warning' | 'danger'; children: React.ReactNode }) {
|
||||
const styles: Record<string, string> = {
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
danger: 'bg-red-50 border-red-200 text-red-800',
|
||||
};
|
||||
return (
|
||||
<div className={cn('border rounded-lg px-4 py-3 text-sm', styles[level])}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ label, value, hint }: { label: string; value: any; hint?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||
<div className="text-xl font-bold text-slate-900">{value}</div>
|
||||
{hint && <div className="text-xs text-slate-400 mt-1">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 클라이언트 API wrapper. JWT 는 localStorage 에 보관.
|
||||
const TOKEN_KEY = 'jm_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
export function setToken(t: string) { localStorage.setItem(TOKEN_KEY, t); }
|
||||
export function clearToken() { localStorage.removeItem(TOKEN_KEY); }
|
||||
|
||||
async function request<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(opts.headers as any),
|
||||
};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(path, { ...opts, headers });
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let msg = text;
|
||||
try { msg = JSON.parse(text).detail || text; } catch {}
|
||||
throw new Error(msg || `${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as any;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = any>(p: string) => request<T>(p),
|
||||
post: <T = any>(p: string, body: any) => request<T>(p, { method: 'POST', body: JSON.stringify(body) }),
|
||||
put: <T = any>(p: string, body: any) => request<T>(p, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: <T = any>(p: string) => request<T>(p, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import { api, getToken, setToken, clearToken } from './api';
|
||||
|
||||
interface AuthUser { id?: number; username: string; role: string; created_at?: string; last_login_at?: string; }
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (u: string, p: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
fetchMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
async login(u, p) {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const r = await api.post<{ access_token: string; user: AuthUser }>('/api/auth/login', { username: u, password: p });
|
||||
setToken(r.access_token);
|
||||
set({ user: r.user, loading: false });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
set({ error: e?.message || '로그인 실패', loading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
clearToken();
|
||||
set({ user: null });
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
},
|
||||
async fetchMe() {
|
||||
if (!getToken()) { set({ user: null }); return; }
|
||||
try {
|
||||
const me = await api.get<AuthUser>('/api/auth/me');
|
||||
set({ user: me });
|
||||
} catch {
|
||||
clearToken();
|
||||
set({ user: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
// /api/* 는 백엔드로 프록시 (Traefik 이 프로덕션에서 처리하지만 dev 호환)
|
||||
async rewrites() {
|
||||
return [
|
||||
{ source: '/api/:path*', destination: process.env.BACKEND_URL ? `${process.env.BACKEND_URL}/api/:path*` : '/api/:path*' },
|
||||
]
|
||||
},
|
||||
}
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "junggomoa-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.16",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-plotly.js": "2.6.0",
|
||||
"plotly.js-dist-min": "2.35.2",
|
||||
"zustand": "4.5.5",
|
||||
"lucide-react": "0.453.0",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.16.10",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/react-plotly.js": "2.6.3",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.4.47",
|
||||
"tailwindcss": "3.4.13",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff', 100: '#dbeafe', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'Noto Sans KR', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -6,3 +6,7 @@ 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
|
||||
streamlit-option-menu>=0.3.13
|
||||
bcrypt>=4.0.0
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
설정값 영속 저장. SQLite key-value 테이블 한 개로 단순화.
|
||||
- 텔레그램 토큰 / chat_id
|
||||
- 모니터링 심볼
|
||||
- 알림 시간축 목록
|
||||
- 쿨다운 / STOP_LOSS_PCT 등 운영 파라미터
|
||||
|
||||
Streamlit rerun 안전: 모듈 최상단의 connection 캐시는 모듈 캐싱 (sys.modules) 으로
|
||||
process lifetime 동안 보존. 멀티 스레드 (alert thread) 도 같은 DB 파일을 읽으므로
|
||||
SQLite 의 thread-safe 모드 (`check_same_thread=False`) 로 연다.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
DB_PATH = os.environ.get("SETTINGS_DB_PATH", "/app/data/settings.db")
|
||||
_lock = threading.RLock()
|
||||
_conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
global _conn
|
||||
if _conn is None:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
_conn = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
return _conn
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
# ── 알림 / 모니터링 ──
|
||||
"telegram_token": "",
|
||||
"telegram_chat_id": "",
|
||||
"alert_symbol": "BTCUSDT",
|
||||
"alert_timeframes": "5m,15m,30m,1h",
|
||||
"alert_cooldown_sec": "600",
|
||||
"stop_loss_pct": "0.0075",
|
||||
"alert_enabled": "1",
|
||||
"daily_report_enabled": "1",
|
||||
"polling_interval_sec": "30",
|
||||
|
||||
# ── 신호 임계값 (RSI / body) ──
|
||||
"long_rsi_max": "75",
|
||||
"short_rsi_min": "25",
|
||||
"strong_long_rsi_max": "65",
|
||||
"strong_short_rsi_min": "35",
|
||||
"body_pct_min": "0.002", # 일반 신호 캔들 body 최소 (양/음봉)
|
||||
"reversal_body_pct": "0.003", # 추세 꺾임 body 최소
|
||||
"reversal_vol_mult": "1.3", # 추세 꺾임 거래량 동반 배수
|
||||
|
||||
# ── 거래량 / 펀딩비 ──
|
||||
"vol_exhaustion_mult": "3.0", # exhaustion 판정 (vol > avg × N)
|
||||
"vol_net_mult": "2.0", # vol_long/short_signal: net > avg × N
|
||||
"oi_active_pct": "0.001", # OI 활성도 임계 (변동률 절대값)
|
||||
"fr_long_overheat": "0.005", # 롱 과열 (배너)
|
||||
"fr_short_caution": "-0.005", # 숏스퀴즈 경보
|
||||
"fr_short_extreme": "-0.007", # 숏 주의 신호 임계
|
||||
|
||||
# ── 차트 / UI ──
|
||||
"candle_limit_desktop": "53",
|
||||
"candle_limit_mobile": "14",
|
||||
"forming_stable_polls": "2", # forming candle 안정성 — 연속 N polls True 요구
|
||||
}
|
||||
|
||||
|
||||
def init_db_with_env_defaults():
|
||||
"""최초 기동 시 .env 값을 DB 기본값으로 복사. 이미 존재하는 키는 건드리지 않음."""
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
for k, default in DEFAULTS.items():
|
||||
cur = conn.execute("SELECT value FROM settings WHERE key=?", (k,))
|
||||
if cur.fetchone() is not None:
|
||||
continue
|
||||
seed = default
|
||||
env_map = {
|
||||
"telegram_token": "TELEGRAM_TOKEN",
|
||||
"telegram_chat_id": "TELEGRAM_CHAT_ID",
|
||||
}
|
||||
if k in env_map:
|
||||
seed = os.environ.get(env_map[k], default) or default
|
||||
conn.execute(
|
||||
"INSERT INTO settings(key, value) VALUES (?, ?)",
|
||||
(k, seed),
|
||||
)
|
||||
|
||||
|
||||
def get(key: str, default: Any = None) -> str:
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
cur = conn.execute("SELECT value FROM settings WHERE key=?", (key,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return DEFAULTS.get(key, default) if default is None else default
|
||||
return row[0]
|
||||
|
||||
|
||||
def get_int(key: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_float(key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_bool(key: str, default: bool = False) -> bool:
|
||||
v = get(key, "1" if default else "0")
|
||||
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def get_list(key: str, default=None, sep: str = ",") -> list:
|
||||
v = get(key, "")
|
||||
if not v:
|
||||
return list(default or [])
|
||||
return [s.strip() for s in v.split(sep) if s.strip()]
|
||||
|
||||
|
||||
def set_value(key: str, value: Any):
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO settings(key, value, updated_at) VALUES(?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
|
||||
def all_settings() -> dict:
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
cur = conn.execute("SELECT key, value FROM settings ORDER BY key")
|
||||
return dict(cur.fetchall())
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
백엔드 통합 테스트 — 컨테이너 안에서 실행 (DATABASE_URL / ENCRYPTION_KEY 환경변수 필요).
|
||||
|
||||
실행:
|
||||
docker compose exec app python -m pytest tests/ -v
|
||||
또는:
|
||||
docker compose exec app python tests/test_backend.py
|
||||
|
||||
각 테스트는 독립적이고 실제 DB / Binance 와 통신.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import settings_db
|
||||
import trades_db
|
||||
import exchange_keys
|
||||
import exchange_adapters
|
||||
import alert_state
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 1. settings_db
|
||||
# ──────────────────────────────────────────────
|
||||
class SettingsDBTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
settings_db.init_db_with_env_defaults()
|
||||
|
||||
def test_defaults_seeded(self):
|
||||
v = settings_db.get("alert_symbol")
|
||||
self.assertTrue(v in ("BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT") or v.endswith("USDT"))
|
||||
|
||||
def test_set_and_get(self):
|
||||
settings_db.set_value("__test_key", "hello")
|
||||
self.assertEqual(settings_db.get("__test_key"), "hello")
|
||||
|
||||
def test_get_int(self):
|
||||
settings_db.set_value("__test_int", "42")
|
||||
self.assertEqual(settings_db.get_int("__test_int"), 42)
|
||||
self.assertEqual(settings_db.get_int("__no_such_key", 7), 7)
|
||||
|
||||
def test_get_float(self):
|
||||
settings_db.set_value("__test_float", "0.0075")
|
||||
self.assertAlmostEqual(settings_db.get_float("__test_float"), 0.0075)
|
||||
|
||||
def test_get_bool(self):
|
||||
settings_db.set_value("__test_bool", "1")
|
||||
self.assertTrue(settings_db.get_bool("__test_bool"))
|
||||
settings_db.set_value("__test_bool", "0")
|
||||
self.assertFalse(settings_db.get_bool("__test_bool"))
|
||||
|
||||
def test_get_list(self):
|
||||
settings_db.set_value("__test_list", "5m,15m,30m")
|
||||
self.assertEqual(settings_db.get_list("__test_list"), ["5m", "15m", "30m"])
|
||||
|
||||
def test_threshold_defaults_present(self):
|
||||
for k in ["long_rsi_max", "short_rsi_min", "body_pct_min",
|
||||
"vol_exhaustion_mult", "fr_short_extreme",
|
||||
"candle_limit_desktop", "polling_interval_sec"]:
|
||||
v = settings_db.get(k)
|
||||
self.assertIsNotNone(v, f"{k} missing")
|
||||
self.assertNotEqual(v, "", f"{k} empty")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 2. trades_db (PostgreSQL)
|
||||
# ──────────────────────────────────────────────
|
||||
class TradesDBTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not trades_db._enabled():
|
||||
raise unittest.SkipTest("DATABASE_URL not set")
|
||||
trades_db.init_db()
|
||||
|
||||
def test_record_entry_and_exit_long(self):
|
||||
ct = datetime.utcnow().replace(microsecond=0)
|
||||
trades_db.record_entry(
|
||||
"TESTUSDT", "5m", "long",
|
||||
["strong_long_signal"], ct, 100.0, 99.25,
|
||||
)
|
||||
trades_db.record_exit("TESTUSDT", "5m", "long", ct, 110.0, "stop_loss")
|
||||
rows = trades_db.fetch_trades(limit=10)
|
||||
match = [r for r in rows if r["symbol"] == "TESTUSDT" and r["candle_time"] == ct]
|
||||
self.assertTrue(match, "trade not found after record_entry+record_exit")
|
||||
r = match[0]
|
||||
self.assertEqual(r["status"], "stop_loss")
|
||||
self.assertAlmostEqual(r["pnl_pct"], (110.0 - 100.0) / 100.0 * 100, places=2)
|
||||
|
||||
def test_record_entry_and_exit_short(self):
|
||||
ct = datetime.utcnow().replace(microsecond=0) - timedelta(seconds=1)
|
||||
trades_db.record_entry(
|
||||
"TESTUSDT", "5m", "short",
|
||||
["short_signal"], ct, 100.0, 100.75,
|
||||
)
|
||||
trades_db.record_exit("TESTUSDT", "5m", "short", ct, 90.0, "reversal")
|
||||
rows = trades_db.fetch_trades(limit=10)
|
||||
r = next(r for r in rows if r["candle_time"] == ct and r["direction"] == "short")
|
||||
self.assertEqual(r["status"], "reversal")
|
||||
self.assertAlmostEqual(r["pnl_pct"], (100.0 - 90.0) / 100.0 * 100, places=2)
|
||||
|
||||
def test_duplicate_entry_ignored(self):
|
||||
ct = datetime.utcnow().replace(microsecond=0) - timedelta(seconds=2)
|
||||
trades_db.record_entry("DUPUSDT", "5m", "long", ["x"], ct, 100.0, 99.0)
|
||||
trades_db.record_entry("DUPUSDT", "5m", "long", ["y"], ct, 100.0, 99.0)
|
||||
rows = [r for r in trades_db.fetch_trades(limit=20)
|
||||
if r["symbol"] == "DUPUSDT" and r["candle_time"] == ct]
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
def test_log_signal_events(self):
|
||||
ct = datetime.utcnow().replace(microsecond=0)
|
||||
trades_db.log_signal_events(
|
||||
"TESTUSDT", "5m",
|
||||
[{"sig": "long_signal", "direction": "long", "candle_time": ct,
|
||||
"row": {"open": 100.0}}],
|
||||
)
|
||||
events = trades_db.fetch_signal_events(limit=100)
|
||||
self.assertTrue(any(e["symbol"] == "TESTUSDT" for e in events))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 3. exchange_keys (Fernet 암호화 + automation_config)
|
||||
# ──────────────────────────────────────────────
|
||||
class ExchangeKeysTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not exchange_keys._enabled():
|
||||
raise unittest.SkipTest("DATABASE_URL or cryptography not set")
|
||||
exchange_keys.init_db()
|
||||
|
||||
def test_add_and_decrypt(self):
|
||||
cid = exchange_keys.add_credential(
|
||||
exchange="binance", label="__test_main",
|
||||
api_key="AK_TEST_12345", api_secret="SK_TEST_67890",
|
||||
passphrase=None, testnet=True, enabled=True,
|
||||
)
|
||||
self.assertIsNotNone(cid)
|
||||
cred = exchange_keys.get_credential(cid)
|
||||
self.assertEqual(cred["api_key"], "AK_TEST_12345")
|
||||
self.assertEqual(cred["api_secret"], "SK_TEST_67890")
|
||||
self.assertTrue(cred["testnet"])
|
||||
|
||||
# 마스킹 확인
|
||||
creds = exchange_keys.list_credentials()
|
||||
match = [c for c in creds if c["id"] == cid][0]
|
||||
self.assertNotEqual(match["api_key_masked"], "AK_TEST_12345") # 마스킹됨
|
||||
self.assertIn("…", match["api_key_masked"])
|
||||
# 정리
|
||||
exchange_keys.delete_credential(cid)
|
||||
|
||||
def test_update_credential(self):
|
||||
cid = exchange_keys.add_credential(
|
||||
"okx", "__test_okx", "k1", "s1", "p1", False, True,
|
||||
)
|
||||
self.assertTrue(exchange_keys.update_credential(cid, label="renamed", enabled=False))
|
||||
cred = exchange_keys.get_credential(cid)
|
||||
self.assertEqual(cred["label"], "renamed")
|
||||
self.assertFalse(cred["enabled"])
|
||||
exchange_keys.delete_credential(cid)
|
||||
|
||||
def test_automation_get_set(self):
|
||||
exchange_keys.automation_set("__test_auto", "yes")
|
||||
self.assertEqual(exchange_keys.automation_get("__test_auto"), "yes")
|
||||
|
||||
def test_automation_defaults(self):
|
||||
cfg = exchange_keys.automation_all()
|
||||
for k in ["enabled", "dry_run", "leverage", "position_size_pct",
|
||||
"max_open_trades", "allowed_directions"]:
|
||||
self.assertIn(k, cfg)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 4. exchange_adapters (DRY-RUN)
|
||||
# ──────────────────────────────────────────────
|
||||
class ExchangeAdaptersTest(unittest.TestCase):
|
||||
def test_dry_run_no_credential(self):
|
||||
adapter = exchange_adapters.make_adapter(None, dry_run=True)
|
||||
self.assertIsInstance(adapter, exchange_adapters.DryRunAdapter)
|
||||
self.assertEqual(adapter.get_balance("USDT"), 1000.0)
|
||||
result = adapter.place_market_order("BTCUSDT", "long", 0.01)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual(result.filled_qty, 0.01)
|
||||
|
||||
def test_dry_run_with_credential_dict(self):
|
||||
cred = {"exchange": "binance", "api_key": "K", "api_secret": "S",
|
||||
"passphrase": None, "testnet": True}
|
||||
adapter = exchange_adapters.make_adapter(cred, dry_run=True)
|
||||
self.assertEqual(adapter.exchange, "binance")
|
||||
self.assertTrue(adapter.testnet)
|
||||
close = adapter.close_position("BTCUSDT")
|
||||
self.assertTrue(close.ok)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 5. signal computation (compute_signals 가 새 임계값을 읽고 NameError 안 나는지)
|
||||
# ──────────────────────────────────────────────
|
||||
class SignalComputationTest(unittest.TestCase):
|
||||
def test_compute_signals_no_nameerror(self):
|
||||
# 백엔드만 import — Streamlit UI 코드는 실행 안 됨.
|
||||
import importlib
|
||||
# Streamlit 의 set_page_config 가 import 시점에 호출되어 컨테이너 밖에서 실행 시
|
||||
# 에러가 날 수 있어, 격리된 작은 DataFrame 으로 compute_signals 만 호출.
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
# 200개 더미 캔들 (지표 계산에 충분)
|
||||
n = 200
|
||||
rng = np.random.default_rng(seed=42)
|
||||
close = 100 + np.cumsum(rng.normal(0, 0.5, n))
|
||||
df = pd.DataFrame({
|
||||
"open_time": pd.date_range("2026-01-01", periods=n, freq="5min"),
|
||||
"open": close + rng.normal(0, 0.1, n),
|
||||
"high": close + np.abs(rng.normal(0, 0.3, n)),
|
||||
"low": close - np.abs(rng.normal(0, 0.3, n)),
|
||||
"close": close,
|
||||
"volume": rng.uniform(1000, 5000, n),
|
||||
"taker_buy_vol": rng.uniform(500, 2500, n),
|
||||
})
|
||||
df["taker_sell_vol"] = df["volume"] - df["taker_buy_vol"]
|
||||
# 지표 계산
|
||||
import ta
|
||||
df["MA7"] = df["close"].rolling(7).mean()
|
||||
df["MA25"] = df["close"].rolling(25).mean()
|
||||
df["BB_mid"] = df["close"].rolling(20).mean()
|
||||
df["BB_std"] = df["close"].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(df["close"], window=14).rsi()
|
||||
macd = ta.trend.MACD(df["close"])
|
||||
df["MACD"] = macd.macd()
|
||||
df["MACD_signal"] = macd.macd_signal()
|
||||
df["MACD_hist"] = macd.macd_diff()
|
||||
|
||||
from app_streamlit import compute_signals
|
||||
result = compute_signals(df, "5m")
|
||||
|
||||
# 결과 검증 — 컬럼 존재 + boolean cast 가능한지 (dtype 자체는 object 가
|
||||
# 되더라도 동작엔 무방, fillna 거치며 mixed type 될 수 있음).
|
||||
for col in ["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"]:
|
||||
self.assertIn(col, result.columns, f"{col} missing")
|
||||
# boolean cast 가 NaN 없이 성공해야 함
|
||||
casted = result[col].fillna(False).astype(bool)
|
||||
self.assertEqual(len(casted), len(result), f"{col} length mismatch after cast")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
PostgreSQL 기반 트레이드 이력 저장.
|
||||
|
||||
DATABASE_URL 환경변수 미설정 시 전체 silent no-op (로컬 개발에서 streamlit run
|
||||
직접 실행해도 에러 안 남). 도커 환경에선 docker-compose 가 자동 주입.
|
||||
|
||||
- trades: 진입 → 청산 lifecycle. status = 'open' / 'stopped' / 'reversal' / 'cancelled'
|
||||
- signal_events: 발사된 모든 알림 raw log (디버그/통계용)
|
||||
|
||||
스레드 안전: 모든 ops 가 _lock 으로 보호. 알림 스레드 + Streamlit UI 동시 접근.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
HAS_PG = True
|
||||
except ImportError:
|
||||
HAS_PG = False
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
||||
_lock = threading.RLock()
|
||||
_conn = None
|
||||
_init_done = False
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return HAS_PG and bool(DATABASE_URL)
|
||||
|
||||
|
||||
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 Exception:
|
||||
pass
|
||||
_conn = None
|
||||
try:
|
||||
_conn = psycopg2.connect(DATABASE_URL, connect_timeout=5)
|
||||
_conn.autocommit = True
|
||||
except Exception as e:
|
||||
print(f"[trades_db] connect 실패: {e}")
|
||||
_conn = None
|
||||
return _conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""앱 기동 시 1회. 테이블 없으면 생성."""
|
||||
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 trades (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
interval TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
signal_types TEXT NOT NULL,
|
||||
candle_time TIMESTAMP NOT NULL,
|
||||
entry_time TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
entry_price DOUBLE PRECISION NOT NULL,
|
||||
stop_price DOUBLE PRECISION NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
exit_time TIMESTAMPTZ,
|
||||
exit_price DOUBLE PRECISION,
|
||||
exit_reason TEXT,
|
||||
pnl_pct DOUBLE PRECISION
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_entry_time ON trades(entry_time DESC)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_trades_lookup ON trades(symbol, interval, direction, candle_time)")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS signal_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
interval TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
candle_time TIMESTAMP NOT NULL,
|
||||
fired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
price DOUBLE PRECISION
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_signal_events_fired ON signal_events(fired_at DESC)")
|
||||
_init_done = True
|
||||
print("[trades_db] init OK")
|
||||
except Exception as e:
|
||||
print(f"[trades_db] init 실패: {e}")
|
||||
|
||||
|
||||
def log_signal_events(symbol: str, interval: str, group: List[Dict[str, Any]]):
|
||||
"""알림 발사 직전에 호출. group 내 각 signal 을 signal_events 에 기록."""
|
||||
if not _enabled():
|
||||
return
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for e in group:
|
||||
cur.execute(
|
||||
"INSERT INTO signal_events(symbol, interval, signal_type, direction, candle_time, price) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
symbol, interval, e["sig"], e["direction"],
|
||||
_to_naive(e["candle_time"]),
|
||||
float(e["row"]["open"]) if "row" in e and e["row"] is not None else None,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[trades_db] log_signal_events 실패: {e}")
|
||||
|
||||
|
||||
def record_entry(symbol: str, interval: str, direction: str, signal_types: List[str],
|
||||
candle_time, entry_price: float, stop_price: float):
|
||||
"""진입 신호 발사 시 호출. 이미 같은 (symbol, interval, direction, candle_time) open 트레이드가
|
||||
있으면 무시 (중복 방지)."""
|
||||
if not _enabled():
|
||||
return
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM trades WHERE symbol=%s AND interval=%s AND direction=%s "
|
||||
"AND candle_time=%s AND status='open' LIMIT 1",
|
||||
(symbol, interval, direction, _to_naive(candle_time)),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return
|
||||
cur.execute(
|
||||
"INSERT INTO trades(symbol, interval, direction, signal_types, candle_time, "
|
||||
"entry_price, stop_price, status) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, 'open')",
|
||||
(
|
||||
symbol, interval, direction, ",".join(signal_types),
|
||||
_to_naive(candle_time), float(entry_price), float(stop_price),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[trades_db] record_entry 실패: {e}")
|
||||
|
||||
|
||||
def record_exit(symbol: str, interval: str, direction: str, candle_time,
|
||||
exit_price: float, exit_reason: str):
|
||||
"""진입 candle_time 매칭으로 open 트레이드를 close. 없으면 무시."""
|
||||
if not _enabled():
|
||||
return
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, entry_price FROM trades WHERE symbol=%s AND interval=%s AND direction=%s "
|
||||
"AND candle_time=%s AND status='open' ORDER BY id DESC LIMIT 1",
|
||||
(symbol, interval, direction, _to_naive(candle_time)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return
|
||||
trade_id, entry_price = row
|
||||
if direction == "long":
|
||||
pnl = (float(exit_price) - float(entry_price)) / float(entry_price) * 100.0
|
||||
else:
|
||||
pnl = (float(entry_price) - float(exit_price)) / float(entry_price) * 100.0
|
||||
cur.execute(
|
||||
"UPDATE trades SET status=%s, exit_time=now(), exit_price=%s, exit_reason=%s, pnl_pct=%s "
|
||||
"WHERE id=%s",
|
||||
(exit_reason, float(exit_price), exit_reason, pnl, trade_id),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[trades_db] record_exit 실패: {e}")
|
||||
|
||||
|
||||
def fetch_trades(limit: int = 500, status: Optional[str] = None) -> list:
|
||||
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:
|
||||
if status:
|
||||
cur.execute(
|
||||
"SELECT * FROM trades WHERE status=%s ORDER BY entry_time DESC LIMIT %s",
|
||||
(status, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute("SELECT * FROM trades ORDER BY entry_time DESC LIMIT %s", (limit,))
|
||||
return cur.fetchall()
|
||||
except Exception as e:
|
||||
print(f"[trades_db] fetch_trades 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_signal_events(limit: int = 1000) -> list:
|
||||
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 * FROM signal_events ORDER BY fired_at DESC LIMIT %s", (limit,))
|
||||
return cur.fetchall()
|
||||
except Exception as e:
|
||||
print(f"[trades_db] fetch_signal_events 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _to_naive(ts):
|
||||
if ts is None:
|
||||
return None
|
||||
if hasattr(ts, "to_pydatetime"):
|
||||
ts = ts.to_pydatetime()
|
||||
if isinstance(ts, datetime) and ts.tzinfo is not None:
|
||||
ts = ts.replace(tzinfo=None)
|
||||
return ts
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
사용자 인증 (PostgreSQL + bcrypt).
|
||||
|
||||
- users 테이블: id / username (unique) / password_hash / role / created_at / last_login_at
|
||||
- 초기 기동 시 admin 계정 자동 시드 (없는 경우만). 비밀번호는 환경변수 ADMIN_INIT_PASSWORD
|
||||
로 override 가능. 미설정 시 'Adm!n2026!' 디폴트 (사용자가 첫 로그인 후 변경 권장).
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
HAS_PG = True
|
||||
except ImportError:
|
||||
HAS_PG = False
|
||||
|
||||
try:
|
||||
import bcrypt
|
||||
HAS_BCRYPT = True
|
||||
except ImportError:
|
||||
HAS_BCRYPT = False
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
||||
DEFAULT_ADMIN_USERNAME = "admin"
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get("ADMIN_INIT_PASSWORD", "Adm!n2026!")
|
||||
|
||||
_lock = threading.RLock()
|
||||
_conn = None
|
||||
_init_done = False
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return HAS_PG and HAS_BCRYPT and bool(DATABASE_URL)
|
||||
|
||||
|
||||
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"[users_db] connect 실패: {e}")
|
||||
_conn = None
|
||||
return _conn
|
||||
|
||||
|
||||
def _hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=10)).decode("utf-8")
|
||||
|
||||
|
||||
def _check(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
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 users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
)
|
||||
""")
|
||||
cur.execute("SELECT count(*) FROM users")
|
||||
count = cur.fetchone()[0]
|
||||
if count == 0:
|
||||
cur.execute(
|
||||
"INSERT INTO users(username, password_hash, role) VALUES (%s, %s, 'admin')",
|
||||
(DEFAULT_ADMIN_USERNAME, _hash(DEFAULT_ADMIN_PASSWORD)),
|
||||
)
|
||||
print(f"[users_db] 초기 admin 시드 → username={DEFAULT_ADMIN_USERNAME} password={DEFAULT_ADMIN_PASSWORD}")
|
||||
_init_done = True
|
||||
print("[users_db] init OK")
|
||||
except Exception as e:
|
||||
print(f"[users_db] init 실패: {e}")
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""성공 시 user dict (password_hash 제외) 반환, 실패 시 None."""
|
||||
if not _enabled() or not username or not password:
|
||||
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 users WHERE username=%s", (username,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not _check(password, row["password_hash"]):
|
||||
return None
|
||||
cur.execute("UPDATE users SET last_login_at=now() WHERE id=%s", (row["id"],))
|
||||
row.pop("password_hash", None)
|
||||
return dict(row)
|
||||
except Exception as e:
|
||||
print(f"[users_db] authenticate 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def change_password(username: str, old_password: str, new_password: str) -> bool:
|
||||
if not _enabled() or len(new_password) < 6:
|
||||
return False
|
||||
user = authenticate(username, old_password)
|
||||
if user is None:
|
||||
return False
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash=%s WHERE username=%s",
|
||||
(_hash(new_password), username),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
print(f"[users_db] change_password 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_user(username: str, password: str, role: str = "user") -> Optional[int]:
|
||||
if not _enabled() or not username or not password:
|
||||
return None
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return None
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users(username, password_hash, role) VALUES (%s, %s, %s) RETURNING id",
|
||||
(username, _hash(password), role),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
except Exception as e:
|
||||
print(f"[users_db] create_user 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def list_users():
|
||||
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, username, role, created_at, last_login_at FROM users ORDER BY id")
|
||||
return cur.fetchall()
|
||||
except Exception as e:
|
||||
print(f"[users_db] list_users 실패: {e}")
|
||||
return []
|
||||
Reference in New Issue
Block a user