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:
chpark
2026-05-06 17:27:11 +09:00
parent bdd2d66ea0
commit c4e6aab7b2
55 changed files with 5192 additions and 46 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
.idea
.claude
.env
*.log
streamlit.log
streamlit.err.log
__pycache__
*.pyc
*.pyo
.DS_Store
data/
+5
View File
@@ -2,3 +2,8 @@
streamlit.log
streamlit.err.log
.env
__pycache__/
*.pyc
data/*.db
data/*.db-*
data/pgdata/
+15
View File
@@ -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
+118
View File
@@ -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
View File
@@ -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"]
+230
View File
@@ -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) 환경 설정 & 페이지 셋업 — [L1L70](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) 텔레그램 송신 + 알림 코어 — [L74L263](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 데이터 수집 — [L267L311](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) 지표 + 신호 계산 — [L315L450](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) 차트 빌드 — [L455L741](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) 알림 백그라운드 스레드 — [L774L786](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) 일일 리포트 — [L791L1005](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 — [L1010L1085](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 머지 로직 (L479L510, L750L770)** — 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
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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

+37
View File
@@ -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"]
View File
+47
View File
@@ -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
+54
View File
@@ -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"])
View File
+54
View File
@@ -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}
+37
View File
@@ -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}
+73
View File
@@ -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
+94
View File
@@ -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"])
+30
View File
@@ -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)}
+26
View File
@@ -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))
+18
View File
@@ -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
]
+14
View File
@@ -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
View File
@@ -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
View File
Executable
+42
View File
@@ -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 발급' 섹션 참조."
+89
View File
@@ -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
+93
View File
@@ -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,
)
+371
View File
@@ -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)
+24
View File
@@ -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"]
+84
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+11
View File
@@ -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; }
+18
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+156
View File
@@ -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
/>
);
}
+165
View File
@@ -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>
</>
);
}
+128
View File
@@ -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>
);
}
+41
View File
@@ -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' }),
};
+47
View File
@@ -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 });
}
},
}));
+3
View File
@@ -0,0 +1,3 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
+12
View File
@@ -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
+31
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
View File
+19
View File
@@ -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
+21
View File
@@ -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"]
}
+4
View File
@@ -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
View File
@@ -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())
+252
View File
@@ -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
View File
@@ -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
View File
@@ -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 []