Compare commits

..

15 Commits

Author SHA1 Message Date
joungmin
6e0c4508fa refactor: MVC 구조 분리 + 미사용 파일 archive 정리
- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리:
  - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal)
  - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회)
  - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건
- type hints, Google docstring, 구체적 예외 타입 적용
- 50줄 초과 함수 분리 (process_signal, restore_positions)
- 미사용 파일 58개 archive/ 폴더로 이동
- README.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:46:47 +09:00
joungmin
976c53ed66 feat: 트레일링 스탑 전환 + 사전 필터 강화 + 예산 증액
- cascade/LLM 매도 제거 -> 트레일링 스탑 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
- 사전 필터 3종 추가: 횡보/고점/연속양봉(>=2) -> LLM 호출 57% 절감
- 현재가 매수 (LLM 가격 제안 제거)
- 종목 30개 -> 10개, BTC 제외
- 예산: 100K/3pos -> 1M/5pos (종목당 200K)
- VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초
- LLM 프롬프트: 연패 무시, get_trade_history 제거
- 3월 백테스트: 승률 52.1%, PNL +17,868원
- STRATEGY.md 전면 재작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:33:15 +09:00
joungmin
872163a3d1 fix: 저가 코인 소수점 표시 + VOL_MIN 6.0 조정
- fp() 헬퍼: 100원 미만 코인 소수점 표시 (HOLO 등)
- VOL_MIN 8→6: 신호 빈도 적정화
- LLM 로그 가격도 :,.2f 통일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:54:56 +09:00
joungmin
9944b55f94 fix: restore_positions에서 locked 잔고 포함 + 기존 매도주문 취소
- balance + locked으로 보유 수량 판단 (지정가 매도 중이면 locked에 잡힘)
- 복구 시 기존 미체결 매도 주문 취소 후 새로 제출
- 취소 후 실제 가용 수량 재조회

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:38:54 +09:00
joungmin
526003c979 fix: 포지션 한도 초과 방지 + LLM 호출 최적화
- VOL_MIN 4→8 복원 (시그널 빈도 과다)
- process_signal: LLM 호출 전/후 포지션 한도 재확인
- check_pending_buys: 체결 시점 한도 초과면 즉시 취소
- LLM tool 중복 호출 방지 (같은 tool+args → 캐시 응답)
- 모든 tool 호출 완료 시 tool 제거해 강제 텍스트 응답
- max_rounds 8→5 축소
- 재시작 시 Upbit 잔고 기반 포지션 자동 복구
- LLM 모델: google/gemini-2.5-flash로 전환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:34:35 +09:00
joungmin
4f9e2c44c7 feat: LLM 판단 상세 로깅 + 텔레그램 메시지에 LLM 응답 포함
- get_entry_price/get_exit_price가 전체 dict 반환 (action, price, confidence, reason, market_status, watch_needed)
- 매수: 시그널→LLM 승인/스킵 사유, 확신도, 시장 상태 텔레그램 전송
- 매도: LLM 지정가 설정 시 진입 대비 수익률, 확신도, 관망 여부 텔레그램 전송
- 청산: LLM/cascade 구분 태그 (텔레그램 + 로그)
- cascade fallback 전환 시 로그 명시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:57:19 +09:00
joungmin
19a35e1009 feat: LLM-driven buy decisions with limit orders + robust JSON parsing
- Add get_entry_price() for LLM buy decisions (BTC trend, trade history, context tools)
- Replace market buy with LLM-determined limit buy price
- Lower signal threshold (VOL_MIN 8→4) — LLM makes final buy/skip decision
- Restructure tick_trader: detect_signal() inside lock, LLM call outside
- Add pending_buys tracking with timeout cancellation
- Remove unused enter_position() and do_buy() functions
- Fix JSON parsing: extract JSON from mixed text responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:52:44 +09:00
joungmin
7f1921441b feat: OpenRouter LLM 매도 어드바이저 + 종목 컨텍스트 수집 데몬
- llm_advisor: Anthropic → OpenRouter API 전환 (claude-haiku-4.5)
- llm_advisor: get_ticker_context DB tool 추가 (24h/7d 가격, 뉴스)
- llm_advisor: 구조화 JSON 응답 (confidence, reason, market_status, watch_needed)
- llm_advisor: LLM primary + cascade fallback (llm_active 플래그)
- llm_advisor: SQL bind variable 버그 수정 (INTERVAL → NUMTODSINTERVAL)
- tick_collector: backtest_ohlcv 1분봉 실시간 갱신 추가 (60초 주기)
- context_collector: 신규 데몬 — 1시간마다 price_stats + SearXNG 뉴스 수집
- ecosystem: tick-collector, tick-trader, context-collector PM2 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:02 +09:00
joungmin
ab5c963803 feat: WF always reads from DB + vol bypass threshold
- buy() now always loads WF history from trade_results DB directly
  (not lazy-cached in memory) → restart-safe, no stale-history issue
- Added WF_VOL_BYPASS_THRESH (default 10.0x): if vol_ratio at entry
  exceeds this threshold, WF filter is skipped regardless of win rate
- buy() now accepts vol_ratio param; runner.py passes it from
  get_active_signals() for both fast-poll and main scan loops

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 10:54:52 +09:00
joungmin
2499ea08ef docs: rewrite STRATEGY.md for 10m vol-lead strategy
- Remove legacy 40m resampling, velocity-based entry, Bear regime sections
- Add F&G 3-tier vol threshold system (<=40->6x, 41-50->5x, >50->blocked)
- Add undying signal and vol refresh behavior docs
- Add watch alert (4x-6x approaching threshold) documentation
- Add 1yr 10m backtest results and THRESH sweep table
- Add F&G greed blocking effect comparison table
- Update file list, env vars, and sim run commands
- Add change history from 2026-03-03 to 2026-03-04

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:51:39 +09:00
joungmin
cfbacdacbc feat: rewrite strategy to 10m vol-lead with undying signal + watch alert
- core/strategy.py: full rewrite to Volume Lead strategy
  - 10m candle direct detection (no 40m resampling)
  - F&G 3-tier vol threshold: <=40->6x, 41-50->5x, >50->blocked
  - Undying signal: price drop does not cancel signal (sig_p fixed)
  - Vol refresh: stronger vol_r updates signal price and timer
  - Watch alert: 4x-6x approaching threshold notifies via Telegram
  - WATCH_VOL_THRESH=4.0, WATCH_COOLDOWN_MIN=30, WATCH_VOL_JUMP=0.5
- daemon/runner.py: remove FNG_MIN_ENTRY block and Bear regime block
  - Only FNG_MAX_ENTRY(>50) blocks scan (greed/extreme greed)
  - Fast-poll loop cleaned of regime check
- core/notify.py: add notify_watch() for near-signal Telegram alerts
  - Shows vol_r, distance to threshold, price, quiet pct
- tests/: add 1y data collection and simulation scripts
  - collect_1y_data.py, refresh_cache.py
  - sim_10m_vol.py, sim_current.py, sim_regime_1y.py
  - sim_regime_sweep.py, sim_vol_override.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:28:13 +09:00
joungmin
6580cda017 docs: update STRATEGY.md with F&G filter and project structure changes
- Add F&G filter section: FNG_MIN_ENTRY=41, 1-year backtest proof
  (no filter: +95K KRW → F&G>=41: +1.72M KRW over 1 year)
- Add F&G backtest result (section D) with adaptive param findings
- Update section E: rename velocity backtest (was D)
- Split file table into production core vs tests/ scripts
- Update simulation commands to use tests/ path prefix
- Update env config: add FNG_MIN_ENTRY=41
- Add changelog entries for F&G filter, project structure, ecosystem.config.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:14:50 +09:00
joungmin
6b2c962ed8 refactor: reorganize project structure into tests/, data/, logs/
- Move all backtest/simulation scripts to tests/
  - Add sys.path.insert to each script for correct import resolution
- Move pkl cache files to data/ (git-ignored)
- Move log files to logs/ (git-ignored)
- Update main.py: trading.log path → logs/trading.log
- Add ecosystem.config.js: pm2 log paths → logs/pm2*.log
- Update .gitignore: ignore data/ and logs/ instead of *.pkl/*.log
- core/fng.py: increase cache TTL 3600→86400s (API updates daily at KST 09:00)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 16:08:50 +09:00
joungmin
bfe0b4d40c fix: reduce F&G log noise and skip scan loop when blocked
- fng.py: downgrade per-ticker block log to DEBUG
- runner.py: skip entire scan (continue) when F&G < FNG_MIN_ENTRY
  instead of iterating 20 tickers each blocked individually

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:57:12 +09:00
joungmin
27189b1ad9 feat: add Fear & Greed filter to entry logic
- core/fng.py: F&G API wrapper with 1h cache (alternative.me)
  - FNG_MIN_ENTRY=41 (env-configurable), blocks entry below threshold
- core/strategy.py: call is_entry_allowed() before volume/regime checks
- daemon/runner.py: log F&G status on every scan cycle
- core/notify.py: include F&G value in buy/signal/status notifications
- core/trader.py: pass current F&G value to notify_buy

Backtest evidence (1y / 18 tickers / 1h candles):
  - No filter:   820 trades, 32.7% WR, avg +0.012%, KRW +95k
  - F&G >= 41:   372 trades, 39.5% WR, avg +0.462%, KRW +1.72M
  - Blocked 452 trades (avg -0.372%, saved ~1.68M KRW loss)

Also add:
- backtest_db.py: Oracle DB storage for backtest runs/results/trades
- fng_1y_backtest.py, fng_adaptive_backtest.py, fng_sim_comparison.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:56:17 +09:00
76 changed files with 12434 additions and 521 deletions

4
.gitignore vendored
View File

@@ -2,5 +2,5 @@
__pycache__/
*.pyc
.venv/
*.log
*.pkl
logs/
data/

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# upbit-trader
Upbit WebSocket 기반 20초봉 자동매매 봇. LLM(Gemini 2.5 Flash) 매수 판단 + 트레일링 스탑 청산.
## 프로젝트 구조
```
upbit-trader/
STRATEGY.md -- 전략 상세 문서
ecosystem.config.js -- PM2 프로세스 설정
backtest_march.py -- 3월 백테스트 시뮬레이션
core/ -- Model / Service 레이어
signal.py -- 시그널 감지 (양봉 + VOL + 사전필터 3종)
order.py -- Upbit 주문 실행 (매수/매도/취소/조회)
position_manager.py -- 포지션 관리, 청산 조건, DB sync, 복구
llm_advisor.py -- LLM 매수 어드바이저 (OpenRouter + tool calling)
notify.py -- 텔레그램 알림
daemons/ -- Controller / 데몬
tick_trader.py -- 주력 트레이더 (WebSocket -> 봉 집계 -> 매매)
tick_collector.py -- price_tick + 1분봉 Oracle 수집
context_collector.py -- 종목 컨텍스트 수집 (뉴스 + 가격 통계)
state_sync.py -- 포지션 상태 동기화
archive/ -- 미사용 파일 보관
```
## 매매 전략 요약
### 진입
1. 20초봉 확정 시 시그널 감지 (양봉 + 거래량 5x + 거래대금 5M+)
2. 사전 필터: 횡보(15봉 변동 < 0.3%), 고점(30분 구간 90%+), 연속양봉(2봉+)
3. LLM(Gemini 2.5 Flash) 매수 판단 -> 현재가 지정가 매수
### 청산
| 조건 | 값 |
|------|------|
| 트레일링 스탑 | 고점 대비 -1.5% (최소 수익 +0.5%) |
| 손절 | -2% |
| 타임아웃 | 4시간 |
### 운용 설정
| 항목 | 값 |
|------|------|
| 감시 종목 | 10개 (ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR) |
| 총 예산 | 1,000,000원 |
| 최대 포지션 | 5개 |
| 종목당 투자 | 200,000원 |
## 기술 스택
| 구성 | 기술 |
|------|------|
| 거래소 | Upbit API (REST + WebSocket) |
| DB | Oracle ADB |
| LLM | Gemini 2.5 Flash via OpenRouter |
| 알림 | Telegram Bot API |
| 뉴스 | SearXNG (self-hosted) |
| 프로세스 | PM2 |
## 실행
```bash
# 환경 설정
cp .env.example .env
# .env에 API 키 입력
# PM2로 실행
pm2 start ecosystem.config.js
# 개별 실행
.venv/bin/python3 daemons/tick_trader.py
```
## PM2 데몬
| 이름 | 파일 | 설명 |
|------|------|------|
| tick-trader | `daemons/tick_trader.py` | 주력 트레이더 |
| tick-collector | `daemons/tick_collector.py` | 가격 데이터 수집 |
| context-collector | `daemons/context_collector.py` | 종목 컨텍스트 수집 |
| state-sync | `daemons/state_sync.py` | 포지션 상태 동기화 |
## 백테스트 결과 (2026-03-01~06)
| 항목 | 값 |
|------|------|
| 거래 수 | 48건 |
| 승률 | 52.1% |
| 총 PNL | +17,868원 (+17.9%) |
| 평균 PNL | +1.22% |
## 주의사항
- `.env` 변경 후 PM2 재시작 시 `pm2 restart --update-env` 필수
- 로그: `/tmp/tick_trader.log`

View File

@@ -1,287 +1,236 @@
# Volume Lead 전략 가이드
# upbit-trader 전략 가이드
## 전략 개요
## 시스템 개요
**거래량 선행(Volume Lead) 매집 전략** — 가격이 횡보하는 중 거래량 급증이 발생하면
매집 신호로 기록하고, 이후 일정 수준 이상 상승 시 진입하는 선진입 전략.
> 핵심 아이디어: 대형 매수자는 가격을 올리지 않고 조용히 매집한다.
> 거래량이 먼저 급증하고, 가격 상승은 그 뒤에 따라온다.
**캔들 단위: 40분봉** (Upbit `minute10` API로 수신 후 인메모리 40분 리샘플링)
| 데몬 | 전략 | 상태 |
|------|------|------|
| `tick-trader` | WebSocket 20초봉 + LLM 매수 + 트레일링 청산 | **운용 중** |
| `upbit-trader` | 10분봉 Volume Lead 매집 전략 | 중지 (2026-03-06~) |
---
## 진입 조건 (2단계)
## 1. tick-trader (WebSocket 20초봉)
### 1단계: 매집 신호 감지
다음 두 조건 동시 충족 시 `signal_price` + `vol_ratio` 기록:
### 1.1 아키텍처
| 조건 | 파라미터 | 기본값 |
|------|----------|--------|
| 2h 가격 변동 < N% (횡보) | `PRICE_QUIET_PCT` | 2.0% |
| 직전 40분봉 거래량 ≥ 로컬 5h(7봉) 평균 × M배 | `VOLUME_MULTIPLIER` | 2.0x |
- 신호 발생 시 텔레그램 알림 발송
- `SIGNAL_TIMEOUT_H` 내 진입 조건 미달 시 신호 초기화 (기본: 8h)
- 신호가 이하 하락 시 즉시 초기화 (매집 실패 판단)
> **2h 횡보 체크**: Oracle DB에 저장된 실시간 가격 기록을 조회 (`get_price_n_hours_ago`)
> **거래량 체크**: `minute10` → 40분봉 resample → 직전 완성봉 vs 이전 7봉 평균
### 2단계: 추세 확인 후 진입 (거리 기반 OR 속도 기반 — 먼저 충족되는 조건으로 진입)
**A. 거리 기반**: `signal_price` 대비 +임계값% 이상 상승 시 매수 (vol_ratio에 따라 동적 조정)
| vol_ratio | 진입 임계값 | 설명 |
|-----------|------------|------|
| ≥ 5.0x | +1.0% | 매우 강한 신호 |
| ≥ 3.5x | +2.0% | 강한 신호 |
| ≥ 2.5x | +3.0% | 중간 신호 |
| < 2.5x | +`TREND_AFTER_VOL`% | 기본 임계값 |
**B. 속도 기반 (조기 진입)**: 신호 후 가격 상승 속도가 `VELOCITY_THRESHOLD` 이상이면 즉시 진입
| 파라미터 | 기본값 | 설명 |
|----------|--------|------|
| `TREND_AFTER_VOL` | 4.8% | 거리 기반 기본 임계값 |
| `VELOCITY_THRESHOLD` | 0.10%/분 | 속도 기준 (6%/h — 가파른 상승 감지) |
| `VELOCITY_MIN_MOVE` | 0.5% | 속도 체크 전 최소 이동 % |
| `VELOCITY_MIN_AGE_M` | 5분 | 속도 체크 전 최소 경과 시간 |
> **예시 (BTC 23:20 신호)**: 23:30에 +1.26%/6분 = 0.21%/분 → 속도 기준 충족 → 조기 진입
> 실제 진입(00:34, 100,840,000) 대비 약 1시간 빠른 23:30(97,286,000) 진입 가능
### 신호 감시 스레드 (Fast Poll)
신호 감지 후 전체 스캔 60초를 기다리지 않고 해당 종목만 빠르게 폴링.
| 파라미터 | 기본값 | 설명 |
|----------|--------|------|
| `SCAN_INTERVAL` | 60초 | 전체 시장 스캔 주기 |
| `SIGNAL_POLL_INTERVAL` | 15초 | 신호 종목 집중 감시 주기 |
- 신호 발생 시 별도 스레드(`signal-fast-poll`)가 15초마다 해당 종목만 체크
- 목표 임계값(거리 or 속도) 도달 즉시 매수 → 60초 지연 제거
---
## 청산 조건
### 트레일링 스탑 (ATR 기반)
- `minute10` → 40분봉 resample 후 최근 7봉(≈5h)의 평균 진폭 계산
- ATR = 평균진폭 × 1.5 계수 → 동적 손절폭 산출
- 최소 1.0% / **최대 2.0%** 범위 내 자동 조정
- 최고가 대비 손절폭 이하 하락 시 즉시 청산
- ATR 캐시: 40분마다 갱신 (캐시 TTL=2400초)
### 타임 스탑
- 보유 `TIME_STOP_HOURS`h 경과 후 수익률 < `TIME_STOP_MIN_GAIN_PCT`% 이면 청산
- 기본값: 8시간 경과 / 수익률 3% 미만
---
## 리스크 관리
### Walk-Forward (WF) 필터
| 파라미터 | 기본값 | 설명 |
|----------|--------|------|
| `WF_WINDOW` | 4 | 이력 윈도우 (직전 N건) — 4연패 시 차단 |
| `WF_MIN_WIN_RATE` | 0.01 | 최소 승률 임계값 (1%) |
| `WF_SHADOW_WINS` | 2 | 차단 해제 조건 (가상 N연승) |
- 직전 4건 승률 < 1% → 해당 종목 진입 차단
- 차단 후 가상 추적으로 2연승 달성 시 자동 복귀
- **WF 차단 상태는 Oracle DB(`wf_state` 테이블)에 영속 저장** → 재시작 후에도 복원
### 예산 관리 (복리)
- 수익 발생 시: `운용예산 = 초기예산 + 누적수익` (복리 증가)
- 손실 발생 시: `운용예산 = 초기예산 + 누적수익` (차감)
- 하한선: 초기예산의 30% (기본: 4,500,000원)
- 포지션당 크기: `운용예산 / MAX_POSITIONS`
---
## 시장 레짐 적응
BTC·ETH·SOL·XRP 가중평균 **2h 추세 score**로 레짐 결정.
| 종목 | 가중치 |
|------|--------|
| KRW-BTC | 40% |
| KRW-ETH | 30% |
| KRW-SOL | 15% |
| KRW-XRP | 15% |
| 레짐 | score 기준 | vol_mult | 신규 진입 |
|------|-----------|----------|---------|
| BULL | ≥ +1.5% | 1.5x | 허용 |
| NEUTRAL | -0.5% ~ +1.5% | 2.0x | 허용 |
| BEAR | < -0.5% | 3.5x | **전면 차단** |
- BEAR 레짐 감지 시 신규 매수 전면 차단 (기존 포지션 청산은 정상 진행)
- 레짐 캐시 TTL: 10분 (API 호출 최소화)
- 현재가는 매 레짐 계산 시 Oracle DB(`price_history`)에 저장 → 2h 전 가격 조회에 재활용
> **2026-03-03 조정**: BEAR 기준 -1.0% → **-0.5%**로 강화
> 완만한 하락장(score ≈ -0.4%)에서 NEUTRAL로 오판하던 문제 수정
---
## 운용 설정 (.env)
```env
# 핵심 전략
PRICE_QUIET_PCT=2.0 # 2h 횡보 기준 (%)
TREND_AFTER_VOL=4.8 # 진입 임계값 (신호가 대비 %)
SIGNAL_TIMEOUT_H=8.0 # 신호 유효 시간 (h)
VOLUME_MULTIPLIER=2.0 # 거래량 배수 기준
# 속도 기반 조기 진입
VELOCITY_THRESHOLD=0.10 # %/분 (0.10 = 6%/h)
VELOCITY_MIN_MOVE=0.5 # 최소 이동 % (잡음 제거)
VELOCITY_MIN_AGE_M=5.0 # 최소 경과 시간 (분)
# 청산
TIME_STOP_HOURS=8 # 타임스탑 보유 시간
TIME_STOP_MIN_GAIN_PCT=3 # 타임스탑 최소 수익률
# 포트폴리오
MAX_BUDGET=15000000 # 초기 운용 예산
MAX_POSITIONS=3 # 최대 동시 보유 종목
# 감시 주기
SIGNAL_POLL_INTERVAL=15 # 신호 종목 빠른 감시 (초)
# WF 필터
WF_WINDOW=4
WF_MIN_WIN_RATE=0.01
WF_SHADOW_WINS=2
```
WebSocket (Upbit trade tick)
-> 20초봉 집계 (on_tick -> finalize_bars)
-> 시그널 감지 (양봉 + VOL >= 5x + 사전 필터 3종)
-> LLM 매수 판단 (get_entry_price)
-> 지정가 매수 (현재가)
-> 트레일링 스탑 / 손절 / 타임아웃 청산
```
---
### 1.2 감시 종목 (10개)
## 백테스트 결과 요약
```
ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR
```
### A. 365일 — 1h봉, WF 적용 (`sim_365.py`)
> 기간: 2025-03-02 ~ 2026-03-02 / 데이터: Oracle DB 1h OHLCV / 20종목
### 1.3 진입 조건
| 항목 | 값 |
|------|-----|
| 초기 예산 | 15,000,000원 |
| 최종 자산 | 29,996,109원 |
| 수익률 | **+100%** |
| 최대 낙폭 | -3.81% (-57만원) |
| 거래수 | 190건 (WF 183건 차단) |
| 승률 | 46% |
| 월평균 수익 | 약 115만원 |
**시그널 감지** -- 20초봉 확정 시 다음 조건 동시 충족:
### B. 45일 — 40분봉, WF + 복리 적용 (`sim_45m40.py`)
> 기간: 2026-01-20 ~ 2026-03-02 / 데이터: Upbit minute10 캐시 40분 리샘플링 / 20종목
| 순서 | 조건 | 파라미터 | 값 |
|------|------|----------|------|
| 1 | 양봉 (close > open) | -- | 필수 |
| 2 | 거래량 >= 이전 61봉 평균 x N배 (trimmed mean, 상위 10% 제거) | `VOL_MIN` | 5.0x |
| 3 | 20초봉 거래대금 >= 하한 | `VOL_KRW_MIN` | 5,000,000원 |
| 4 | 횡보 필터: 최근 15봉 변동폭 >= 0.3% | `SPREAD_MIN` | 0.3% |
| 5 | 고점 필터: 30분 구간 내 90%+ 위치 & 변동 1%+ 아닐 것 | `HIGHPOS` | 90% / 1.0% |
| 6 | 연속 양봉 필터: 직전 2봉 이상 연속 양봉 | -- | >= 2 |
| 항목 | 값 |
|------|-----|
| 초기 예산 | 15,000,000원 |
| 최종 자산 | 21,684,574원 |
| 수익률 | **+44.56%** |
| 최대 낙폭 | -3.90% (-585,102원) |
| 거래수 | 87건 (WF 3건 차단 / MAX_POS 1건 스킵) |
| 승률 | 47.1% |
| 월평균 수익 | 약 2,228,000원 |
**LLM 매수 판단** -- 사전 필터 통과 후 LLM에게 매수 여부 위임:
| 월 | 거래 | 승률 | 월수익 | 누적수익 |
|----|------|------|--------|---------|
| 2026-01 | 17건 | 29% | -160,000원 | -160,000원 |
| 2026-02 | 61건 | 49% | +6,217,000원 | +6,057,000원 |
| 2026-03 | 9건 | 67% | +627,000원 | +6,685,000원 |
- LLM이 DB Tool로 시장 데이터 조회 후 `buy` / `skip` 판단
- `buy` 시 현재가로 지정가 매수 (LLM은 가격 결정 안 함)
- `skip` 시 텔레그램 알림 + 사유 기록
- 과거 연패/승률은 고려하지 않도록 프롬프팅 (get_trade_history 제거)
> **참고 — 봉 단위별 단순 PnL 합산 비교** (WF 미적용, `interval_sweep.py`)
>
> | 봉 단위 | 거래수 | 승률 | 누적PnL | 최대낙폭 |
> |---------|--------|------|---------|---------|
> | 10분 | 180 | 33.9% | +15.8% | -32.6% |
> | 20분 | 120 | 36.7% | +31.0% | -16.7% |
> | 30분 | 91 | 48.4% | +81.7% | -12.9% |
> | **40분** | **91** | **48.4%** | **+119.4%** | **-11.2%** ← 채택 |
> | 50분 | 83 | 50.6% | +94.7% | -17.1% |
> | 60분 | 65 | 50.8% | +88.3% | -11.9% |
**중복/한도 방지**:
- 이미 보유(`positions`) 또는 매수대기(`pending_buys`) 종목은 스킵
- LLM 호출 전/후 포지션 한도(`MAX_POS`) 이중 체크
- 예산 체크: MAX_BUDGET - (보유 투자금 + 미체결 투자금)
- 미체결 180초 초과 시 자동 취소
### C. ATR_MAX_STOP 스윕 — 1h봉 기준 (`atr_sweep.py`)
> 데이터: Oracle DB 1h OHLCV / 20종목
### 1.4 청산: 트레일링 스탑
| ATR_MAX | 승률 | 누적PnL | 최대낙폭 | 평균스탑 |
|---------|------|---------|---------|---------|
| 1.5% | 52.3% | +442% | -3.2% | 1.49% |
| **2.0%** | **50.8%** | **+299%** | **-4.1%** | **1.49%** ← 현재 채택 |
| 2.5% | 50.8% | +256% | -5.3% | 1.77% |
| 4.0% | 45.9% | -52% | -29.1% | 3.11% |
LLM 매도는 제거됨. 규칙 기반 트레일링 스탑으로 청산.
### D. 속도 진입 효과 비교 — 10분봉 (`velocity_backtest.py`)
> 기간: 2026-01-19 ~ 2026-03-02 / 10분봉 캐시 / 20종목
| 조건 | 파라미터 | 값 | 설명 |
|------|----------|------|------|
| 트레일링 스탑 | `TRAIL_PCT` | -1.5% | 고점 대비 하락 시 시장가 청산 |
| 최소 수익 | `MIN_PROFIT_PCT` | +0.5% | 트레일 발동 최소 수익률 |
| 손절 | `STOP_LOSS_PCT` | -2.0% | 진입가 대비 -2% 시 시장가 청산 |
| 타임아웃 | `TIMEOUT_SECS` | 14,400초 (4h) | 경과 시 시장가 청산 |
| 설정 | 속도진입 | 승률 | 수익률 | 최대낙폭 |
|------|---------|------|--------|---------|
| A: 거리 기반만 | 0건 | 34.7% | +8.83% | -8.35% |
| B: +속도(0.10) | 89건 | 33.6% | +13.36% | **-4.50%** |
| B: +속도(0.15) | 39건 | 35.7% | +17.19% | -7.84% |
- 실시간 tick마다 peak 갱신 + 손절/트레일 체크 (`update_positions`)
- 20초봉 확정 시에도 체크 (`check_filled_positions`)
- **0.10 채택**: 낙폭 -8.35% → -4.50% 개선, 수익률 +4.5%p
- 속도진입 BTC 예시: 0.21%/분 → 0.10 기준 충족 (0.20도 아슬하게 충족)
### 1.5 LLM 어드바이저
**모델**: Google Gemini 2.5 Flash (OpenRouter API)
**비용**: ~5원/일 (~150원/월) -- 매도 LLM 제거 + 사전 필터로 대폭 절감
**DB Tool 4개** (매수 판단용):
| Tool | 데이터 소스 | 용도 |
|------|-----------|------|
| `get_price_ticks` | Oracle `price_tick` | 최근 N분 가격 틱 (단기 추세) |
| `get_ohlcv` | Oracle `backtest_ohlcv` | 1분봉 OHLCV (지지/저항) |
| `get_ticker_context` | Oracle `ticker_context` | 종목 평판 (가격 변동, 뉴스) |
| `get_btc_trend` | Oracle `backtest_ohlcv` | BTC 4시간봉 추세 |
**최적화**:
- Tool call 중복 제거: 동일 tool+args 호출 시 캐시된 결과 반환
- max_rounds=5: 최대 5라운드 tool calling 후 강제 응답
### 1.6 재시작 복구
PM2 재시작 시 `restore_positions()`:
1. Upbit `get_balances()`로 보유 종목 조회 (balance + locked)
2. 포지션 복구 (트레일링 스탑 모드)
3. 미체결 매수 주문도 `pending_buys`에 복구
4. `entry_ts` 백데이팅으로 즉시 활성화
### 1.7 가격 표시
`fp()` 헬퍼로 가격대별 소수점 자동 조정:
- >= 100원: 정수 (예: 106,177,000)
- >= 10원: 소수 1자리 (예: 47.5)
- < 10원: 소수 2자리 (예: 0.85)
---
## 주요 파일
## 2. upbit-trader (10분봉 Volume Lead) [중지됨]
> 중지 사유: tick-trader와 동일 계좌에서 동시 운용 시 예산 초과 문제
- 10분봉 Volume Lead 매집 전략
- 횡보 중 거래량 급증 -> 신호 기록 -> +4.8% 상승 확인 후 진입
- F&G 필터: <=40->6x / 41~50->5x / >50->차단
- ATR 트레일링 스탑 + 타임스탑(8h)
- WF 필터: Oracle DB 영속 저장
---
## 3. 공통 인프라
### 3.1 데이터 수집 데몬
| 데몬 | 주기 | 역할 |
|------|------|------|
| `tick-collector` | 30초 | `price_tick` 30초봉 + `backtest_ohlcv` 1분봉 Oracle 저장 |
| `context-collector` | 1시간 | 종목별 `ticker_context` (가격 통계 + SearXNG 뉴스) |
### 3.2 기술 스택
| 구성 | 기술 |
|------|------|
| 거래소 | Upbit API (REST + WebSocket) |
| DB | Oracle ADB (price_tick, backtest_ohlcv, ticker_context, trade_log, wf_state, position_sync) |
| LLM | Google Gemini 2.5 Flash via OpenRouter |
| 알림 | Telegram Bot API |
| 뉴스 | SearXNG (self-hosted) |
| 프로세스 | PM2 (tick-trader, tick-collector, context-collector) |
### 3.3 예산 관리
| 파라미터 | 값 | 설명 |
|----------|------|------|
| `MAX_BUDGET` | 1,000,000원 | 총 운용 예산 |
| `MAX_POSITIONS` | 5 | 최대 동시 보유 종목 |
| 종목당 예산 | 200,000원 | `MAX_BUDGET / MAX_POSITIONS` |
---
## 4. 프로젝트 구조
### 프로덕션
| 파일 | 역할 |
|------|------|
| `core/strategy.py` | 진입 신호 로직 (40분봉 vol-lead + 속도 기반 조기 진입) |
| `core/monitor.py` | ATR 트레일링 스탑 + 타임스탑 (40분봉 ATR) |
| `core/trader.py` | 주문 실행 + 복리 예산 관리 |
| `core/market_regime.py` | 시장 레짐 감지 (BTC/ETH/SOL/XRP 가중 2h 추세) |
| `core/price_db.py` | 가격 DB + WF 상태 영속화 |
| `daemon/runner.py` | 전체 스캔 루프 + 신호 종목 fast-poll 스레드 |
| `ohlcv_db.py` | OHLCV 시계열 DB 캐시 관리 |
| `sim_365.py` | 365일 복리 시뮬레이션 (1h봉, DB) |
| `sim_45m40.py` | 45일 복리 시뮬레이션 (40분봉, 캐시) |
| `velocity_backtest.py` | 속도 진입 효과 비교 백테스트 (A vs B vs C) |
| `atr_sweep.py` | ATR_MAX_STOP 파라미터 스윕 |
| `interval_sweep.py` | 봉 단위별 성과 비교 (10/20/30/40/50/60분) |
| `daemons/tick_trader.py` | WebSocket 20초봉 트레이더 (LLM 매수 + 트레일링 청산) |
| `daemons/tick_collector.py` | price_tick + 1분봉 수집 |
| `daemons/context_collector.py` | 종목 컨텍스트 수집 (뉴스 + 가격 통계) |
| `core/llm_advisor.py` | OpenRouter LLM 매수 어드바이저 (tool calling) |
| `core/notify.py` | 텔레그램 알림 |
### 설정
| 파일 | 역할 |
|------|------|
| `.env` | API 키, 전략 파라미터, DB 설정 |
| `ecosystem.config.js` | PM2 프로세스 설정 |
### 백테스트
| 파일 | 역할 |
|------|------|
| `backtest_march.py` | 3월 1분봉 시뮬레이션 (Oracle DB 데이터) |
---
## 시뮬레이션 실행
## 5. 운용 설정 (.env)
```bash
# 45일 복리 시뮬 — 40분봉 (현재 전략 기준)
python sim_45m40.py
```env
# 총 운용 예산 / 최대 동시 보유 종목
MAX_BUDGET=1000000
MAX_POSITIONS=5
# 365일 복리 시뮬 — 1h봉 (DB에서 로드)
python sim_365.py
# LLM (OpenRouter)
OPENROUTER_API_KEY=sk-or-v1-...
LLM_MODEL=google/gemini-2.5-flash
# 속도 진입 효과 비교
python velocity_backtest.py
# Oracle ADB
ORACLE_USER=admin
ORACLE_PASSWORD=...
ORACLE_DSN=...
ORACLE_WALLET=...
# 봉 단위별 비교 (10m 캐시 필요)
python interval_sweep.py
# ATR_MAX_STOP 스윕 (DB에서 로드)
python atr_sweep.py
# OHLCV DB 상태 확인
python ohlcv_db.py status
# 신규 봉 증분 업데이트
python ohlcv_db.py update
# Telegram
TELEGRAM_TRADE_TOKEN=...
TELEGRAM_CHAT_ID=...
```
---
## 변경 이력
## 6. 백테스트 결과
### 3월 시뮬레이션 (1분봉, 연속양봉 필터 적용)
> 기간: 2026-03-01 ~ 2026-03-06 / 10종목 / MAX_POS=3
| 항목 | 필터 전 | 연속양봉 >= 2 적용 |
|------|---------|-------------------|
| 시그널 | 112건 | 48건 (-57%) |
| 승률 | 38.4% | **52.1%** |
| 총 PNL | +11,530원 | **+17,868원** |
| 평균 PNL | +0.13% | **+1.22%** |
| 손절 | 31건 | 12건 |
| 트레일 익절 | 13건 | 18건 |
---
## 7. 변경 이력
| 날짜 | 변경 내용 |
|------|---------|
| 2026-03-03 | BEAR_THRESHOLD -1.0% → **-0.5%** 강화 (완만한 하락장 오판 수정) |
| 2026-03-03 | 속도 기반 조기 진입 추가 (`VELOCITY_THRESHOLD=0.10%/분`) |
| 2026-03-03 | 신호 종목 fast-poll 스레드 추가 (`SIGNAL_POLL_INTERVAL=15s`) |
| 2026-03-03 | `sell_reason` 컬럼 VARCHAR2(100→500) 자동 확장 |
| 2026-03-03 | vol_ratio 강도별 진입 임계값 티어 추가 (5x→1%, 3.5x→2%, 2.5x→3%) |
| 2026-03-06 | 연속 양봉 >= 2 필터 추가 (승률 38% -> 52%) |
| 2026-03-06 | 예산 변경: 100K/3pos -> 1M/5pos (종목당 200K) |
| 2026-03-06 | LLM 매도 제거 -> 트레일링 스탑 전환 (TRAIL -1.5%, SL -2%, 4h timeout) |
| 2026-03-06 | cascade 매도 제거 |
| 2026-03-06 | 사전 필터 3종 추가 (횡보/고점/연속양봉) -> LLM 호출 ~57% 절감 |
| 2026-03-06 | 종목 30개 -> 10개 축소, BTC 제외 |
| 2026-03-06 | 현재가 매수 (LLM 가격 제안 제거) |
| 2026-03-06 | LLM 매수 프롬프트: 연패 무시, get_trade_history 제거 |
| 2026-03-06 | VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초 |
| 2026-03-06 | 예산 체크: MAX_BUDGET - 투자금 합계 방식 |
| 2026-03-06 | `_round_price` 호가 단위 수정: 10만~50만 구간 50->100원 |
| 2026-03-06 | 매도 주문 실패 시 자동 재시도 |
| 2026-03-06 | upbit-trader (보조 봇) 중지 |
| 2026-03-05 | `tick-trader` LLM-driven 매수/매도 전환, cascade fallback 구현 |
| 2026-03-05 | LLM 모델 Claude Haiku 4.5 -> Gemini 2.5 Flash (비용 7.5x 절감) |
| 2026-03-05 | `restore_positions()`: 잔고 기반 포지션 복구 |
| 2026-03-05 | 포지션 한도 초과 방지 3중 체크 |

71
archive/core/fng.py Normal file
View File

@@ -0,0 +1,71 @@
"""공포탐욕지수(F&G) 조회 모듈.
alternative.me API로 일일 F&G 값을 가져와 메모리에 캐시한다.
캐시 TTL은 24시간 (F&G는 하루 1회 KST 09:00 업데이트).
환경변수:
FNG_MIN_ENTRY (기본값 41): 이 값 미만이면 진입 차단
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.request
from datetime import datetime
logger = logging.getLogger(__name__)
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41")) # 진입 허용 최소 F&G 값
_FNG_API_URL = "https://api.alternative.me/fng/?limit=1&format=json"
_CACHE_TTL = 86400 # 24시간 (API는 하루 1회 KST 09:00 업데이트)
_fng_value: int | None = None
_fng_cached_at: float = 0.0
_fng_date_str: str = ""
def get_fng() -> int:
"""오늘의 F&G 지수 반환 (0~100). API 실패 시 50(중립) 반환."""
global _fng_value, _fng_cached_at, _fng_date_str
now = time.time()
if _fng_value is not None and (now - _fng_cached_at) < _CACHE_TTL:
return _fng_value
try:
with urllib.request.urlopen(_FNG_API_URL, timeout=5) as r:
data = json.loads(r.read())
entry = data["data"][0]
_fng_value = int(entry["value"])
_fng_cached_at = now
_fng_date_str = entry.get("timestamp", "")
logger.info(
f"[F&G] 지수={_fng_value} ({entry.get('value_classification','')}) "
f"날짜={datetime.fromtimestamp(int(_fng_date_str)).strftime('%Y-%m-%d') if _fng_date_str else '?'}"
)
except Exception as e:
logger.warning(f"[F&G] API 조회 실패: {e} → 캐시/중립값 사용")
if _fng_value is None:
_fng_value = 50 # 폴백: 중립
return _fng_value # type: ignore[return-value]
def is_entry_allowed() -> bool:
"""현재 F&G 기준으로 진입 허용 여부 반환.
F&G ≥ FNG_MIN_ENTRY(41) 이면 True.
극공포/공포 구간(< 41)이면 False → 진입 차단.
"""
fv = get_fng()
allowed = fv >= FNG_MIN_ENTRY
if not allowed:
label = (
"극공포" if fv <= 25 else
"공포" if fv <= 40 else
"약공포"
)
logger.debug(f"[F&G] 진입 차단 — F&G={fv} ({label}) < {FNG_MIN_ENTRY}")
return allowed

171
archive/core/strategy.py Normal file
View File

@@ -0,0 +1,171 @@
"""Volume Lead 전략: 10분봉 거래량 급증 + 횡보 감지 후 +THRESH% 상승 시 진입.
흐름:
1. 직전 완성 10분봉 거래량 > 로컬 LV봉(280분) 평균 × VOL_THRESH AND
QN봉(120분) 이전 종가 대비 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적)
→ 신호가(signal_price) + 거래량비율(vol_ratio) 기록
* 더 강한 vol(> 기존 sig vol_ratio)이 오면 sig_p 갱신
2. signal_price 대비 +TREND_AFTER_VOL%(4.8%) 이상 상승 시 진입
3. 신호불사: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정, 만료까지 대기)
4. SIGNAL_TIMEOUT_MIN(480분=8h) 초과 시 신호 초기화
거래량 임계값 + 진입 차단 (F&G 기반 3구간):
- F&G ≤ FNG_FEAR_THRESHOLD(40): VOL_THRESH_FEAR(6.0x) ← 공포/극공포
- F&G 41 ~ FNG_MAX_ENTRY(50): VOL_THRESH_NORMAL(5.0x) ← 중립
- F&G > FNG_MAX_ENTRY(50): 진입 차단 ← 탐욕/극탐욕
캔들: minute10 데이터 직접 사용 (40분봉 리샘플링 없음)
"""
from __future__ import annotations
import logging
import os
import time
import pyupbit
from .fng import get_fng
from .market import get_current_price
from .notify import notify_signal, notify_watch
logger = logging.getLogger(__name__)
# 10분봉 직접 사용 파라미터
LOCAL_VOL_CANDLES = int(os.getenv("LOCAL_VOL_CANDLES", "28")) # 로컬 vol 평균 구간 (280분)
QUIET_CANDLES = int(os.getenv("QUIET_CANDLES", "12")) # 횡보 체크 구간 (120분)
PRICE_QUIET_PCT = float(os.getenv("PRICE_QUIET_PCT", "2.0")) # 횡보 기준 (%)
TREND_AFTER_VOL = float(os.getenv("TREND_AFTER_VOL", "4.8")) # 진입 임계값 (신호가 대비 %)
SIGNAL_TIMEOUT_MIN = int(os.getenv("SIGNAL_TIMEOUT_MIN", "480")) # 신호 유효 시간 (분=8h)
# F&G 기반 거래량 임계값 + 진입 차단
VOL_THRESH_NORMAL = float(os.getenv("VOL_THRESH_NORMAL", "5.0")) # 중립 구간 (F&G 41~FNG_MAX_ENTRY)
VOL_THRESH_FEAR = float(os.getenv("VOL_THRESH_FEAR", "6.0")) # 공포/극공포 (F&G ≤ FNG_FEAR_THRESHOLD)
FNG_FEAR_THRESHOLD = int(os.getenv("FNG_FEAR_THRESHOLD", "40")) # 공포 기준 (이하 → FEAR 임계값)
FNG_MAX_ENTRY = int(os.getenv("FNG_MAX_ENTRY", "50")) # 진입 허용 최대 F&G (초과 → 차단)
# 관찰 알림 (신호 임계값에 근접했지만 미달인 종목)
WATCH_VOL_THRESH = float(os.getenv("WATCH_VOL_THRESH", "4.0")) # 관찰 시작 임계값
WATCH_COOLDOWN_MIN = int(os.getenv("WATCH_COOLDOWN_MIN", "30")) # 같은 종목 재알림 최소 간격 (분)
WATCH_VOL_JUMP = float(os.getenv("WATCH_VOL_JUMP", "0.5")) # 쿨다운 무시 vol 상승폭
# 10분봉 조회 수
_FETCH_10M = LOCAL_VOL_CANDLES + QUIET_CANDLES + 5 # 45봉
# 축적 신호 상태: ticker → {"price": float, "time": float(unix), "vol_ratio": float}
_accum_signals: dict[str, dict] = {}
# 관찰 알림 상태: ticker → {"time": float, "vol_ratio": float}
_watch_notified: dict[str, dict] = {}
def get_active_signals() -> dict[str, dict]:
"""현재 활성화된 신호 딕셔너리 반환 (fast-poll 루프용).
Returns:
{ticker: {"price": float, "time": float, "vol_ratio": float}}
"""
return dict(_accum_signals)
def should_buy(ticker: str) -> bool:
"""Volume Lead 전략 (10분봉 직접 감지).
1단계: F&G 값으로 vol 임계값 동적 설정 (≤40→6x, >40→5x)
2단계: 10분봉 거래량 급증 + QN봉 횡보 → 신호가 기록 (더 강한 vol이면 갱신)
3단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입
신호불사: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정)
"""
fng = get_fng()
# F&G 탐욕/극탐욕 구간 진입 차단
if fng > FNG_MAX_ENTRY:
logger.debug(f"[F&G차단] {ticker} F&G={fng} > {FNG_MAX_ENTRY} (탐욕) → 진입 금지")
return False
# F&G 구간별 vol 임계값
vth = VOL_THRESH_FEAR if fng <= FNG_FEAR_THRESHOLD else VOL_THRESH_NORMAL
current = get_current_price(ticker)
if not current:
return False
now = time.time()
# ── 신호 만료 체크 ────────────────────────────────────
sig = _accum_signals.get(ticker)
if sig is not None:
age_min = (now - sig["time"]) / 60
if age_min > SIGNAL_TIMEOUT_MIN:
del _accum_signals[ticker]
sig = None
logger.debug(f"[축적타임아웃] {ticker} {age_min:.0f}분 경과 → 신호 초기화")
# ── 10분봉 데이터 조회 ────────────────────────────────
try:
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M)
except Exception:
return False
if df10 is None or len(df10) < LOCAL_VOL_CANDLES + QUIET_CANDLES:
return False
# ── 거래량 비율 계산 (직전 완성봉 기준) ───────────────
vol_prev = float(df10["volume"].iloc[-2])
vol_avg = float(df10["volume"].iloc[-(LOCAL_VOL_CANDLES + 2):-2].mean())
vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0
# ── 횡보 체크 (QN봉 이전 종가 기준) ──────────────────
close_qn = float(df10["close"].iloc[-(QUIET_CANDLES + 1)])
chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0
# ── 관찰 알림: WATCH_VOL_THRESH ≤ vol_r < vth + 횡보 ──
if WATCH_VOL_THRESH <= vol_r < vth and chg < PRICE_QUIET_PCT:
prev = _watch_notified.get(ticker)
age_min = (now - prev["time"]) / 60 if prev else 999.0
vol_jump = vol_r - prev["vol_ratio"] if prev else vol_r
if prev is None or age_min >= WATCH_COOLDOWN_MIN or vol_jump >= WATCH_VOL_JUMP:
_watch_notified[ticker] = {"time": now, "vol_ratio": vol_r}
logger.info(
f"[관찰] {ticker} vol={vol_r:.2f}x (신호기준={vth:.1f}x) + 횡보({chg:.1f}%) | F&G={fng}"
)
notify_watch(ticker, current, vol_r, vth, chg, fng=fng)
elif vol_r < WATCH_VOL_THRESH:
_watch_notified.pop(ticker, None)
# ── vol 스파이크 + 횡보 → 신호 설정/갱신 ────────────
if vol_r >= vth and chg < PRICE_QUIET_PCT:
if sig is None or vol_r > sig.get("vol_ratio", 0.0):
_accum_signals[ticker] = {"price": current, "time": now, "vol_ratio": vol_r}
sig = _accum_signals[ticker]
logger.info(
f"[축적감지] {ticker} 10m vol={vol_r:.2f}x ≥ {vth:.1f}x + 횡보({chg:.1f}%) "
f"→ 신호가={current:,.2f}원 (F&G={fng})"
)
notify_signal(ticker, current, vol_r, fng=fng)
if sig is None:
logger.debug(
f"[축적✗] {ticker} vol={vol_r:.2f}x (기준={vth:.1f}x) / 횡보={chg:.1f}%"
)
return False
# ── 진입 확인: 신호가 대비 +TREND_AFTER_VOL% 이상 ──
signal_price = sig["price"]
vol_ratio = sig["vol_ratio"]
move_pct = (current - signal_price) / signal_price * 100
age_min = (now - sig["time"]) / 60
if move_pct >= TREND_AFTER_VOL:
del _accum_signals[ticker]
logger.info(
f"[축적진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}"
f"(+{move_pct:.1f}% ≥ {TREND_AFTER_VOL}% | 거래량={vol_ratio:.2f}x | F&G={fng})"
)
return True
# 신호불사: 가격 하락해도 신호 유지 (sig_p 고정, 만료까지 대기)
logger.debug(
f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} "
f"({move_pct:+.1f}% / 목표={TREND_AFTER_VOL}% | "
f"거래량={vol_ratio:.2f}x | 경과={age_min:.0f}분)"
)
return False

View File

@@ -63,6 +63,7 @@ def _recalc_compound_budget() -> None:
WF_WINDOW = int(float(os.getenv("WF_WINDOW", "5"))) # 이력 윈도우 크기
WF_MIN_WIN_RATE = float(os.getenv("WF_MIN_WIN_RATE", "0.40")) # 최소 승률 임계값
WF_SHADOW_WINS = int(os.getenv("WF_SHADOW_WINS", "2")) # shadow N연승 → WF 해제
WF_VOL_BYPASS_THRESH = float(os.getenv("WF_VOL_BYPASS_THRESH", "10.0")) # 이 이상 vol이면 WF 무시
_lock = threading.Lock()
_positions: dict = {}
@@ -388,17 +389,28 @@ def restore_positions() -> None:
logger.error(f"DB 포지션 삭제 실패 {ticker}: {e}")
def buy(ticker: str) -> bool:
"""시장가 매수. 예산·포지션 수 확인 후 진입."""
def buy(ticker: str, vol_ratio: float = 0.0) -> bool:
"""시장가 매수. 예산·포지션 수 확인 후 진입.
Args:
vol_ratio: 진입 시점의 거래량 배율. WF_VOL_BYPASS_THRESH 이상이면 WF 필터 무시.
"""
with _lock:
if ticker in _positions:
logger.debug(f"{ticker} 이미 보유 중")
return False
# WF 이력 항상 DB에서 직접 로드 (재시작과 무관하게 최신 이력 반영)
try:
hist = load_recent_wins(ticker, WF_WINDOW)
_trade_history[ticker] = hist # 메모리 캐시 동기화
except Exception as e:
logger.warning(f"WF 이력 DB 로드 실패 {ticker}: {e}")
hist = _trade_history.get(ticker, [])
# 직전 매도가 +1% 이상일 때만 재진입 (손절 직후 역방향 재매수 방지)
# 단, 직전 거래가 수익(승)이었으면 이 필터 스킵 — 다시 상승 시 재진입 허용
if ticker in _last_sell_prices:
hist = _get_history(ticker)
last_was_win = bool(hist[-1]) if hist else False
if not last_was_win:
current_check = pyupbit.get_current_price(ticker)
@@ -412,9 +424,14 @@ def buy(ticker: str) -> bool:
return False
# Walk-forward 필터: 직전 WF_WINDOW건 승률이 낮으면 진입 차단 + shadow 진입
# vol이 WF_VOL_BYPASS_THRESH 이상이면 WF 무시 (강한 신호 우선)
if WF_MIN_WIN_RATE > 0:
hist = _get_history(ticker)
if len(hist) >= WF_WINDOW:
if WF_VOL_BYPASS_THRESH > 0 and vol_ratio >= WF_VOL_BYPASS_THRESH:
logger.info(
f"[WF바이패스] {ticker} vol={vol_ratio:.1f}x ≥ {WF_VOL_BYPASS_THRESH}x"
f" → WF 필터 무시 (강한 vol 신호)"
)
elif len(hist) >= WF_WINDOW:
recent_wr = sum(hist[-WF_WINDOW:]) / WF_WINDOW
if recent_wr < WF_MIN_WIN_RATE:
logger.info(
@@ -480,8 +497,10 @@ def buy(ticker: str) -> bool:
f"{prefix}[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | "
f"수량={amount} | 투자금={order_krw:,}원 | trade_id={trade_id[:8]}"
)
from .fng import get_fng
notify_buy(ticker, actual_price, amount, order_krw,
max_budget=MAX_BUDGET, per_position=PER_POSITION)
max_budget=MAX_BUDGET, per_position=PER_POSITION,
fng=get_fng())
return True
except Exception as e:
logger.error(f"매수 예외 {ticker}: {e}")

View File

@@ -6,9 +6,9 @@ import threading
import time
from core import trader
from core.fng import get_fng
from core.market import get_top_tickers
from core.market_regime import get_regime
from core.strategy import get_active_signals, should_buy
from core.strategy import FNG_MAX_ENTRY, get_active_signals, should_buy
logger = logging.getLogger(__name__)
@@ -27,8 +27,6 @@ def _fast_poll_loop() -> None:
try:
signals = get_active_signals()
if signals:
regime = get_regime()
if regime["name"] != "bear":
positions = trader.get_positions()
for ticker in list(signals):
if ticker in positions:
@@ -37,8 +35,9 @@ def _fast_poll_loop() -> None:
break
try:
if should_buy(ticker):
logger.info(f"[빠른감시] 매수 신호: {ticker}")
trader.buy(ticker)
vol_r = signals.get(ticker, {}).get("vol_ratio", 0.0)
logger.info(f"[빠른감시] 매수 신호: {ticker} (vol={vol_r:.1f}x)")
trader.buy(ticker, vol_ratio=vol_r)
time.sleep(0.1)
except Exception as e:
logger.error(f"[빠른감시] 오류 {ticker}: {e}")
@@ -63,18 +62,22 @@ def run_scanner() -> None:
time.sleep(SCAN_INTERVAL)
continue
# Bear 레짐 시 신규 매수 완전 차단
regime = get_regime()
if regime["name"] == "bear":
# F&G 탐욕/극탐욕 구간 → 전체 스캔 스킵 (strategy.py와 동일 기준)
fv = get_fng()
fng_label = (
"극탐욕" if fv >= 76 else "탐욕" if fv >= 56 else
"중립" if fv >= 46 else "약공포" if fv >= 41 else
"공포" if fv >= 26 else "극공포"
)
if fv > FNG_MAX_ENTRY:
logger.info(
f"[Bear차단] 레짐={regime['emoji']} BEAR "
f"(score={regime['score']:+.2f}%) — 신규 매수 스킵"
f"[F&G차단] F&G={fv} ({fng_label}) > {FNG_MAX_ENTRY} — 탐욕 구간 스캔 스킵"
)
time.sleep(SCAN_INTERVAL)
continue
tickers = get_top_tickers()
logger.info(f"스캔 시작: {len(tickers)}개 종목")
logger.info(f"스캔 시작: {len(tickers)}개 종목 | F&G={fv}({fng_label})")
for ticker in tickers:
# 이미 보유 중인 종목 제외
@@ -83,8 +86,9 @@ def run_scanner() -> None:
try:
if should_buy(ticker):
logger.info(f"매수 신호: {ticker}")
trader.buy(ticker)
vol_r = get_active_signals().get(ticker, {}).get("vol_ratio", 0.0)
logger.info(f"매수 신호: {ticker} (vol={vol_r:.1f}x)")
trader.buy(ticker, vol_ratio=vol_r)
time.sleep(0.15) # API rate limit 방지
except Exception as e:
logger.error(f"스캔 오류 {ticker}: {e}")

View File

@@ -0,0 +1,169 @@
"""1분봉 장기 히스토리 fetch 데몬.
주요 5종목(BTC/ETH/XRP/SOL/DOGE)의 1분봉을 2년치까지 소급 수집.
백그라운드에서 조용히 실행 — API 딜레이 충분히 줘서 다른 작업 방해 안 함.
재시작 시 DB에 이미 있는 범위는 건너뜀.
실행:
.venv/bin/python3 daemons/fetch_1min_history.py [--tickers BTC ETH] [--days 730]
로그:
/tmp/fetch_1min_history.log
"""
import sys, os, time, argparse, logging
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
# ── 설정 ──────────────────────────────────────────────────────────────────────
DEFAULT_TICKERS = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE']
BATCH = 200 # API 1회 요청 봉수 (Upbit 최대 200)
DELAY = 0.4 # API 호출 간격 (초) — 넉넉히 줘서 rate limit 회피
RETRY_WAIT = 5.0 # 오류 시 대기 (초)
# ── 로깅 설정 ─────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/fetch_1min_history.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def get_oldest_ts(conn, ticker: str):
"""DB에 있는 해당 ticker 1분봉의 가장 오래된 ts 반환. 없으면 None."""
cur = conn.cursor()
cur.execute(
"SELECT MIN(ts) FROM backtest_ohlcv "
"WHERE ticker=:t AND interval_cd='minute1'",
{"t": ticker}
)
row = cur.fetchone()
return row[0] if row and row[0] else None
def insert_batch(conn, ticker: str, rows: list) -> int:
"""rows: [(ts, open, high, low, close, volume), ...] — bulk insert, 중복 무시."""
if not rows:
return 0
cur = conn.cursor()
cur.executemany(
"INSERT INTO backtest_ohlcv "
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,'minute1',:2,:3,:4,:5,:6,:7)",
[(ticker, ts, o, h, l, c, v) for ts, o, h, l, c, v in rows],
batcherrors=True,
)
errors = cur.getbatcherrors()
conn.commit()
return len(rows) - len(errors)
def fetch_ticker(conn, ticker: str, cutoff: datetime) -> int:
"""ticker의 cutoff까지 1분봉 소급 fetch.
DB에 이미 있는 범위는 batcherrors로 자동 스킵.
"""
oldest_in_db = get_oldest_ts(conn, ticker)
if oldest_in_db and oldest_in_db <= cutoff:
log.info(f"{ticker}: DB에 이미 {oldest_in_db.date()} 까지 있음 → 스킵")
return 0
# datetime.now()에서 시작해 cutoff까지 역방향 fetch
# 중복은 DB unique constraint + batcherrors가 처리
to_dt = datetime.now()
total = 0
batch_n = 0
if oldest_in_db:
log.info(f"{ticker}: DB 최솟값={oldest_in_db.date()}, {cutoff.date()} 까지 소급 시작")
else:
log.info(f"{ticker}: DB에 데이터 없음, {cutoff.date()} 까지 전체 fetch 시작")
while to_dt > cutoff:
# pyupbit의 to 파라미터는 UTC로 해석됨 — KST에서 9시간 빼서 전달
to_utc = to_dt - timedelta(hours=9)
to_str = to_utc.strftime('%Y-%m-%d %H:%M:%S')
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str)
time.sleep(DELAY)
except Exception as e:
log.warning(f"{ticker} API 오류: {e}{RETRY_WAIT}s 후 재시도")
time.sleep(RETRY_WAIT)
continue
if df is None or len(df) == 0:
log.info(f"{ticker}: API 데이터 소진 ({to_str})")
break
rows = [
(ts.to_pydatetime(), float(r['open']), float(r['high']),
float(r['low']), float(r['close']), float(r['volume']))
for ts, r in df.iterrows()
]
n = insert_batch(conn, ticker, rows)
total += n
batch_n += 1
oldest = df.index[0].to_pydatetime()
if batch_n % 50 == 0:
log.info(f" {ticker} 배치{batch_n:04d}: {oldest.date()} | 신규 누적 {total:,}")
to_dt = oldest - timedelta(minutes=1)
if oldest <= cutoff:
break
log.info(f"{ticker}: 완료 — 신규 {total:,}행 (배치 {batch_n}회)")
return total
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--tickers', nargs='+', default=DEFAULT_TICKERS,
help='수집 티커 (예: KRW-BTC KRW-ETH)')
parser.add_argument('--days', type=int, default=730,
help='소급 일수 (기본 730일 = 2년)')
args = parser.parse_args()
cutoff = datetime.now() - timedelta(days=args.days)
log.info(f"=== 1분봉 히스토리 데몬 시작 ===")
log.info(f"대상: {args.tickers}")
log.info(f"목표: {cutoff.date()} ({args.days}일) 까지 소급")
conn = _get_conn()
grand_total = 0
t_start = time.time()
for ticker in args.tickers:
t0 = time.time()
try:
n = fetch_ticker(conn, ticker, cutoff)
grand_total += n
elapsed = time.time() - t0
log.info(f"{ticker}: {n:,}행 저장 ({elapsed/60:.1f}분)")
except Exception as e:
log.error(f"{ticker}: 오류 — {e}")
conn.close()
total_min = (time.time() - t_start) / 60
log.info(f"=== 완료: 총 {grand_total:,}행 / {total_min:.0f}분 ===")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,363 @@
"""실시간 1분봉 볼륨 가속 트레이더.
4봉 연속 가격+볼륨 가속 시그널(VOL≥8x) 감지 후 실제 매수/매도 + Telegram 알림.
실행:
.venv/bin/python3 daemons/live_trader.py
로그:
/tmp/live_trader.log
"""
import sys, os, time, logging, requests
from datetime import datetime
from typing import Optional
import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
]
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
FETCH_BARS = 100
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030 # 3.0% (ATR 계산용, ⑤ trail에서는 미사용)
ATR_MAX_R = 0.050 # 5.0%
# ── Cascade 청산 파라미터 ──────────────────────────────────────────────────────
# (시작분, 종료분, limit 수익률)
CASCADE_STAGES = [
(0, 2, 0.020), # ① 0~ 2분: 현재가 >= 진입가×1.020 → 청산
(2, 5, 0.010), # ② 2~ 5분: 현재가 >= 진입가×1.010 → 청산
(5, 35, 0.005), # ③ 5~35분: 현재가 >= 진입가×1.005 → 청산
(35, 155, 0.001), # ④ 35~155분: 현재가 >= 진입가×1.001 → 청산 (본전)
]
TRAIL_STOP_R = 0.004 # ⑤ 155분~: Trail Stop 0.4%
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
FEE = 0.0005
POLL_SEC = 65
API_DELAY = 0.12
TIMEOUT_BARS = 240 # 4시간: ⑤ Trail 구간에서 본전 이하 시 청산
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
# ── Upbit 클라이언트 ───────────────────────────────────────────────────────────
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
# ── Telegram ──────────────────────────────────────────────────────────────────
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
def tg(msg: str) -> None:
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
requests.post(
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
timeout=5,
)
except Exception as e:
log.warning(f'Telegram 전송 실패: {e}')
# ── 로깅 ──────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/live_trader.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
def compute_indicators(df: pd.DataFrame) -> pd.DataFrame:
vol_ma = df['volume'].rolling(VOL_LOOKBACK, min_periods=30).mean().shift(2)
df = df.copy()
df['vr'] = df['volume'] / vol_ma
prev_close = df['close'].shift(1)
tr = pd.concat([
df['high'] - df['low'],
(df['high'] - prev_close).abs(),
(df['low'] - prev_close).abs(),
], axis=1).max(axis=1)
df['atr_raw'] = tr.rolling(ATR_LOOKBACK, min_periods=10).mean() / prev_close
return df
def check_signal(df: pd.DataFrame) -> Optional[dict]:
"""마지막 3봉(완성봉) 가격+볼륨 가속 조건 체크."""
if len(df) < VOL_LOOKBACK + 10:
return None
b = df.iloc[-4:-1] # 완성된 마지막 3봉
if len(b) < 3:
return None
c = b['close'].values
o = b['open'].values
vr = b['vr'].values
if not all(c[i] > o[i] for i in range(3)): return None # 양봉
if not (c[2] > c[1] > c[0]): return None # 가격 가속
if not (vr[2] > vr[1] > vr[0]): return None # 볼륨 가속
if vr[2] < VOL_MIN: return None # VOL 임계값
atr_raw = float(b['atr_raw'].iloc[-1]) if not pd.isna(b['atr_raw'].iloc[-1]) else 0.0
return {
'sig_ts': b.index[-1],
'sig_price': float(c[2]),
'vr': list(vr),
'prices': list(c),
'atr_raw': atr_raw,
}
# ── 주문 ──────────────────────────────────────────────────────────────────────
def do_buy(ticker: str, krw_amount: int) -> Optional[float]:
"""시장가 매수. 실제 체결 수량 반환. 실패 시 None."""
if SIM_MODE:
current = pyupbit.get_current_price(ticker)
qty = krw_amount * (1 - FEE) / current
log.info(f"[SIM 매수] {ticker} {krw_amount:,}원 → {qty:.6f}개 @ {current:,.0f}")
return qty
try:
krw_bal = upbit.get_balance("KRW")
if krw_bal is None or krw_bal < krw_amount:
log.warning(f"KRW 잔고 부족: {krw_bal:,.0f}원 < {krw_amount:,}")
return None
order = upbit.buy_market_order(ticker, krw_amount)
if not order or 'error' in str(order):
log.error(f"매수 주문 실패: {order}")
return None
# 체결 대기 후 실제 보유량 조회
time.sleep(1.5)
coin = ticker.split('-')[1]
qty = upbit.get_balance(coin)
log.info(f"[매수 완료] {ticker} {krw_amount:,}원 → {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
return qty if qty and qty > 0 else None
except Exception as e:
log.error(f"매수 오류 {ticker}: {e}")
return None
def do_sell(ticker: str, qty: float) -> Optional[float]:
"""시장가 매도. 체결가(추정) 반환. 실패 시 None."""
if SIM_MODE:
current = pyupbit.get_current_price(ticker)
log.info(f"[SIM 매도] {ticker} {qty:.6f}개 @ {current:,.0f}")
return current
try:
order = upbit.sell_market_order(ticker, qty)
if not order or 'error' in str(order):
log.error(f"매도 주문 실패: {order}")
return None
time.sleep(1.5)
current = pyupbit.get_current_price(ticker)
log.info(f"[매도 완료] {ticker} {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
return current
except Exception as e:
log.error(f"매도 오류 {ticker}: {e}")
return None
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
positions: dict = {}
def enter_position(ticker: str, sig: dict, entry_price: float) -> None:
ar = sig['atr_raw']
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
qty = do_buy(ticker, PER_POS)
if qty is None:
log.warning(f"[진입 실패] {ticker} — 매수 주문 오류")
return
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
'vr': sig['vr'],
'prices': sig['prices'],
}
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {sig['vr'][2]:.1f}x cascade①2%②1%③0.5%④0.1%⑤trail0.4%")
tg(
f"🟢 <b>매수 완료</b> {ticker}\n"
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n"
f"전략: ①2% ②1% ③0.5% ④0.1%(본전) ⑤Trail0.4%\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
def _do_exit(ticker: str, current_price: float, reason: str) -> bool:
"""공통 청산 처리. reason: 'trail' | 'timeout'"""
pos = positions[ticker]
exit_price = do_sell(ticker, pos['qty'])
if exit_price is None:
exit_price = current_price
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
icon = "" if pnl > 0 else "🔴"
reason_tag = {
'①2%': '① +2.0% 익절',
'②1%': '② +1.0% 익절',
'③0.5%': '③ +0.5% 익절',
'④0.1%': '④ +0.1% 본전',
'⑤trail': '⑤ 트레일스탑',
'timeout': '⑤ 타임아웃',
}.get(reason, reason)
msg = (
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
f"진입: {pos['entry_price']:,.0f}\n"
f"고점: {pos['running_peak']:,.0f}원 ({(pos['running_peak']/pos['entry_price']-1)*100:+.2f}%)\n"
f"청산: {exit_price:,.0f}\n"
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}분 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
log.info(
f"[청산/{reason}] {ticker} {exit_price:,.0f}"
f"PNL {pnl:+.2f}% {krw:+,.0f}{held}분 보유"
)
tg(msg)
del positions[ticker]
return True
def update_position(ticker: str, current_price: float) -> bool:
"""Cascade 청산 체크. 청산 시 True 반환.
① 0~ 2분: +2.0% limit
② 2~ 5분: +1.0% limit
③ 5~35분: +0.5% limit
④ 35~155분: +0.1% limit (본전)
⑤ 155분~: Trail Stop 0.4%
"""
pos = positions[ticker]
ep = pos['entry_price']
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
# 항상 고점 갱신 (⑤ trail 진입 시 정확한 고점 기준)
pos['running_peak'] = max(pos['running_peak'], current_price)
# ①②③④: cascade limit 단계
stage_labels = {0: '①2%', 2: '②1%', 5: '③0.5%', 35: '④0.1%'}
for start, end, lr in CASCADE_STAGES:
if start <= held < end:
if current_price >= ep * (1 + lr):
return _do_exit(ticker, current_price, stage_labels[start])
return False
# ⑤: Trail Stop 0.4%
drop = (pos['running_peak'] - current_price) / pos['running_peak']
if drop >= TRAIL_STOP_R:
return _do_exit(ticker, current_price, '⑤trail')
# 타임아웃: 4시간 경과 + 본전 이하
if held >= TIMEOUT_BARS and current_price <= ep:
return _do_exit(ticker, current_price, 'timeout')
return False
# ── 메인 루프 ─────────────────────────────────────────────────────────────────
def run_once() -> None:
for ticker in TICKERS:
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=FETCH_BARS)
time.sleep(API_DELAY)
except Exception as e:
log.warning(f"{ticker} API 오류: {e}")
continue
if df is None or len(df) < 30:
continue
df = compute_indicators(df)
current_price = float(df['close'].iloc[-1])
# 열린 포지션: trail stop 체크
if ticker in positions:
update_position(ticker, current_price)
continue
# 신규 진입: 시그널 체크 (슬롯 여부와 관계없이 항상 탐지)
sig = check_signal(df)
if sig:
vr = sig['vr']
pr = sig['prices']
slot_tag = f"→ 매수 진행" if len(positions) < MAX_POS else f"⚠️ 슬롯 {len(positions)}/{MAX_POS} 꽉 참"
# 시그널 감지 즉시 알림
tg(
f"🔔 <b>시그널</b> {ticker}\n"
f"가격: {pr[0]:,.0f}{pr[1]:,.0f}{pr[2]:,.0f}\n"
f"볼륨: {vr[0]:.1f}x→{vr[1]:.1f}x→{vr[2]:.1f}x\n"
f"현재가: {current_price:,.0f}원 ATR: {sig['atr_raw']*100:.2f}%\n"
f"{slot_tag}"
)
log.info(f"[시그널] {ticker} {current_price:,.0f}원 vol {vr[2]:.1f}x {slot_tag}")
if len(positions) < MAX_POS:
enter_position(ticker, sig, current_price)
pos_str = ', '.join(
f"{t}({p['entry_price']:,.0f}{p['running_peak']:,.0f}, {((p['running_peak']/p['entry_price'])-1)*100:+.1f}%)"
for t, p in positions.items()
) or "없음"
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS}: {pos_str}")
def main():
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
log.info(f"=== 실시간 트레이더 시작 ({mode}) ===")
log.info(f"전략: 3봉 vol가속 VOL≥{VOL_MIN}x, cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%")
log.info(f"종목: {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}")
tg(
f"🚀 <b>트레이더 시작</b> ({mode})\n"
f"3봉 VOL≥{VOL_MIN}x cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%\n"
f"종목 {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}"
)
while True:
t0 = time.time()
try:
run_once()
except Exception as e:
log.error(f"루프 오류: {e}")
elapsed = time.time() - t0
sleep = max(1.0, POLL_SEC - elapsed)
log.info(f"[대기] {sleep:.0f}초 후 다음 체크")
time.sleep(sleep)
if __name__ == '__main__':
main()

View File

@@ -13,7 +13,7 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("trading.log", encoding="utf-8"),
logging.FileHandler("logs/trading.log", encoding="utf-8"),
],
)

View File

@@ -17,6 +17,9 @@
캐시: Oracle ADB backtest_ohlcv / backtest_daily / backtest_trades 테이블
시뮬레이션 결과는 backtest_trades에 파라미터 해시로 저장 동일 파라미터 재실행 즉시 반환
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import os

View File

@@ -0,0 +1,250 @@
"""백테스트 결과 Oracle DB 저장 모듈.
테이블:
backtest_runs - 실행 단위 (실행시각, 설명, 파라미터)
backtest_results - 조건별 집계 (run_id + label)
backtest_trade_log - 개별 거래 (run_id + label + 종목 + pnl + fng + ...)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Generator
import oracledb
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
_pool: oracledb.ConnectionPool | None = None
def _get_pool() -> oracledb.ConnectionPool:
global _pool
if _pool is None:
kwargs: dict = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
min=1,
max=3,
increment=1,
)
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
_pool = oracledb.create_pool(**kwargs)
return _pool
@contextmanager
def _conn() -> Generator[oracledb.Connection, None, None]:
pool = _get_pool()
conn = pool.acquire()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.release(conn)
# ── DDL ────────────────────────────────────────────────────────
_DDL_RUNS = """
CREATE TABLE backtest_runs (
run_id VARCHAR2(36) DEFAULT SYS_GUID() PRIMARY KEY,
run_name VARCHAR2(200) NOT NULL,
description VARCHAR2(1000),
params_json CLOB,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""
_DDL_RESULTS = """
CREATE TABLE backtest_results (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
label VARCHAR2(100) NOT NULL,
n_trades NUMBER,
win_rate NUMBER(6,3),
avg_pnl NUMBER(10,4),
total_pnl NUMBER(12,4),
rr NUMBER(8,4),
avg_win NUMBER(10,4),
avg_loss NUMBER(10,4),
max_dd NUMBER(10,4),
fng_lo NUMBER,
fng_hi NUMBER,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT fk_br_run FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id)
)
"""
_DDL_TRADES = """
CREATE TABLE backtest_trade_log (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
label VARCHAR2(100),
ticker VARCHAR2(20),
pnl NUMBER(10,4),
hold_h NUMBER,
fng_val NUMBER,
exit_type VARCHAR2(10),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT fk_bt_run FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id)
)
"""
def ensure_tables() -> None:
"""백테스트 테이블이 없으면 생성."""
with _conn() as conn:
cur = conn.cursor()
for tbl_name, ddl in [
("BACKTEST_RUNS", _DDL_RUNS),
("BACKTEST_RESULTS", _DDL_RESULTS),
("BACKTEST_TRADE_LOG", _DDL_TRADES),
]:
cur.execute(
"SELECT COUNT(*) FROM user_tables WHERE table_name=:1", [tbl_name]
)
if cur.fetchone()[0] == 0:
cur.execute(ddl)
print(f" {tbl_name} 테이블 생성 완료")
# ── 삽입 헬퍼 ──────────────────────────────────────────────────
def insert_run(run_name: str, description: str = "", params: dict | None = None) -> str:
"""새 백테스트 실행 레코드 삽입. run_id 반환."""
sql = """
INSERT INTO backtest_runs (run_name, description, params_json)
VALUES (:rname, :rdesc, :rparams)
RETURNING run_id INTO :out_id
"""
with _conn() as conn:
cur = conn.cursor()
out = cur.var(oracledb.STRING)
cur.execute(sql, {
"rname": run_name,
"rdesc": description,
"rparams": json.dumps(params or {}, ensure_ascii=False),
"out_id": out,
})
return out.getvalue()[0]
def insert_result(
run_id: str,
label: str,
stats: dict,
fng_lo: int | None = None,
fng_hi: int | None = None,
) -> None:
"""조건별 집계 결과 삽입."""
sql = """
INSERT INTO backtest_results
(run_id, label, n_trades, win_rate, avg_pnl, total_pnl,
rr, avg_win, avg_loss, max_dd, fng_lo, fng_hi)
VALUES
(:run_id, :label, :n, :wr, :avg_pnl, :total_pnl,
:rr, :avg_win, :avg_loss, :max_dd, :fng_lo, :fng_hi)
"""
with _conn() as conn:
conn.cursor().execute(sql, {
"run_id": run_id,
"label": label,
"n": stats.get("n", 0),
"wr": round(stats.get("wr", 0), 3),
"avg_pnl": round(stats.get("avg_pnl", 0), 4),
"total_pnl": round(stats.get("total_pnl", 0), 4),
"rr": round(stats.get("rr", 0), 4),
"avg_win": round(stats.get("avg_win", 0), 4),
"avg_loss": round(stats.get("avg_loss", 0), 4),
"max_dd": round(stats.get("max_dd", 0), 4),
"fng_lo": fng_lo,
"fng_hi": fng_hi,
})
def insert_trades_bulk(
run_id: str,
label: str,
ticker: str,
trades: list,
) -> None:
"""개별 거래 목록 일괄 삽입."""
if not trades:
return
sql = """
INSERT INTO backtest_trade_log
(run_id, label, ticker, pnl, hold_h, fng_val, exit_type)
VALUES (:run_id, :label, :ticker, :pnl, :hold_h, :fng_val, :exit_type)
"""
rows = []
for t in trades:
rows.append({
"run_id": run_id,
"label": label,
"ticker": ticker,
"pnl": round(float(getattr(t, "pnl", 0)), 4),
"hold_h": int(getattr(t, "h", 0)),
"fng_val": int(getattr(t, "fng", 0)),
"exit_type": str(getattr(t, "exit", "")),
})
with _conn() as conn:
conn.cursor().executemany(sql, rows)
# ── 조회 ───────────────────────────────────────────────────────
def list_runs(limit: int = 20) -> list[dict]:
"""최근 백테스트 실행 목록 반환."""
sql = """
SELECT run_id, run_name, description, created_at
FROM backtest_runs
ORDER BY created_at DESC
FETCH FIRST :n ROWS ONLY
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"n": limit})
rows = cur.fetchall()
return [
{"run_id": r[0], "run_name": r[1], "description": r[2],
"created_at": r[3].strftime("%Y-%m-%d %H:%M")}
for r in rows
]
def get_results(run_id: str) -> list[dict]:
"""특정 run_id의 조건별 결과 반환."""
sql = """
SELECT label, n_trades, win_rate, avg_pnl, total_pnl,
rr, avg_win, avg_loss, max_dd, fng_lo, fng_hi
FROM backtest_results
WHERE run_id = :run_id
ORDER BY avg_pnl DESC
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"run_id": run_id})
cols = ["label", "n_trades", "win_rate", "avg_pnl", "total_pnl",
"rr", "avg_win", "avg_loss", "max_dd", "fng_lo", "fng_hi"]
return [dict(zip(cols, r)) for r in cur.fetchall()]
if __name__ == "__main__":
print("백테스트 DB 테이블 확인/생성...")
ensure_tables()
print("완료. 최근 실행 목록:")
for r in list_runs(5):
print(f" {r['created_at']} {r['run_name']}")

View File

@@ -0,0 +1,360 @@
"""최근 N일 볼륨 가속 시그널 확인 + ATR trail stop 시뮬레이션.
4봉 연속 가격+볼륨 가속 시그널 발생 후 실제 trail stop 로직을 돌려
진입가·청산가·PNL을 표시.
최적 파라미터 (sweep_volaccel 기준):
N봉=4, VOL≥5.0x, ATR_MULT=1.5, ATR_MIN=1.5%, ATR_MAX=2.0%
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
# ── 파라미터 ──────────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
LOOKBACK_DAYS = 3 # 오늘 기준 며칠 전까지 시그널 탐색
VOL_MIN_LIST = [8.0]
VOL_MIN = min(VOL_MIN_LIST)
# ATR 비교 목록: (MULT, MIN%, MAX%) — 타이트→여유 순
ATR_SCENARIOS = [
(1.0, 0.010, 0.020), # 타이트: 1.0×ATR, 1.0~2.0%
(1.5, 0.015, 0.025), # 기존최적: 1.5×ATR, 1.5~2.5%
(1.0, 0.020, 0.030), # 고정 2.0~3.0%
(1.0, 0.030, 0.050), # 느슨: 3.0~5.0%
]
ATR_MULT = ATR_SCENARIOS[0][0]
ATR_MIN_R = ATR_SCENARIOS[0][1]
ATR_MAX_R = ATR_SCENARIOS[0][2]
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
# ── 시그널 SQL (4봉 가속 조건 전부 Oracle SQL에서 처리) ───────────────────────
SIGNAL_SQL = f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1,
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1,
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1,
LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2,
LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2,
LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2,
LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3,
LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3,
LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3,
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr0,
prev_vol_1 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr1,
prev_vol_2 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr2,
prev_vol_3 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr3,
prev_close_1, prev_open_1,
prev_close_2, prev_open_2,
prev_close_3, prev_open_3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
)
SELECT ticker, ts sig_ts, close_p sig_price,
vr0, vr1, vr2, vr3, atr_raw,
prev_close_3, prev_close_2, prev_close_1, close_p
FROM indicators
WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= :min_vol
-- 4봉 연속: 양봉
AND close_p > open_p
AND prev_close_1 > prev_open_1
AND prev_close_2 > prev_open_2
AND prev_close_3 > prev_open_3
-- 4봉 연속: 가격 가속
AND close_p > prev_close_1
AND prev_close_1 > prev_close_2
AND prev_close_2 > prev_close_3
-- 4봉 연속: 볼륨 가속
AND vr0 > vr1
AND vr1 > vr2
AND vr2 > vr3
ORDER BY ticker, ts
"""
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def simulate_trail(cur, ticker: str, entry_ts, entry_price: float,
atr_raw: float,
mult: float = None, min_r: float = None, max_r: float = None) -> dict:
"""entry_ts 봉부터 trail stop 시뮬. 상세 결과 dict 반환."""
m = mult if mult is not None else ATR_MULT
n = min_r if min_r is not None else ATR_MIN_R
x = max_r if max_r is not None else ATR_MAX_R
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
atr_stop = max(n, min(x, ar * m)) if ar > 0 else x
cur.execute(
"""SELECT ts, close_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry
ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1}
)
bars = cur.fetchall()
if not bars:
return dict(status="데이터없음", exit_ts=entry_ts, exit_price=entry_price,
peak=entry_price, peak_pct=0.0, pnl=0.0, atr_stop=atr_stop,
held_bars=0)
running_peak = entry_price
for i, (ts, close_p) in enumerate(bars):
close_p = float(close_p)
running_peak = max(running_peak, close_p)
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
if drop >= atr_stop:
pnl = (close_p - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
return dict(status="손절", exit_ts=ts, exit_price=close_p,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, atr_stop=atr_stop, held_bars=i + 1)
last_ts, last_price = bars[-1][0], float(bars[-1][1])
pnl = (last_price - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, atr_stop=atr_stop, held_bars=len(bars))
def apply_pos_limit(sim_results: list, vol_thr: float) -> tuple:
"""VOL 필터 + MAX_POS 동시 포지션 제한 적용. (taken, skipped) 반환."""
filtered = [r for r in sim_results if r['vr'][3] >= vol_thr]
open_positions = []
taken, skipped = [], []
for r in filtered:
open_positions = [ex for ex in open_positions if ex > r['entry_ts']]
if len(open_positions) < MAX_POS:
open_positions.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def print_detail(vol_thr: float, taken: list, skipped: list):
div = "" * 120
print(f"\n{''*120}")
print(f" 4봉 VOL≥{vol_thr:.0f}x | ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%] "
f"| 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}")
print(f"{''*120}\n")
total_krw, total_wins = 0.0, 0
for i, r in enumerate(taken, 1):
vr = r['vr']
entry_str = str(r['entry_ts'])[:16]
exit_str = str(r['exit_ts'])[:16]
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
total_krw += krw
won = r['pnl'] > 0
if won:
total_wins += 1
pr = r['prices']
# 봉별 가격 변화율 (봉1→2, 2→3, 3→4)
pchg = [
(pr[1] - pr[0]) / pr[0] * 100 if pr[0] else 0,
(pr[2] - pr[1]) / pr[1] * 100 if pr[1] else 0,
(pr[3] - pr[2]) / pr[2] * 100 if pr[2] else 0,
]
sign = "" if won else ""
print(f" #{i:02d} {r['ticker']:12s} [{sign}]")
print(f" 가격흐름 {pr[0]:,.0f}{pr[1]:,.0f} ({pchg[0]:+.2f}%)"
f"{pr[2]:,.0f} ({pchg[1]:+.2f}%)"
f"{pr[3]:,.0f} ({pchg[2]:+.2f}%)")
print(f" 볼륨흐름 {vr[0]:.1f}x → {vr[1]:.1f}x → {vr[2]:.1f}x → {vr[3]:.1f}x "
f"(ATR손절 {r['atr_stop']*100:.1f}%)")
print(f" 진입 {entry_str} @ {r['entry_price']:>13,.0f}")
print(f" 고점 {r['peak']:>13,.0f}원 ({r['peak_pct']:>+.2f}%까지 상승)")
print(f" 청산 {exit_str} @ {r['exit_price']:>13,.0f}"
f" ({r['status']}, {r['held_bars']}봉 보유)")
print(f" 손익 {r['pnl']:>+.2f}% → {krw:>+,.0f}")
print()
if skipped:
print(f" ── 스킵 {len(skipped)}건 (포지션 한도 초과) ──")
for r in skipped:
vr = r['vr']
krw_if = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
print(f" SKIP {r['ticker']:12s} 진입 {str(r['entry_ts'])[:16]} "
f"vol {vr[3]:.1f}x → {r['pnl']:>+.2f}% ({krw_if:>+,.0f}원 기회손실)")
print()
n = len(taken)
win_rate = total_wins / n * 100 if n else 0
ret_rate = total_krw / BUDGET * 100
print(div)
print(f" ◆ 실거래 {n}건 | 승률 {win_rate:.0f}% ({total_wins}/{n}) | "
f"합산 {total_krw:>+,.0f}원 | 수익률 {ret_rate:>+.2f}%")
print(div)
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
print(f"=== 4봉 볼륨 가속 시뮬 — VOL {VOL_MIN_LIST} 비교 ===")
print(f"ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%], "
f"자본 {BUDGET/10000:.0f}만원 / 포지션당 {PER_POS/10000:.0f}만원 / 동시 {MAX_POS}")
print(f"조회 기간: {check_since[:10]} ~ 현재\n")
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 10_000
# ── 1회 조회 (VOL_MIN = 가장 낮은 값) ────────────────────────────────────────
cur.execute(SIGNAL_SQL, {
"warmup_since": warmup_since,
"check_since": check_since,
"min_vol": VOL_MIN,
})
rows = cur.fetchall()
if not rows:
print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음")
conn.close()
return
# 진입봉 조회
signals = []
for row in rows:
ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \
p3, p2, p1, p0 = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{"t": ticker, "sig": sig_ts}
)
entry_row = cur.fetchone()
if not entry_row:
continue
entry_price, entry_ts = float(entry_row[0]), entry_row[1]
signals.append({
'ticker': ticker,
'sig_ts': sig_ts,
'entry_ts': entry_ts,
'entry_price': entry_price,
'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]],
'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]],
'atr_raw': float(atr_raw) if atr_raw else 0.0,
})
if not signals:
print("진입봉을 찾을 수 없음")
conn.close()
return
# ── trail stop 시뮬 — 시나리오별 ─────────────────────────────────────────────
signals.sort(key=lambda x: x['entry_ts'])
vol_thr = VOL_MIN_LIST[0]
# 각 시나리오에 맞는 bars를 재사용하기 위해 기본(가장 느슨한) 시나리오로 bars 캐시
# (atr_stop 달라도 bars 조회는 동일하므로 1회만 fetch)
print(f"시그널 {len(signals)}건 trail stop 시뮬 중 ({len(ATR_SCENARIOS)}가지 ATR 비교)...", flush=True)
# 시나리오별 결과 수집
scenario_results = []
for mult, min_r, max_r in ATR_SCENARIOS:
sim = []
for s in signals:
trail = simulate_trail(cur, s['ticker'], s['entry_ts'], s['entry_price'],
s['atr_raw'], mult=mult, min_r=min_r, max_r=max_r)
sim.append({**s, **trail})
taken, skipped = apply_pos_limit(sim, vol_thr)
scenario_results.append((mult, min_r, max_r, taken, skipped))
# ── 시나리오 요약 비교표 ──────────────────────────────────────────────────────
print(f"\n{''*95}")
print(f" {'ATR 설정':22s} {'거래':>4s} {'승률':>5s} {'합산손익':>12s} {'수익률':>6s} {'평균보유(분)':>10s}")
print(f"{''*95}")
best_krw = max(sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken)
for _, _, _, taken, _ in scenario_results)
for mult, min_r, max_r, taken, skipped in scenario_results:
n = len(taken)
if n == 0:
continue
wins = sum(1 for r in taken if r['pnl'] > 0)
total = sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken)
avg_held = sum(r['held_bars'] for r in taken) / n
ret = total / BUDGET * 100
label = f"{mult:.1f}×ATR [{min_r*100:.1f}~{max_r*100:.1f}%]"
star = "" if abs(total - best_krw) < 1 else ""
print(f" {label:22s} {n:>4d}{wins/n*100:>4.0f}% {total:>+12,.0f}{ret:>+5.2f}%"
f" {avg_held:>6.0f}{star}")
print(f"{''*95}")
# ── 최적 시나리오 건별 상세 출력 ─────────────────────────────────────────────
best_idx = max(range(len(scenario_results)),
key=lambda i: sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2
for r in scenario_results[i][3]))
bm, bn, bx, best_taken, best_skipped = scenario_results[best_idx]
print(f"\n[최적 시나리오 건별 상세: {bm:.1f}×ATR {bn*100:.1f}~{bx*100:.1f}%]")
print_detail(vol_thr, best_taken, best_skipped)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,153 @@
"""1년치 데이터 수집 — 10분봉 OHLCV + F&G 히스토리.
생성 파일:
data/sim1y_cache.pkl — {"10m": {ticker: DataFrame}} (10분봉, 365일)
data/fng_1y.json — {"YYYY-MM-DD": int, ...} (Fear & Greed 1년치)
소요 시간: 약 10~15분 (20종목 × 263 API 호출)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
import time
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
import pandas as pd
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
# ── 설정 ─────────────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP30_FILE = Path(__file__).parent.parent / "data" / "top30_tickers.pkl"
DAYS = 365
TOP_N = 20
# ── 10분봉 수집 ───────────────────────────────────────────
def fetch_10m(ticker: str, days: int) -> "pd.DataFrame | None":
target_start = datetime.now() - timedelta(days=days)
all_dfs, to, prev_oldest = [], None, None
while True:
kwargs = dict(ticker=ticker, interval="minute10", count=200)
if to:
kwargs["to"] = to.strftime("%Y-%m-%d %H:%M:%S")
try:
df = pyupbit.get_ohlcv(**kwargs)
except Exception:
time.sleep(0.5)
break
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
if oldest <= target_start:
break
to = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
return combined[combined.index >= target_start]
# ── F&G 1년치 수집 ────────────────────────────────────────
def fetch_fng(limit: int = 400) -> dict:
url = f"https://api.alternative.me/fng/?limit={limit}&format=json"
with urllib.request.urlopen(url, timeout=15) as r:
data = json.loads(r.read())
result = {}
for e in data["data"]:
dt = datetime.fromtimestamp(int(e["timestamp"]))
result[dt.strftime("%Y-%m-%d")] = int(e["value"])
return result
# ── 메인 ─────────────────────────────────────────────────
def main():
# ── 종목 목록 ─────────────────────────────────────────
try:
from core.market import get_top_tickers
tickers = get_top_tickers()[:TOP_N]
print(f"Top{TOP_N} 종목 API 조회: {tickers}\n")
# top30 파일 갱신
pickle.dump(tickers, open(TOP30_FILE, "wb"))
except Exception as e:
print(f" [경고] 종목 API 실패: {e}")
if TOP30_FILE.exists():
tickers = pickle.load(open(TOP30_FILE, "rb"))[:TOP_N]
print(f" 기존 top30 파일 사용: {tickers}\n")
else:
print(" [오류] 종목 목록 없음. 종료.")
return
# ── F&G 1년치 ─────────────────────────────────────────
print("F&G 1년치 수집...")
try:
fng_map = fetch_fng(limit=400)
sorted_dates = sorted(fng_map.keys())
print(f" 기간: {sorted_dates[0]} ~ {sorted_dates[-1]} ({len(fng_map)}일)")
# 분포
zones = {"극공포(≤25)": 0, "공포(26~40)": 0, "중립(41~55)": 0,
"탐욕(56~75)": 0, "극탐욕(76+)": 0}
for v in fng_map.values():
if v <= 25: zones["극공포(≤25)"] += 1
elif v <= 40: zones["공포(26~40)"] += 1
elif v <= 55: zones["중립(41~55)"] += 1
elif v <= 75: zones["탐욕(56~75)"] += 1
else: zones["극탐욕(76+)"] += 1
total = sum(zones.values())
for name, cnt in zones.items():
print(f" {name:12} {cnt:>3}일 ({cnt/total*100:.1f}%)")
json.dump(fng_map, open(FNG_FILE, "w"))
print(f" 저장: {FNG_FILE}\n")
except Exception as e:
print(f" [오류] F&G 수집 실패: {e}\n")
fng_map = {}
# ── 10분봉 1년치 ──────────────────────────────────────
print(f"10분봉 {DAYS}일치 수집 중 ({len(tickers)}종목)...")
print(f" 예상 소요: {len(tickers) * 265 * 0.12 / 60:.0f}~{len(tickers) * 265 * 0.15 / 60:.0f}\n")
data = {"10m": {}}
for i, ticker in enumerate(tickers, 1):
start_t = time.time()
df = fetch_10m(ticker, DAYS)
elapsed = time.time() - start_t
if df is not None and len(df) > 500:
data["10m"][ticker] = df
candles = len(df)
period = f"{df.index[0].strftime('%Y-%m-%d')}~{df.index[-1].strftime('%Y-%m-%d')}"
print(f" {i:>2}/{len(tickers)} {ticker:<15} {candles:>6}{period} ({elapsed:.0f}s)")
else:
print(f" {i:>2}/{len(tickers)} {ticker:<15} 데이터 부족 ({elapsed:.0f}s)")
time.sleep(0.15)
# ── 저장 ──────────────────────────────────────────────
print(f"\n수집 완료: {len(data['10m'])}종목")
if data["10m"]:
sample = next(iter(data["10m"].values()))
print(f"기간: {sample.index[0].strftime('%Y-%m-%d')} ~ {sample.index[-1].strftime('%Y-%m-%d')}")
print(f"봉 수: {len(sample)}개 (10분봉)")
# 파일 크기 추정
import sys
size_mb = sys.getsizeof(pickle.dumps(data)) / 1024 / 1024
print(f"예상 크기: {size_mb:.1f} MB")
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE}")
print("완료!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,323 @@
"""3% 고정 익절 vs 현재 trail stop 전략 비교 시뮬레이션.
전략:
A. Trail Stop only : ATR 1.0×[3.0~5.0%]
B. TP 3% + Trail Stop 손절 : +3% 도달 시 익절, 못 도달하면 trail stop
C. TP 2% + Trail Stop 손절 : +2% 도달 시 익절
D. TP 5% + Trail Stop 손절 : +5% 도달 시 익절
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
# ── 파라미터 ──────────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
LOOKBACK_DAYS = 3
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
SIGNAL_SQL = f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1,
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1,
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1,
LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2,
LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2,
LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2,
LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3,
LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3,
LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3,
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr0,
prev_vol_1 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr1,
prev_vol_2 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr2,
prev_vol_3 / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vr3,
prev_close_1, prev_open_1,
prev_close_2, prev_open_2,
prev_close_3, prev_open_3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
)
SELECT ticker, ts sig_ts, close_p sig_price,
vr0, vr1, vr2, vr3, atr_raw,
prev_close_3, prev_close_2, prev_close_1, close_p
FROM indicators
WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= :min_vol
AND close_p > open_p
AND prev_close_1 > prev_open_1
AND prev_close_2 > prev_open_2
AND prev_close_3 > prev_open_3
AND close_p > prev_close_1
AND prev_close_1 > prev_close_2
AND prev_close_2 > prev_close_3
AND vr0 > vr1 AND vr1 > vr2 AND vr2 > vr3
ORDER BY ticker, ts
"""
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def _fetch_bars(cur, ticker, entry_ts):
"""진입 시점 이후 봉 데이터 조회 (캐시용)."""
cur.execute(
"""SELECT ts, close_p, high_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry
ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1}
)
return [(ts, float(cp), float(hp)) for ts, cp, hp in cur.fetchall()]
def simulate_trail_only(bars, entry_price, atr_raw):
"""전략 A: Trail Stop만 사용."""
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
running_peak = entry_price
for i, (ts, close_p, _) in enumerate(bars):
running_peak = max(running_peak, close_p)
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
if drop >= atr_stop:
pnl = (close_p - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
return dict(status="트레일손절", exit_ts=ts, exit_price=close_p,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=i + 1)
last_ts, last_price = bars[-1][0], bars[-1][1]
pnl = (last_price - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=len(bars))
def simulate_tp_trail(bars, entry_price, atr_raw, tp_r):
"""전략 B/C/D: 고정 익절 + Trail Stop 손절.
먼저 +tp_r% 도달하면 익절.
못 도달하고 trail stop 걸리면 손절.
"""
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp_price = entry_price * (1 + tp_r)
running_peak = entry_price
for i, (ts, close_p, high_p) in enumerate(bars):
# 고가로 익절 체크 (봉 내 고가가 익절가 도달하면 익절)
if high_p >= tp_price:
pnl = tp_r * 100
peak_pct = (max(running_peak, high_p) - entry_price) / entry_price * 100
return dict(status=f"익절+{tp_r*100:.0f}%", exit_ts=ts, exit_price=tp_price,
peak=max(running_peak, high_p), peak_pct=peak_pct,
pnl=pnl, held_bars=i + 1)
running_peak = max(running_peak, close_p)
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
if drop >= atr_stop:
pnl = (close_p - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
return dict(status="트레일손절", exit_ts=ts, exit_price=close_p,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=i + 1)
last_ts, last_price = bars[-1][0], bars[-1][1]
pnl = (last_price - entry_price) / entry_price * 100
peak_pct = (running_peak - entry_price) / entry_price * 100
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
peak=running_peak, peak_pct=peak_pct,
pnl=pnl, held_bars=len(bars))
def apply_pos_limit(sim_results):
open_positions = []
taken, skipped = [], []
for r in sim_results:
open_positions = [ex for ex in open_positions if ex > r['entry_ts']]
if len(open_positions) < MAX_POS:
open_positions.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def print_summary(label, taken):
n = len(taken)
if n == 0:
print(f" {label:35s} 거래없음")
return
wins = sum(1 for r in taken if r['pnl'] > 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held_bars'] for r in taken) / n
ret = total / BUDGET * 100
print(f" {label:35s} {n:>3d}{wins/n*100:>4.0f}% {total:>+12,.0f}{ret:>+5.2f}% {avg_h:>5.0f}")
def print_detail(label, taken):
print(f"\n{''*110}")
print(f" {label}")
print(f"{''*110}")
for i, r in enumerate(taken, 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = "" if r['pnl'] > 0 else ""
print(f" #{i:02d} {r['ticker']:12s} [{sign}] 진입 {str(r['entry_ts'])[:16]} "
f"고점 {r['peak_pct']:>+.2f}% → {r['status']} {r['held_bars']}"
f"PNL {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
print(f"=== 고정 익절 vs Trail Stop 비교 시뮬 ===")
print(f"기간: {check_since[:10]} ~ 현재 | 4봉 VOL≥{VOL_MIN}x | "
f"손절: ATR {ATR_MULT}×[{ATR_MIN_R*100:.0f}~{ATR_MAX_R*100:.0f}%]")
print(f"자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}\n")
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 10_000
cur.execute(SIGNAL_SQL, {
"warmup_since": warmup_since,
"check_since": check_since,
"min_vol": VOL_MIN,
})
rows = cur.fetchall()
if not rows:
print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음")
conn.close()
return
# 진입봉 + bars 수집
signals = []
for row in rows:
ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \
p3, p2, p1, p0 = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{"t": ticker, "sig": sig_ts}
)
entry_row = cur.fetchone()
if not entry_row:
continue
entry_price, entry_ts = float(entry_row[0]), entry_row[1]
bars = _fetch_bars(cur, ticker, entry_ts)
if not bars:
continue
signals.append({
'ticker': ticker,
'entry_ts': entry_ts,
'entry_price': entry_price,
'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]],
'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]],
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
print(f"시그널 {len(signals)}건 → 전략별 시뮬 중...\n")
# ── 전략별 시뮬 ───────────────────────────────────────────────────────────
strategies = [
("A. Trail Stop [3~5%]", None),
("B. 익절 2% + Trail Stop", 0.02),
("C. 익절 3% + Trail Stop", 0.03),
("D. 익절 5% + Trail Stop", 0.05),
]
results = {}
for label, tp_r in strategies:
sim = []
for s in signals:
if tp_r is None:
r = simulate_trail_only(s['bars'], s['entry_price'], s['atr_raw'])
else:
r = simulate_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
sim.append({**s, **r})
taken, _ = apply_pos_limit(sim)
results[label] = taken
# ── 요약 비교표 ───────────────────────────────────────────────────────────
print(f"{''*95}")
print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s}")
print(f"{''*95}")
for label, taken in results.items():
print_summary(label, taken)
print(f"{''*95}")
# ── 전략별 건별 상세 ──────────────────────────────────────────────────────
for label, taken in results.items():
print_detail(label, taken)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,131 @@
"""1년치 1분봉 OHLCV → BACKTEST_OHLCV (수동 페이지네이션).
pyupbit이 count>5000에서 실패하므로 to 파라미터로 직접 페이지네이션.
실행: .venv/bin/python3 tests/fetch_1y_minute1.py
예상 소요: 20종목 × ~15분 = ~5시간 (overnight 실행 권장)
"""
import sys, os, time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
from datetime import datetime, timedelta
import pandas as pd
ALL_TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', # 그룹 1
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', # 그룹 2
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', # 그룹 3
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', # 그룹 4
]
# 실행: python fetch_1y_minute1.py [그룹번호 1-4]
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('group', nargs='?', type=int, default=0,
help='1-4: 해당 그룹만 실행, 0: 전체')
args, _ = parser.parse_known_args()
if args.group in (1,2,3,4):
TICKERS = ALL_TICKERS[(args.group-1)*5 : args.group*5]
print(f"그룹 {args.group} 실행: {TICKERS}")
else:
TICKERS = ALL_TICKERS
BATCH = 4000 # 한 번에 요청할 봉 수 (4000 = ~2.8일)
DELAY = 0.15 # API 딜레이 (초)
TARGET_DAYS = 365 # 목표 기간
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def insert_batch(conn, ticker, df) -> int:
"""DB에 없는 행만 삽입. 반환: 신규 건수."""
cur = conn.cursor()
min_ts = df.index.min().to_pydatetime()
max_ts = df.index.max().to_pydatetime()
cur.execute(
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd='minute1' "
"AND ts BETWEEN :s AND :e",
{"t": ticker, "s": min_ts, "e": max_ts}
)
existing = {r[0] for r in cur.fetchall()}
new_rows = [
(ticker, 'minute1', ts.to_pydatetime(),
float(r["open"]), float(r["high"]), float(r["low"]),
float(r["close"]), float(r["volume"]))
for ts, r in df.iterrows()
if ts.to_pydatetime() not in existing
]
if not new_rows:
return 0
cur.executemany(
"INSERT INTO backtest_ohlcv (ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,:2,:3,:4,:5,:6,:7,:8)",
new_rows
)
conn.commit()
return len(new_rows)
def fetch_ticker(conn, ticker) -> int:
"""ticker 1년치 1분봉 fetch → DB 저장. 반환: 총 신규 건수."""
cutoff = datetime.now() - timedelta(days=TARGET_DAYS)
to_dt = datetime.now()
total = 0
batch_no = 0
while to_dt > cutoff:
to_str = to_dt.strftime('%Y-%m-%d %H:%M:%S')
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str)
time.sleep(DELAY)
except Exception as e:
print(f" API 오류: {e} → 재시도")
time.sleep(2.0)
continue
if df is None or len(df) == 0:
break
n = insert_batch(conn, ticker, df)
total += n
batch_no += 1
oldest = df.index[0]
print(f" 배치{batch_no:03d}: {oldest.date()} ~ {df.index[-1].strftime('%m-%d')} "
f"({len(df)}봉, 신규 {n}) | 누적 {total:,}", flush=True)
# 다음 페이지: 이 배치에서 가장 오래된 봉 이전부터
to_dt = oldest - timedelta(minutes=1)
if oldest <= cutoff:
break
return total
conn = _get_conn()
grand_total = 0
start_time = time.time()
for idx, tk in enumerate(TICKERS, 1):
t0 = time.time()
print(f"\n[{idx:02d}/{len(TICKERS)}] {tk} 시작...", flush=True)
try:
n = fetch_ticker(conn, tk)
elapsed = time.time() - t0
grand_total += n
print(f" → 완료: 신규 {n:,}행 ({elapsed/60:.1f}분) | 전체 누적 {grand_total:,}", flush=True)
except Exception as e:
print(f" → 오류: {e}")
conn.close()
elapsed_total = time.time() - start_time
print(f"\n전체 완료: {grand_total:,}행 저장 ({elapsed_total/60:.0f}분 소요)")

View File

@@ -0,0 +1,318 @@
"""F&G 조건별 백테스트 - 1년치 데이터 (배치 수집)
60일 극공포 편향을 제거하고 Bull/Neutral/Bear 다양한 구간 포함.
데이터: 1h 캔들 배치 수집 → 약 365일치
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import datetime, json, time, sys, urllib.request
import pandas as pd
import pyupbit
from dataclasses import dataclass
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015
TIME_H = 24
TIME_MIN = 3.0
# ── 데이터 수집 ───────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
"""1h 캔들을 배치로 수집해 약 1년치 DataFrame 반환."""
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts):
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ────────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(df, fng_map, fng_lo=None, fng_hi=None) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= TIME_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_MIN:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > SIG_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=sum(t.pnl for t in trades) / len(trades),
total_pnl=sum(t.pnl for t in trades),
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
# F&G 연간 분포 출력
from collections import Counter
zone_cnt = Counter()
for v in fng_map.values():
if v <= 25: zone_cnt["극공포(0~25)"] += 1
elif v <= 45: zone_cnt["공포(26~45)"] += 1
elif v <= 55: zone_cnt["중립(46~55)"] += 1
elif v <= 75: zone_cnt["탐욕(56~75)"] += 1
else: zone_cnt["극탐욕(76~100)"] += 1
total_days = sum(zone_cnt.values())
print(f" 1년 F&G 분포 ({total_days}일):")
for k, v in sorted(zone_cnt.items()):
bar = "" * (v // 5)
print(f" {k:<14} {v:>3}일 ({v/total_days*100:>4.1f}%) {bar}")
print(f"\n종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 전체 기간 F&G 구간별 성과 ────────────────────────────
CONFIGS = [
(None, None, "필터 없음 (전체)"),
(None, 25, "극공포만 (0~25)"),
(26, 45, "공포만 (26~45)"),
(46, 55, "중립만 (46~55)"),
(56, 100, "탐욕+ (56~100)"),
(46, 100, "중립 이상 (46~100)"),
(26, 100, "공포 이상 (26~100)"),
]
print("=" * 78)
print(" F&G 조건별 성과 - 1년치 (1h 캔들 / 모멘텀 / 스탑1.5%)")
print("=" * 78)
print(f" {'조건':<26} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 72)
all_results = {}
for lo, hi, label in CONFIGS:
all_trades = []
for df in datasets.values():
all_trades.extend(simulate(df, fng_map, lo, hi))
s = stats(all_trades)
all_results[label] = (s, all_trades)
if s["n"] == 0:
print(f" {label:<26} 거래 없음 (해당 구간 진입 기회 없음)")
continue
sign = "+" if s["total_pnl"] > 0 else ""
print(
f" {label:<26} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{sign}{s['total_pnl']:>8.1f}% -{s['max_dd']:>5.1f}%"
)
# ── 분기별 성과 (계절성) ──────────────────────────────────
print()
print(" 분기별 성과 (전체 필터 없음 기준):")
base_trades = all_results["필터 없음 (전체)"][1]
for df in datasets.values():
pass # already computed
# 전체 종목 합산 후 날짜로 분기 분리
all_base = []
for df in datasets.values():
t_list = simulate(df, fng_map)
# trade에 날짜 정보 추가
# simulate에서 idx를 참조하지 않으므로 재계산
all_base.extend(t_list)
# F&G 수치별 세분화
print()
print(" F&G 10단위 구간별 세부 성과:")
print(f" {'구간':<16} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6} {'의미'}")
print(" " + "-" * 65)
fng_zones_detail = [
(0, 10, "극단 공포(0~10)"),
(11, 20, "극단 공포(11~20)"),
(21, 30, "극공포(21~30)"),
(31, 40, "공포(31~40)"),
(41, 50, "약공포(41~50)"),
(51, 60, "약탐욕(51~60)"),
(61, 75, "탐욕(61~75)"),
(76, 100, "극탐욕(76~100)"),
]
base_all = all_results["필터 없음 (전체)"][1]
for lo, hi, name in fng_zones_detail:
sub = [t for t in base_all if lo <= t.fng <= hi]
if not sub:
continue
s = stats(sub)
breakeven_wr = 1 / (1 + s["rr"]) * 100 if s["rr"] > 0 else 50
profitable = "✅ 수익" if s["avg_pnl"] > 0 else ("⚠️ BEP 근접" if s["avg_pnl"] > -0.2 else "❌ 손실")
print(
f" {name:<16} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f} {profitable}"
)
# ── 최적 F&G 구간 요약 ───────────────────────────────────
print()
best = max(
[(label, s) for label, (s, _) in all_results.items() if s["n"] >= 50],
key=lambda x: x[1]["avg_pnl"],
)
print(f" ★ 최적 구간: {best[0]} "
f"(거래 {best[1]['n']}건 | 승률 {best[1]['wr']:.1f}% | "
f"평균PnL {best[1]['avg_pnl']:+.3f}%)")
# ── DB 저장 ──────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
ensure_tables()
params = {
"tickers": len(datasets), "days": 365, "candle": "1h",
"trail_stop": 0.015, "mom_thr": 3.0, "vol_mult": 2.0,
}
run_id = insert_run(
run_name="fng_1y_backtest",
description="F&G 구간별 성과 1년치 백테스트 (1h 캔들 / 모멘텀 / 스탑1.5%)",
params=params,
)
for lo, hi, label in CONFIGS:
if label in all_results:
s, trades = all_results[label]
if s["n"] > 0:
insert_result(run_id, label, s, lo, hi)
# 전체 거래는 per-ticker 분리 없이 일괄 저장 (run_id+label로 구분)
insert_trades_bulk(run_id, label, "_all_", trades)
print(f"\n [DB 저장 완료] run_id: {run_id}")
except Exception as e:
print(f"\n [DB 저장 실패] {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,320 @@
"""F&G 구간별 맞춤 파라미터 백테스트
핵심 가설:
극공포 구간은 시장이 불안정 → 더 엄격한 진입 기준 필요
탐욕 구간은 상승 모멘텀이 지속 → 다소 느슨한 기준도 가능
테스트 방식:
각 F&G 구간마다 다른 파라미터 조합을 적용하고 성과 비교.
구간별 최적 파라미터 도출 → 실제 전략에 반영
결과를 Oracle DB에 저장.
데이터: 1년치 1h 캔들 (배치 수집)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import datetime
import json
import sys
import time
import urllib.request
import pandas as pd
import pyupbit
from dataclasses import dataclass
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
# ── F&G 구간별 파라미터 조합 ─────────────────────────────────
# (fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min)
ADAPTIVE_CONFIGS = [
# 기준선 (F&G 무관, 단일 파라미터)
(None, None, "기준선(전체/현행파라미터)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
# ── 극공포 (0~25) 구간 ── 엄격한 기준 ──
# 극공포에서는 변동성 급증이 흔함 → 볼륨 기준 올리고, 모멘텀 강화
(None, 25, "극공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(None, 25, "극공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/매우엄격(3x+5%+1%스탑)", 3.0, 2.0, 6, 5.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/넓은스탑(2x+3%+2%스탑)", 2.0, 2.0, 8, 3.0, 3.0, 0.020, 24, 3.0),
(None, 25, "극공포/짧은신호(3x+4%+4h유효)", 3.0, 2.0, 4, 4.0, 3.0, 0.015, 24, 3.0),
# ── 공포 (26~45) ── 중간 기준 ──
(26, 45, "공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/약강화(2.5x vol+3.5%mom)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
# ── 중립 이상 (46~100) ── 완화된 기준 가능 ──
(46, None, "중립이상/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/완화(1.5x vol+2.5%mom)",1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/엄격(2.5x+3.5%)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
# ── 탐욕+ (56~100) ──
(56, None, "탐욕이상/기준", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(56, None, "탐욕이상/완화(1.5x+2.5%)", 1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
]
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 이상 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(
df, fng_map,
fng_lo=None, fng_hi=None,
vol_mult=2.0, quiet_2h=2.0, sig_to_h=8,
mom_thr=3.0, sig_cancel=3.0, trail_stop=0.015,
time_h=24, time_min=3.0,
) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(time_h + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= trail_stop:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= time_h:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < time_min:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > sig_to_h:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -sig_cancel:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= vol_mult:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < quiet_2h:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= mom_thr:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=sum(t.pnl for t in trades) / len(trades),
total_pnl=sum(t.pnl for t in trades),
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── DB 준비 ───────────────────────────────────────────
run_id = None
if DB_ENABLED:
ensure_tables()
params = {
"tickers": len(datasets),
"days": 365,
"candle": "1h",
"stop": "trail+time",
}
run_id = insert_run(
run_name="fng_adaptive_1y",
description="F&G 구간별 맞춤 파라미터 1년 백테스트",
params=params,
)
print(f" DB run_id: {run_id}\n")
# ── 결과 출력 ─────────────────────────────────────────
print("=" * 92)
print(" F&G 구간별 맞춤 파라미터 성과 비교 (1년치 / 1h 캔들)")
print("=" * 92)
print(f" {'조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 86)
best_by_zone: dict[str, tuple] = {}
for cfg in ADAPTIVE_CONFIGS:
fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min = cfg
all_trades: list[Trade] = []
per_ticker: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
t = simulate(
df, fng_map,
fng_lo=fng_lo, fng_hi=fng_hi,
vol_mult=vol_mult, quiet_2h=quiet_2h, sig_to_h=sig_to_h,
mom_thr=mom_thr, sig_cancel=sig_cancel, trail_stop=trail_stop,
time_h=time_h, time_min=time_min,
)
all_trades.extend(t)
per_ticker[tk] = t
s = stats(all_trades)
# 구분선 (기준선 다음)
if label == "극공포/기준(2x vol+3%mom)":
print()
if s["n"] == 0:
print(f" {label:<42} 거래 없음")
continue
marker = "" if s["avg_pnl"] > 0 else ""
print(
f" {label:<42} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+8.1f}% -{s['max_dd']:>5.1f}%{marker}"
)
# DB 저장
if DB_ENABLED and run_id:
insert_result(run_id, label, s, fng_lo, fng_hi)
for tk, t_list in per_ticker.items():
insert_trades_bulk(run_id, label, tk, t_list)
# 구간별 최고 avg_pnl 추적
zone_key = label.split("/")[0]
if zone_key not in best_by_zone or s["avg_pnl"] > best_by_zone[zone_key][1]:
best_by_zone[zone_key] = (label, s["avg_pnl"], s)
# ── 구간별 최적 요약 ──────────────────────────────────
print()
print(" ★ 구간별 최적 파라미터:")
print(f" {'구간':<14} {'최적 조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8}")
print(" " + "-" * 72)
for zone, (label, best_pnl, s) in best_by_zone.items():
if s["n"] > 0:
print(f" {zone:<14} {label:<42} {s['n']:>5}{s['wr']:>5.1f}% {best_pnl:>+7.3f}%")
if DB_ENABLED and run_id:
print(f"\n [DB 저장 완료] run_id: {run_id}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,258 @@
"""공포탐욕지수(F&G) 조건별 백테스트
질문: "F&G 수치에 따라 진입 조건을 제한하면 성과가 나아지는가?"
테스트 구간:
A. 필터 없음 (baseline)
B. F&G > 25 (Extreme Fear 구간 제외)
C. F&G > 45 (Fear 이하 구간 제외, Neutral 이상)
D. F&G > 55 (Greed 이상만 진입)
E. F&G 20~45 (Fear 구간만 진입 역발상 매수)
진입 전략: 모멘텀 (거래량 급증 + 횡보 → +3% 상승 시 매수)
청산: 트레일링 스탑 1.5% + 타임 스탑 24h/+3%
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import json, time, sys, datetime, urllib.request
import pyupbit
import pandas as pd
from collections import defaultdict
from dataclasses import dataclass
# ── 종목 ──────────────────────────────────────────────────────
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR", "KRW-TRX",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
]
# ── 공통 파라미터 ─────────────────────────────────────────────
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015 # 1.5% (앞 테스트에서 최적)
TIME_H = 24
TIME_MIN = 3.0
# ── F&G 데이터 로드 ───────────────────────────────────────────
def load_fng(days: int = 365) -> dict[str, int]:
"""날짜(YYYY-MM-DD) → F&G 지수 딕셔너리 반환."""
url = f"https://api.alternative.me/fng/?limit={days}&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
result = {}
for d in data["data"]:
dt_str = datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d")
result[dt_str] = int(d["value"])
return result
def fng_at(fng_map: dict, ts: pd.Timestamp) -> int:
"""타임스탬프에 해당하는 F&G 값 반환. 없으면 50(Neutral) 반환."""
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── F&G 필터 정의 ─────────────────────────────────────────────
CONFIGS = [
(None, None, "필터 없음 (baseline)"),
(26, None, "F&G > 25 (Extreme Fear 제외)"),
(46, None, "F&G > 45 (Neutral 이상)"),
(56, None, "F&G > 55 (Greed 이상)"),
(20, 45, "F&G 20~45 (Fear 역발상 매수)"),
]
# ── 시뮬레이션 ────────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(df, fng_map: dict, fng_lo=None, fng_hi=None) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
# ── 포지션 관리 ──────────────────────────────────────
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
pnl = (cur - pos_buy) / pos_buy * 100
trades.append(Trade(pnl, i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= TIME_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_MIN:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
# ── 신호 유효성 ──────────────────────────────────────
if sig_px is not None:
if i - sig_i > SIG_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
sig_px = sig_i = None
# ── 축적 감지 ─────────────────────────────────────────
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
# ── F&G 필터 체크 ─────────────────────────────────────
fng_val = fng_at(fng_map, idx[i])
if fng_lo is not None and fng_val < fng_lo:
continue
if fng_hi is not None and fng_val > fng_hi:
continue
# ── 모멘텀 진입 ───────────────────────────────────────
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fng_val
sig_px = sig_i = None
return trades
def stats(trades: list[Trade]) -> dict:
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, avg_fng=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
rr = abs(aw / al) if al else 0
cum = 0.0; pk = 0.0; max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n = len(trades),
wr = len(wins) / len(trades) * 100,
avg_pnl = sum(t.pnl for t in trades) / len(trades),
total_pnl= sum(t.pnl for t in trades),
rr = rr,
avg_win = aw,
avg_loss = al,
avg_fng = sum(t.fng for t in trades) / len(trades),
max_dd = max_dd,
)
def main() -> None:
print("F&G 데이터 로드 중...")
fng_map = load_fng(365)
print(f" {len(fng_map)}일치 F&G 데이터 로드 완료\n")
print(f"종목 데이터 수집 중 ({len(TICKERS)}개, 60일 1h 캔들)...")
datasets: dict = {}
for i, tk in enumerate(TICKERS):
try:
df = pyupbit.get_ohlcv(tk, interval="minute60", count=1440)
if df is not None and len(df) > 50:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ")
time.sleep(0.08)
except Exception:
pass
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 결과 집계 ──────────────────────────────────────────────
print("=" * 78)
print(" F&G 조건별 백테스트 결과 (60일 / 모멘텀 진입 / 스탑 1.5%)")
print("=" * 78)
print(f" {'조건':<30} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} {'진입F&G':>7}")
print(" " + "-" * 74)
all_results = {}
for fng_lo, fng_hi, label in CONFIGS:
all_trades: list[Trade] = []
for df in datasets.values():
all_trades.extend(simulate(df, fng_map, fng_lo, fng_hi))
s = stats(all_trades)
all_results[label] = (s, all_trades)
marker = " ◀ 현행" if "없음" in label else ""
if s["n"] == 0:
print(f" {label:<30} 거래 없음")
continue
print(
f" {label:<30} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
f"{s['avg_fng']:>6.1f}{marker}"
)
# ── F&G 구간별 세부 성과 ──────────────────────────────────
print()
print(" F&G 수치 구간별 실제 거래 성과 (필터 없음 전체 거래 기준):")
base_trades = all_results["필터 없음 (baseline)"][1]
zones = [
(0, 25, "Extreme Fear (0~25) "),
(26, 45, "Fear (26~45)"),
(46, 55, "Neutral (46~55)"),
(56, 75, "Greed (56~75)"),
(76, 100, "Extreme Greed(76~100)"),
]
print(f" {'F&G 구간':<28} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6}")
print(" " + "-" * 60)
for lo, hi, name in zones:
subset = [t for t in base_trades if lo <= t.fng <= hi]
if not subset:
continue
s = stats(subset)
print(
f" {name:<28} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f}"
)
# ── 월별 F&G 흐름과 성과 ─────────────────────────────────
print()
print(" F&G 추이와 진입 시점 분포:")
sorted_fng = sorted(fng_map.items())[-70:] # 최근 70일
for dt_str, val in sorted_fng[::7]: # 1주 간격
bar_len = val // 5
bar = "" * bar_len
zone = ("극공포" if val <= 25 else "공포" if val <= 45
else "중립" if val <= 55 else "탐욕")
print(f" {dt_str} {val:>3} {bar:<20} {zone}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,385 @@
"""F&G 필터 전후 수익 비교 시뮬레이션
필터 없음 vs F&G ≥ 41 필터 적용 시 1년치 성과를 직접 비교.
표시:
- 거래 수, 승률, 평균 PnL, 총 누적 PnL
- 거래당 고정 자본 100만 원 기준 KRW 환산 손익
- 월별 손익 흐름 (계절성 확인)
- 극공포 차단 일수 통계
결과는 Oracle DB(backtest_results)에 저장.
데이터: 1년치 1h 캔들 (배치 수집)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import datetime
import json
import sys
import time
import urllib.request
from dataclasses import dataclass
import pandas as pd
import pyupbit
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
CAPITAL_PER_TRADE = 1_000_000 # 거래당 고정 자본 (KRW)
# 전략 파라미터 (현행)
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015
TIME_H = 24
TIME_MIN = 3.0
FNG_MIN = 41 # 이 값 미만이면 진입 차단
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
date: str # YYYY-MM
def simulate(df, fng_map, fng_min: int | None = None) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
trades.append(Trade(
(cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail",
idx[i].strftime("%Y-%m"),
))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= TIME_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_MIN:
trades.append(Trade(
pnl, i - pos_i, pos_fng, "time",
idx[i].strftime("%Y-%m"),
))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > SIG_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_min is not None and fv < fng_min:
continue
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades: list[Trade]) -> dict:
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0, krw_total=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
total_pnl = sum(t.pnl for t in trades)
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=total_pnl / len(trades),
total_pnl=total_pnl,
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
krw_total=total_pnl / 100 * CAPITAL_PER_TRADE,
)
def monthly_pnl(trades: list[Trade]) -> dict[str, float]:
"""월별 누적 PnL(%) 반환."""
monthly: dict[str, float] = {}
for t in trades:
monthly[t.date] = monthly.get(t.date, 0) + t.pnl
return dict(sorted(monthly.items()))
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
# F&G 분포
block_days = sum(1 for v in fng_map.values() if v < FNG_MIN)
total_days = len(fng_map)
print(f" 1년 F&G 분포: 진입차단(< {FNG_MIN}) = {block_days}일 / {total_days}"
f"({block_days/total_days*100:.1f}%)")
print(f" 진입허용(≥ {FNG_MIN}) = {total_days - block_days}일 ({(total_days-block_days)/total_days*100:.1f}%)\n")
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets: dict[str, pd.DataFrame] = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 두 가지 조건 시뮬레이션 ──────────────────────────────
# A: 필터 없음 (현행)
# B: F&G ≥ 41 (신규)
all_trades_A: list[Trade] = []
all_trades_B: list[Trade] = []
per_ticker_A: dict[str, list[Trade]] = {}
per_ticker_B: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
ta = simulate(df, fng_map, fng_min=None)
tb = simulate(df, fng_map, fng_min=FNG_MIN)
all_trades_A.extend(ta)
all_trades_B.extend(tb)
per_ticker_A[tk] = ta
per_ticker_B[tk] = tb
sa = stats(all_trades_A)
sb = stats(all_trades_B)
# ── 결과 출력 ─────────────────────────────────────────────
print("=" * 80)
print(f" F&G 필터 전후 비교 (1년치 / {len(datasets)}개 종목 / 1h캔들 / 자본 {CAPITAL_PER_TRADE:,}원/거래)")
print("=" * 80)
print(f" {'조건':<26} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>8} {'MaxDD':>7} {'KRW손익':>14}")
print(" " + "-" * 76)
for label, s in [("필터 없음 (현행)", sa), (f"F&G≥{FNG_MIN} 필터 (신규)", sb)]:
krw_str = f"{s['krw_total']:>+,.0f}"
print(
f" {label:<26} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% {krw_str:>14}"
)
diff_trades = sb["n"] - sa["n"]
diff_krw = sb["krw_total"] - sa["krw_total"]
diff_wr = sb["wr"] - sa["wr"]
print(f"\n 변화: 거래수 {diff_trades:+d}건 | 승률 {diff_wr:+.1f}%p | "
f"KRW손익 {diff_krw:>+,.0f}")
# ── 월별 손익 흐름 ────────────────────────────────────────
print()
print(" 월별 손익 비교 (필터없음 vs F&G≥41):")
print(f" {'':>8} {'차단일수':>6} {'필터없음':>9} {'F&G필터':>9} {'개선':>8} {'누적(필터)':>12}")
print(" " + "-" * 62)
ma = monthly_pnl(all_trades_A)
mb = monthly_pnl(all_trades_B)
all_months = sorted(set(ma.keys()) | set(mb.keys()))
cum_b = 0.0
for m in all_months:
pa = ma.get(m, 0.0)
pb = mb.get(m, 0.0)
cum_b += pb
diff = pb - pa
# 해당 월 차단 일수
yr, mo = int(m[:4]), int(m[5:])
blocked = sum(
1 for d, v in fng_map.items()
if d.startswith(m) and v < FNG_MIN
)
bar = "" * min(int(abs(pb) / 3), 12) if pb > 0 else "" * min(int(abs(pb) / 3), 12)
sign = "+" if pb > 0 else ""
diff_sign = "" if diff > 0 else ("" if diff < 0 else "=")
print(
f" {m} {blocked:>4}일차단 "
f"{pa:>+8.1f}% {sign}{pb:>8.1f}% "
f"{diff_sign}{abs(diff):>6.1f}% {cum_b:>+10.1f}%"
)
# ── 종목별 비교 (상위/하위) ───────────────────────────────
print()
print(" 종목별 성과 비교 (필터없음 vs F&G≥41):")
print(f" {'종목':<14} {'현행거래':>6} {'현행PnL':>8} {'필터거래':>7} {'필터PnL':>8} {'개선':>8}")
print(" " + "-" * 58)
ticker_rows = []
for tk in sorted(datasets.keys()):
ta_list = per_ticker_A.get(tk, [])
tb_list = per_ticker_B.get(tk, [])
pa = sum(t.pnl for t in ta_list) if ta_list else 0
pb = sum(t.pnl for t in tb_list) if tb_list else 0
ticker_rows.append((tk, len(ta_list), pa, len(tb_list), pb, pb - pa))
for row in sorted(ticker_rows, key=lambda x: x[5], reverse=True):
tk, na, pa, nb, pb, delta = row
mark = "" if delta > 1 else ("" if delta < -1 else " =")
print(
f" {tk:<14} {na:>6}{pa:>+7.1f}% {nb:>6}{pb:>+7.1f}% "
f"{mark}{abs(delta):>6.1f}%"
)
# ── 극공포 차단 효과 분석 ─────────────────────────────────
print()
print(f" F&G < {FNG_MIN} 구간(차단) 거래 성과 분석:")
blocked_trades = [t for t in all_trades_A if t.fng < FNG_MIN]
if blocked_trades:
sb2 = stats(blocked_trades)
print(f" → 차단된 거래 수: {sb2['n']}")
print(f" → 차단 거래 승률: {sb2['wr']:.1f}%")
print(f" → 차단 거래 평균 PnL: {sb2['avg_pnl']:+.3f}%")
print(f" → 차단으로 절약된 손실: {sb2['krw_total']:>+,.0f}"
f"({CAPITAL_PER_TRADE:,}× {sb2['n']}거래 기준)")
else:
print(" → 차단된 거래 없음")
# ── 최적 임계값 확인 ─────────────────────────────────────
print()
print(f" F&G 임계값별 성과 비교 (현행 기준 비교):")
print(f" {'임계값':>8} {'거래':>5} {'승률':>6} {'평균PnL':>9} {'KRW손익':>14}")
print(" " + "-" * 52)
for thr in [25, 30, 35, 41, 45, 50]:
filtered = [t for t in all_trades_A if t.fng >= thr]
if not filtered:
continue
sf = stats(filtered)
marker = " ◀ 채택" if thr == FNG_MIN else ""
print(
f" {thr:>5}이상 {sf['n']:>5}{sf['wr']:>5.1f}% "
f"{sf['avg_pnl']:>+8.3f}% {sf['krw_total']:>+14,.0f}{marker}"
)
# ── DB 저장 ───────────────────────────────────────────────
if DB_ENABLED:
try:
ensure_tables()
params = {
"tickers": len(datasets), "days": 365, "candle": "1h",
"trail_stop": TRAIL_STOP, "mom_thr": MOM_THR,
"fng_min_new": FNG_MIN, "capital_per_trade": CAPITAL_PER_TRADE,
}
run_id = insert_run(
"fng_sim_comparison",
f"F&G 필터 전후 비교 시뮬레이션 (1년치 / F&G≥{FNG_MIN})",
params,
)
insert_result(run_id, "필터 없음 (현행)", sa, None, None)
insert_result(run_id, f"F&G≥{FNG_MIN} 필터 (신규)", sb, FNG_MIN, None)
for tk, t_list in per_ticker_A.items():
insert_trades_bulk(run_id, "필터없음", tk, t_list)
for tk, t_list in per_ticker_B.items():
insert_trades_bulk(run_id, f"fng_ge{FNG_MIN}", tk, t_list)
print(f"\n [DB 저장 완료] run_id: {run_id}")
except Exception as e:
print(f"\n [DB 저장 실패] {e}")
if __name__ == "__main__":
main()

View File

@@ -4,6 +4,9 @@ A안: 추세(2h +5%) + 15분 워치리스트 (모멘텀 없음)
B안: 추세(2h +5%) + 모멘텀 + 15 워치리스트 (현행)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os, time
from dotenv import load_dotenv
load_dotenv()

View File

@@ -7,6 +7,9 @@
- 증분 업데이트 (신규 봉만 API 페치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import os

View File

@@ -0,0 +1,248 @@
"""눌림목 진입 vs 모멘텀 진입 백테스트 비교
전략 A — 모멘텀 (현행):
거래량 급증 + 횡보 감지 → 신호가 기록
→ 신호가 대비 +3% 상승 확인 시 즉시 매수
전략 B — 눌림목:
거래량 급증 + 횡보 감지 → 신호가 기록
→ 신호가 대비 -1.5% 이하로 눌림 확인
→ 눌림 저점에서 +0.5% 회복 시 매수
공통 청산:
트레일링 스탑 (최고가 대비 -2%) + 타임 스탑 (24h / +3% 미달 시)
데이터: 1h 캔들 60일 (최근)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import pyupbit, time, sys
from collections import defaultdict
# ─── 종목 (현재 전략이 스캔하는 상위권 종목) ───────────────────
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM", "KRW-SAND",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
]
# ─── 공통 파라미터 ──────────────────────────────────────────────
VOL_MULT = 2.0 # 거래량 급증 배수 (직전 캔들 / 5h 평균)
QUIET_2H = 2.0 # 2h 횡보 기준 (%)
SIGNAL_TO_H = 8 # 신호 유효 시간 (h)
TRAIL_STOP = 0.020 # 트레일링 스탑 2%
TIME_STOP_H = 24 # 타임 스탑 기준 시간
TIME_STOP_MIN = 3.0 # 타임 스탑 최소 수익 (%)
SIGNAL_CANCEL = 3.0 # 신호가 대비 -3% 하락 시 신호 취소 (%)
# ─── 전략 A: 모멘텀 진입 ─────────────────────────────────────
MOMENTUM_THR = 3.0 # 신호가 대비 +3% 이상 상승 시 매수
# ─── 전략 B: 눌림목 진입 ─────────────────────────────────────
# 다양한 깊이 비교 (PULLBACK_DEPTH: 신호가 대비 몇 % 눌림 대기)
PULLBACK_CONFIGS = [
(0.5, 0.3, "눌림0.5%+회복0.3%"),
(1.0, 0.5, "눌림1.0%+회복0.5%"),
(1.5, 0.5, "눌림1.5%+회복0.5%"),
(2.0, 0.5, "눌림2.0%+회복0.5%"),
]
def simulate(df, strategy: str, pb_depth: float = 1.5, pb_recovery: float = 0.5) -> dict:
closes = df["close"].values
vols = df["volume"].values
trades = []
sig_px = sig_i = dip_px = None
pos_buy = pos_peak = pos_i = None
vol_window = 5 # 5h 평균 (1h 캔들 기준)
for i in range(vol_window + 2, len(closes) - max(TIME_STOP_H + 4, 10)):
# ── 포지션 관리 ────────────────────────────────────────
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
# 트레일링 스탑
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
pnl = (cur - pos_buy) / pos_buy * 100
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "trail"})
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
continue
# 타임 스탑
if i - pos_i >= TIME_STOP_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_STOP_MIN:
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "time"})
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
continue
continue
# ── 신호 유효성 체크 ──────────────────────────────────
if sig_px is not None:
# 시간 초과
if i - sig_i > SIGNAL_TO_H:
sig_px = sig_i = dip_px = None
# 신호가 대비 큰 하락 → 축적 실패
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
sig_px = sig_i = dip_px = None
# ── 신호 없으면 축적 조건 탐색 ────────────────────────
if sig_px is None:
vol_avg = vols[i - vol_window - 1 : i - 1].mean()
if vol_avg <= 0:
continue
vol_ratio = vols[i - 1] / vol_avg
quiet = abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H
if vol_ratio >= VOL_MULT and quiet:
sig_px = closes[i]
sig_i = i
dip_px = None
continue
# ── 진입 로직 ─────────────────────────────────────────
cur = closes[i]
move = (cur - sig_px) / sig_px * 100
if strategy == "momentum":
if move >= MOMENTUM_THR:
pos_buy = pos_peak = cur
pos_i = i
sig_px = sig_i = None
else: # pullback
if dip_px is None:
if move <= -pb_depth:
dip_px = cur
else:
if cur < dip_px:
dip_px = cur # 저점 갱신
recovery = (cur - dip_px) / dip_px * 100
if recovery >= pb_recovery:
pos_buy = pos_peak = cur
pos_i = i
sig_px = sig_i = dip_px = None
if not trades:
return {"n": 0, "wins": 0, "wr": 0.0, "avg_pnl": 0.0, "total_pnl": 0.0, "avg_h": 0.0}
wins = [t for t in trades if t["pnl"] > 0]
return {
"n": len(trades),
"wins": len(wins),
"wr": len(wins) / len(trades) * 100,
"avg_pnl": sum(t["pnl"] for t in trades) / len(trades),
"total_pnl": sum(t["pnl"] for t in trades),
"avg_h": sum(t["h"] for t in trades) / len(trades),
}
def agg(results: list[dict]) -> dict:
"""종목별 결과 리스트를 합산."""
if not results:
return {"n": 0, "wins": 0, "wr": 0, "avg_pnl": 0, "total_pnl": 0, "avg_h": 0}
total_n = sum(r["n"] for r in results)
total_wins = sum(r["wins"] for r in results)
total_pnl = sum(r["total_pnl"] for r in results)
all_h = [r["avg_h"] for r in results if r["n"] > 0]
return {
"n": total_n,
"wins": total_wins,
"wr": total_wins / total_n * 100 if total_n else 0,
"avg_pnl": total_pnl / total_n if total_n else 0,
"total_pnl": total_pnl,
"avg_h": sum(all_h) / len(all_h) if all_h else 0,
}
def main() -> None:
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
datasets: dict[str, object] = {}
for i, ticker in enumerate(TICKERS):
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
if df is not None and len(df) > 50:
datasets[ticker] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
time.sleep(0.08)
except Exception:
pass
sys.stderr.write("\n")
print(f" 수집 완료: {len(datasets)}개 종목\n")
# 전략별 결과 수집
strat_labels = ["모멘텀(현행)", "눌림0.5%", "눌림1.0%", "눌림1.5%", "눌림2.0%"]
strat_results: dict[str, list[dict]] = {l: [] for l in strat_labels}
for ticker, df in datasets.items():
r_mom = simulate(df, "momentum")
strat_results["모멘텀(현행)"].append(r_mom)
for depth, rec, label in PULLBACK_CONFIGS:
lbl = f"눌림{depth:.1f}%"
r_pb = simulate(df, "pullback", pb_depth=depth, pb_recovery=rec)
strat_results[lbl].append(r_pb)
# ─── 결과 출력 ─────────────────────────────────────────────
print("=" * 72)
print(" 전략 비교 (60일 / 25개 종목 합산)")
print("=" * 72)
print(f" {'전략':<16} {'거래':>5} {'승률':>7} {'평균PnL':>9} {'총PnL(%)':>10} {'평균보유':>8}")
print(" " + "-" * 62)
for label in strat_labels:
r = agg(strat_results[label])
if r["n"] == 0:
print(f" {label:<16} 데이터 없음")
continue
marker = " ◀ 현행" if label == "모멘텀(현행)" else ""
print(
f" {label:<16} {r['n']:>5}{r['wr']:>6.1f}% "
f"{r['avg_pnl']:>+8.3f}% {r['total_pnl']:>+9.2f}% "
f"{r['avg_h']:>6.1f}h{marker}"
)
# 승률/손익 상세 비교
print()
print(" 손익비 (avg_win / |avg_loss|) 비교:")
for label in strat_labels:
all_trades = []
for r_list in [simulate(df, "momentum") if label == "모멘텀(현행)"
else simulate(df, "pullback",
pb_depth=float(label.replace("눌림","").replace("%","")),
pb_recovery=0.5)
for df in datasets.values()]:
pass # need per-trade data
# 종목별 상세 (상위/하위)
print()
print(" 종목별 모멘텀 vs 눌림1.5% 비교:")
print(f" {'종목':<14} {'모멘텀 WR':>9} {'모멘텀 PnL':>11} {'눌림1.5% WR':>11} {'눌림1.5% PnL':>12}")
print(" " + "-" * 62)
ticker_list = list(datasets.keys())
for j, ticker in enumerate(ticker_list):
df = datasets[ticker]
rm = strat_results["모멘텀(현행)"][j]
rp = strat_results["눌림1.5%"][j]
if rm["n"] == 0 and rp["n"] == 0:
continue
rm_wr = f"{rm['wr']:.0f}%" if rm["n"] else "-"
rm_pnl = f"{rm['total_pnl']:+.2f}%" if rm["n"] else "-"
rp_wr = f"{rp['wr']:.0f}%" if rp["n"] else "-"
rp_pnl = f"{rp['total_pnl']:+.2f}%" if rp["n"] else "-"
diff = (rp["total_pnl"] - rm["total_pnl"]) if (rm["n"] and rp["n"]) else 0
mark = "" if diff > 0.5 else ("" if diff < -0.5 else "=")
print(f" {ticker:<14} {rm_wr:>9} {rm_pnl:>11} {rp_wr:>11} {rp_pnl:>12} {mark}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,81 @@
"""10분봉 캐시 갱신 스크립트 — 최신 45일 데이터를 Upbit API로 재수집."""
import os, sys, pickle, time
from pathlib import Path
from datetime import datetime, timedelta
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
sys.path.insert(0, str(Path(__file__).parent.parent))
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim10m_cache.pkl"
TOP30_FILE = Path(__file__).parent.parent / "data" / "top30_tickers.pkl"
SIM_DAYS = 45
TOP_N = 20
def fetch_10m(ticker: str, days: int) -> "pd.DataFrame | None":
import pandas as pd
target_start = datetime.now() - timedelta(days=days)
all_dfs, to, prev_oldest = [], None, None
while True:
kwargs = dict(ticker=ticker, interval="minute10", count=200)
if to:
kwargs["to"] = to.strftime("%Y-%m-%d %H:%M:%S")
try:
df = pyupbit.get_ohlcv(**kwargs)
except Exception:
time.sleep(0.5)
break
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
if oldest <= target_start:
break
to = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
return combined[combined.index >= target_start]
def main():
# 현재 Top20 종목 가져오기
from core.market import get_top_tickers
print("Top20 종목 조회...")
tickers = get_top_tickers()[:TOP_N]
print(f" {tickers}\n")
data = {"10m": {}}
for i, ticker in enumerate(tickers, 1):
print(f"\r {i:>2}/{len(tickers)} {ticker} ", end="", flush=True)
df = fetch_10m(ticker, SIM_DAYS)
if df is not None and len(df) > 100:
data["10m"][ticker] = df
time.sleep(0.15)
print(f"\n\n종목: {len(data['10m'])}")
if data["10m"]:
sample = next(iter(data["10m"].values()))
print(f"기간: {sample.index[0].strftime('%Y-%m-%d')} ~ {sample.index[-1].strftime('%Y-%m-%d')}")
print(f"레코드: {len(sample)}")
# 저장
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE}")
# top30 갱신
pickle.dump(tickers, open(TOP30_FILE, "wb"))
print(f"종목 저장: {TOP30_FILE}")
if __name__ == "__main__":
main()

View File

@@ -4,6 +4,9 @@ WF차단 종목들에 대해 shadow 포지션을 시뮬레이션하여
번의 shadow 승리 WF차단이 해제될 있었는지 분석.
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from datetime import datetime
from dotenv import load_dotenv

View File

@@ -1,5 +1,8 @@
"""Shadow 재활 이후 실제 진입 성과 시뮬레이션."""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from datetime import datetime
from dotenv import load_dotenv

View File

@@ -6,6 +6,9 @@
3. 15 워치리스트: 신호 15 재확인
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os, time
from datetime import datetime, timedelta
from collections import defaultdict

View File

@@ -0,0 +1,382 @@
"""10분봉 vol 감지 vs 40분봉 vol 감지 비교 시뮬레이션.
40분봉 집계 시 10분봉 spike가 희석되는 문제를 해결하기 위해
신호 감지를 10분봉 기준으로 실행하고 40분봉 전략과 노이즈/수익 비교.
비교 모드 (각 필터 조합 × 2개 봉 단위):
10분봉 detection:
A. 10m 필터없음
B. 10m F&G≥41 + BEAR차단N5
C. 10m vol≥5x 오버라이드 (F&G+레짐 무시)
40분봉 detection (기준선):
D. 40m 필터없음
E. 40m F&G≥41 + BEAR차단N5
F. 40m vol≥5x 오버라이드
데이터: data/sim1y_cache.pkl (10분봉 1년치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
THRESH = 4.8
QUIET_PCT = 2.0
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
FNG_MIN_ENTRY = 41
# ── 40분봉 파라미터 ────────────────────────────────
P40 = dict(
local_vol_n = 7,
quiet_n = 3,
signal_to_n = 12,
atr_n = 7,
ts_n = 12,
time_stop_pct = 3.0,
vol_mult = 2.0,
)
# ── 10분봉 파라미터 (벽시계 기준 동등) ───────────────
# LOCAL_VOL_N: 40m×7=280min → 10min×28
# QUIET_N: 40m×3=120min → 10min×12
# SIGNAL_TO_N: 40m×12=480min → 10min×48
# ATR_N: 40m×7=280min → 10min×28
# TS_N: 40m×12=480min → 10min×48
P10 = dict(
local_vol_n = 28,
quiet_n = 12,
signal_to_n = 48,
atr_n = 28,
ts_n = 48,
time_stop_pct = 3.0,
vol_mult = 2.0,
)
REGIME_N = 5 # 40분봉 기준
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
VOL_OVERRIDE_THRESH = 5.0
# ─────────────────────────────────────────────────────────────────────────────
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40: continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def regime_to_10m(regime_40m: pd.Series, df_10m: pd.DataFrame) -> pd.Series:
"""40분봉 레짐 시리즈를 10분봉 인덱스에 ffill 매핑."""
combined = regime_40m.reindex(
regime_40m.index.union(df_10m.index)
).ffill()
return combined.reindex(df_10m.index)
def calc_atr(df, buy_idx, atr_n):
sub = df.iloc[max(0, buy_idx - atr_n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-atr_n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct, ts_n, time_stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_n and pnl_now < time_stop_pct:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, fng_map, p,
use_fng, use_regime, vol_override_thresh):
"""
공통 전략 함수. df = 봉 단위 OHLCV (10분봉 또는 40분봉).
regime_series: df 인덱스와 정렬된 레짐 시리즈.
우선순위:
① 포지션 청산
② 축적 신호 감지 (필터 무관, 항상 실행)
③ 진입: vol_strong → 모든 필터 skip; 아니면 F&G+레짐 체크
"""
local_vol_n = p["local_vol_n"]
quiet_n = p["quiet_n"]
signal_to_n = p["signal_to_n"]
atr_n = p["atr_n"]
ts_n = p["ts_n"]
time_stop_pct = p["time_stop_pct"]
vol_mult = p["vol_mult"]
trades = []
sig_i = sig_p = sig_vr = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(local_vol_n + 2, quiet_n + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── ① 포지션 청산 ─────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct,
ts_n, time_stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = sig_vr = None; i = next_i
continue
# 신호 만료 체크
if sig_i is not None and (i - sig_i) > signal_to_n:
sig_i = sig_p = sig_vr = None
# ── ② 축적 신호 감지 (항상 실행) ──────────────────────
if sig_i is None:
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-local_vol_n:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-quiet_n]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= vol_mult:
sig_i = i; sig_p = cur; sig_vr = vol_r
i += 1
continue
# 신호 이후 가격 하락 → 초기화
if cur < sig_p:
sig_i = sig_p = sig_vr = None
i += 1
continue
# ── ③ 진입 체크 ─────────────────────────────────────
vol_strong = (vol_override_thresh > 0
and sig_vr is not None
and sig_vr >= vol_override_thresh)
if not vol_strong:
# F&G 필터
if use_fng and fng_map:
fv = fng_map.get(ts.strftime("%Y-%m-%d"), 50)
if fv < FNG_MIN_ENTRY:
i += 1
continue
# 레짐 BEAR 차단
if use_regime and not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
if not pd.isna(v) and float(v) < BEAR_THRESHOLD:
i += 1
continue
move_pct = (cur - sig_p) / sig_p * 100
if move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i, atr_n)
sig_i = sig_p = sig_vr = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS:
blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0
wins = 0; peak = BUDGET; max_dd = 0.0; pf = float(BUDGET)
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
wins += int(is_win)
pf = max(pf + max(pf, MIN_BUDGET) / MAX_POS * pnl / 100, MIN_BUDGET)
peak = max(peak, pf)
max_dd = max(max_dd, (peak - pf) / peak * 100)
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted), "wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"max_dd": max_dd,
}
def sim_mode(dfs_per_ticker, regime_map, fng_map, p,
use_fng, use_regime, vol_override_thresh):
"""
dfs_per_ticker: {ticker: DataFrame (10m 또는 40m)}
regime_map: {ticker: regime Series (같은 인덱스로 정렬됨)}
"""
all_trades = []; wf_total = 0
for ticker, df in dfs_per_ticker.items():
rs = regime_map.get(ticker, pd.Series(dtype=float))
raw = run_strategy(df, ticker, rs, fng_map, p,
use_fng, use_regime, vol_override_thresh)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
return run_compound(accepted), wf_total, len(skipped)
def fmt(r, wf, skip):
if r["total"] == 0:
return "진입없음"
return (f"{r['total']:>5}{r['wr']:>4.1f}% "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}"
f"-{r['max_dd']:>4.1f}% wf:{wf} skip:{skip}")
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
fng_map = json.loads(FNG_FILE.read_text())
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}")
dfs10 = {t: cache["10m"][t] for t in tickers}
dfs40 = {t: resample_40m(df) for t, df in dfs10.items()}
# 레짐 (40분봉 기반)
regime_40m = build_regime_series(dfs40)
# 40분봉용 regime map
regime_map_40 = {t: regime_40m for t in tickers}
# 10분봉용 regime map (ffill 매핑)
regime_map_10 = {
t: regime_to_10m(regime_40m, dfs10[t])
for t in tickers
}
sample = next(iter(dfs10.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
print(f"\n{'='*80}")
print(f" 10분봉 vs 40분봉 vol 감지 비교 | {start_dt} ~ {end_dt} | {len(tickers)}종목")
print(f" vol override: ≥{VOL_OVERRIDE_THRESH}x | F&G≥{FNG_MIN_ENTRY} | BEAR차단N{REGIME_N}")
print(f"{'='*80}")
print(f" {'모드':<32}{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>6}")
print(f" {''*76}")
# ── 10분봉 모드들 ─────────────────────────────────────────────────────
print(f"\n [10분봉 vol 감지 — local_vol_n={P10['local_vol_n']}봉({P10['local_vol_n']*10}분) quiet_n={P10['quiet_n']}봉({P10['quiet_n']*10}분)]")
modes_10 = [
("A. 10m 필터없음", False, False, 0.0),
("B. 10m F&G+BEAR차단", True, True, 0.0),
(f"C. 10m vol≥{VOL_OVERRIDE_THRESH}x 오버라이드", True, True, VOL_OVERRIDE_THRESH),
]
for label, uf, ur, vt in modes_10:
r, wf, skip = sim_mode(dfs10, regime_map_10, fng_map, P10, uf, ur, vt)
print(f" {label:<32}{fmt(r, wf, skip)}")
# ── 40분봉 모드들 (기준선) ──────────────────────────────────────────
print(f"\n [40분봉 vol 감지 — local_vol_n={P40['local_vol_n']}봉({P40['local_vol_n']*40}분) quiet_n={P40['quiet_n']}봉({P40['quiet_n']*40}분)]")
modes_40 = [
("D. 40m 필터없음", False, False, 0.0),
("E. 40m F&G+BEAR차단", True, True, 0.0),
(f"F. 40m vol≥{VOL_OVERRIDE_THRESH}x 오버라이드", True, True, VOL_OVERRIDE_THRESH),
]
for label, uf, ur, vt in modes_40:
r, wf, skip = sim_mode(dfs40, regime_map_40, fng_map, P40, uf, ur, vt)
print(f" {label:<32}{fmt(r, wf, skip)}")
print(f"\n{'='*80}")
# ── vol≥5x 신호 품질 비교 (10m vs 40m) ─────────────────────────────
print("\n [vol≥5x 오버라이드 신호 품질 비교 — 필터 없음 조건에서 override 효과]")
print(f" {'모드':<32}{'진입':>5} {'승률':>5}{'수익률':>8} {'낙폭':>6}")
for label, dfs, rmap, p in [
("10m vol≥5x (filter none)", dfs10, regime_map_10, P10),
("40m vol≥5x (filter none)", dfs40, regime_map_40, P40),
]:
r, wf, skip = sim_mode(dfs, rmap, fng_map, p, False, False, VOL_OVERRIDE_THRESH)
if r["total"]:
print(f" {label:<32}{r['total']:>5}{r['wr']:>4.1f}% "
f"{r['roi_pct']:>+7.2f}% -{r['max_dd']:>4.1f}%")
print(f"{'='*80}")
if __name__ == "__main__":
main()

314
archive/tests/sim_3bar.py Normal file
View File

@@ -0,0 +1,314 @@
"""3봉 vol가속 시그널 + 다양한 청산 전략 비교 시뮬 (30일)."""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
# ── 3봉 시그널 SQL ─────────────────────────────────────────────────────────────
SIGNAL_SQL_3BAR = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
-- 3봉 연속 양봉
AND close_p>open_p AND pc1>po1 AND pc2>po2
-- 3봉 연속 가격 가속
AND close_p>pc1 AND pc1>pc2
-- 3봉 연속 볼륨 가속
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
# ── 4봉 시그널 SQL (비교용) ────────────────────────────────────────────────────
SIGNAL_SQL_4BAR = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3,
LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3,
pc1,po1,pc2,po2,pc3,po3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3
AND close_p>pc1 AND pc1>pc2 AND pc2>pc3
AND vr0>vr1 AND vr1>vr2 AND vr2>vr3
ORDER BY ticker, ts
"""
def fetch_signals(cur, sql, warmup_since, check_since):
cur.execute(sql, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row[:6]
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
# ── 청산 전략 함수들 ───────────────────────────────────────────────────────────
def sim_trail(bars, ep, ar):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_trail(bars, ep, ar, tp_r):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_sl(bars, ep, tp_r, sl_r):
tp = ep * (1 + tp_r)
sl = ep * (1 - sl_r)
for i, (ts, cp, hp, lp) in enumerate(bars):
if lp <= sl:
return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl,
pnl=-sl_r * 100, held=i + 1)
if hp >= tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def run_strategies(signals, strategies):
results = {}
for label, mode, tp_r, sl_r in strategies:
sim = []
for s in signals:
if mode == 'trail':
r = sim_trail(s['bars'], s['entry_price'], s['atr_raw'])
elif mode == 'tp_trail':
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
else:
r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
results[label] = taken
return results
def print_table(title, results, strategies):
print(f"\n{''*105}")
print(f" {title}")
print(f"{''*105}")
print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}")
print(f"{''*105}")
for label, _, _, _ in strategies:
taken = results.get(label, [])
n = len(taken)
if n == 0:
print(f" {label:32s} 거래없음")
continue
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
rr = avg_w / avg_l
print(f" {label:32s} {n:>3d}{wins/n*100:>4.0f}% {total:>+12,.0f}"
f"{ret:>+5.2f}% {avg_h:>5.0f}{rr:>4.1f}:1")
print(f"{''*105}")
def print_detail(label, taken):
print(f"\n[{label} 건별 상세]")
print(f"{''*105}")
for i, r in enumerate(taken, 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"{r['status']:14s} {r['held']:3d}{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== 3봉 vs 4봉 진입 비교 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)")
print(f"VOL≥{VOL_MIN}x | 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}\n")
# 시그널 수집
print("3봉 시그널 수집 중...", flush=True)
sigs_3 = fetch_signals(cur, SIGNAL_SQL_3BAR, warmup_since, check_since)
print("4봉 시그널 수집 중...", flush=True)
sigs_4 = fetch_signals(cur, SIGNAL_SQL_4BAR, warmup_since, check_since)
print(f" → 3봉: {len(sigs_3)}건 / 4봉: {len(sigs_4)}\n")
strategies = [
('TP 3% + Trail Stop [3~5%]', 'tp_trail', 0.03, None ),
('TP 2% + Trail Stop [3~5%]', 'tp_trail', 0.02, None ),
('TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ),
('TP 2% + SL 3%', 'tp_sl', 0.02, 0.03 ),
('TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ),
('TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ),
]
res_3 = run_strategies(sigs_3, strategies)
res_4 = run_strategies(sigs_4, strategies)
print_table(f"【3봉 진입】 시그널 {len(sigs_3)}", res_3, strategies)
print_table(f"【4봉 진입】 시그널 {len(sigs_4)}", res_4, strategies)
# 3봉에서 가장 나은 전략 상세
best_label = max(res_3, key=lambda k: sum(
PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in res_3[k]))
print_detail(f"3봉 최적: {best_label}", res_3[best_label])
conn.close()
if __name__ == '__main__':
main()

View File

@@ -15,8 +15,8 @@ load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 파라미터 ─────────────────────────────────────────────
CACHE_FILE = Path("sim10m_cache.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim10m_cache.pkl"
TOP30_FILE = Path(__file__).parent.parent / "data" / "top30_tickers.pkl"
TOP_N = 20
BUDGET = 15_000_000

View File

@@ -0,0 +1,281 @@
"""캐스케이드 limit 주문 전략 시뮬 (30일).
전략:
① bars[0:2] → 2봉, +2% limit (trail 없음)
② bars[2:5] → 3봉, +1% limit (trail 없음)
③ bars[5:5+last_n] → last_n봉, +0.5% limit (trail 없음)
④ bars[5+last_n:] → 기존전략 (TP2% + ATR Trail Stop)
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2
AND close_p>pc1 AND pc1>pc2
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
def fetch_signals(cur, warmup_since, check_since):
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
"""기본 전략: TP + Trail Stop."""
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status='TP2%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_cascade(bars, ep, ar, last_n):
"""
① bars[0:2] → 2봉, +2% limit
② bars[2:5] → 3봉, +1% limit
③ bars[5:5+last_n] → last_n봉, +0.5% limit
④ bars[5+last_n:] → 기존전략 (TP2% + Trail Stop)
"""
stages = [
(0, 2, 0.020, f'①2봉2%'),
(2, 5, 0.010, f'②3봉1%'),
(5, 5 + last_n, 0.005, f'{last_n}봉0.5%'),
]
for start, end, lr, tag in stages:
lp = ep * (1 + lr)
for i, (ts, cp, hp, _) in enumerate(bars[start:end]):
if hp >= lp:
return dict(status=tag, exit_ts=ts, exit_price=lp,
pnl=lr * 100, held=start + i + 1)
offset = 5 + last_n
fb = sim_tp_trail(bars[offset:] or bars[-1:], ep, ar)
fb['held'] += offset
fb['status'] = '④기존→' + fb['status']
return fb
def sim_limit_then_trail(bars, ep, ar, n_bars=2, limit_r=0.005, tp_r=0.02):
"""단순 limit: N봉 내 체결 안되면 TP/Trail."""
lp = ep * (1 + limit_r)
for i, (ts, cp, hp, _) in enumerate(bars[:n_bars]):
if hp >= lp:
return dict(status=f'limit{limit_r*100:.1f}%', exit_ts=ts,
exit_price=lp, pnl=limit_r * 100, held=i + 1)
fb = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
fb['held'] += n_bars
fb['status'] = '미체결→' + fb['status']
return fb
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def krw(r):
return PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
def print_cascade_detail(taken, last_n, label):
stage_tags = ['①2봉2%', '②3봉1%', f'{last_n}봉0.5%']
stage_lr = [0.020, 0.010, 0.005]
print(f"\n{''*70}")
print(f" {label}")
print(f"{len(taken)}건 승률 {sum(1 for r in taken if r['pnl']>0)/len(taken)*100:.0f}% "
f"합산 {sum(krw(r) for r in taken):+,.0f}")
print(f"{''*70}")
for tag, lr in zip(stage_tags, stage_lr):
grp = [r for r in taken if r['status'] == tag]
if not grp:
continue
total = sum(krw(r) for r in grp)
avg = total / len(grp)
print(f" ┌─ {tag}: {len(grp):3d}건 avg {avg:+,.0f}원/건 소계 {total:+,.0f}")
# ④ 기존전략 하위 분류
fb_grp = [r for r in taken if r['status'].startswith('④기존→')]
if fb_grp:
print(f" └─ ④기존전략 (미체결 후): {len(fb_grp)}")
for sub in ['TP2%', '트레일손절', '타임아웃', '진행중']:
sub_grp = [r for r in fb_grp if r['status'].endswith(sub)]
if not sub_grp:
continue
total = sum(krw(r) for r in sub_grp)
avg = total / len(sub_grp)
print(f" {'' if total>0 else ''} {sub:8s}: {len(sub_grp):3d}"
f"avg {avg:+,.0f}원/건 소계 {total:+,.0f}")
print()
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== 캐스케이드 limit 전략 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
signals = fetch_signals(cur, warmup_since, check_since)
print(f"시그널 {len(signals)}\n")
# ── 기준선: 현재전략 ─────────────────────────────────────────────────────
base_sim = []
for s in signals:
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
base_sim.append({**s, **r})
base_taken, _ = pos_limit(base_sim)
base_total = sum(krw(r) for r in base_taken)
base_wr = sum(1 for r in base_taken if r['pnl'] > 0) / len(base_taken) * 100
print(f"{''*70}")
print(f" [기준] 현재전략 TP2%+Trail: {len(base_taken)}"
f"승률 {base_wr:.0f}% 합산 {base_total:+,.0f}")
# ── 비교: limit 0.5%/2봉 → TP/Trail ─────────────────────────────────────
lim_sim = []
for s in signals:
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'])
lim_sim.append({**s, **r})
lim_taken, _ = pos_limit(lim_sim)
lim_total = sum(krw(r) for r in lim_taken)
lim_wr = sum(1 for r in lim_taken if r['pnl'] > 0) / len(lim_taken) * 100
print(f" [비교] limit 0.5%/2봉→TP/Trail: {len(lim_taken)}"
f"승률 {lim_wr:.0f}% 합산 {lim_total:+,.0f}")
print(f"{''*70}\n")
# ── 캐스케이드 (15봉 / 30봉) ─────────────────────────────────────────────
for last_n in [15, 30]:
label = f"cascade ①2봉+2% → ②3봉+1% → ③{last_n}봉+0.5% → ④기존전략"
csim = []
for s in signals:
r = sim_cascade(s['bars'], s['entry_price'], s['atr_raw'], last_n)
csim.append({**s, **r})
taken, _ = pos_limit(csim)
print_cascade_detail(taken, last_n, label)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,448 @@
"""현재 전략 기준 45일 복리 시뮬레이션 — 40분봉.
sim_45m40.py의 검증된 코어 로직을 기반으로
현재 전략 추가사항만 반영:
+ F&G 필터 (FNG_MIN_ENTRY=41)
+ 시장 레짐 필터 (BEAR < -0.5% → 차단, BULL ≥ 1.5% → vol_mult 완화)
+ 신호 강도별 진입 임계값 티어 (5x→1%, 3.5x→2%, 2.5x→3%, 기본→5%)
+ 속도 기반 조기 진입 (0.10%/분)
"""
import json
import os
import pickle
import sys
import urllib.request
import datetime
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
sys.path.insert(0, str(Path(__file__).parent.parent))
# ── 파라미터 ─────────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim10m_cache.pkl"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT= 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_NEUTRAL = 2.0 # NEUTRAL 레짐
VOL_MULT_BULL = 1.5 # BULL 레짐
QUIET_PCT = 2.0
THRESH_BASE = 5.0 # 기본 진입 임계값 (TREND_AFTER_VOL)
# 신호 강도별 임계값 티어
ENTRY_TIERS = [(5.0, 1.0), (3.5, 2.0), (2.5, 3.0)]
# 속도 진입
VELOCITY_THRESHOLD = 0.10 # %/분
VELOCITY_MIN_MOVE = 0.5 # 최소 이동 %
VELOCITY_MIN_AGE_M = 5.0 # 최소 경과 분
# F&G
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41"))
# 레짐
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
# 40분봉 봉수 환산
LOCAL_VOL_N = 7 # 5h
QUIET_N = 3 # 2h
SIGNAL_TO_N = 12 # 8h
ATR_N = 7
TS_N = 12 # 8h (타임스탑)
REGIME_N = 3 # 2h (레짐 추세)
# ── F&G 히스토리 ─────────────────────────────────────────
def load_fng_history() -> dict:
try:
url = "https://api.alternative.me/fng/?limit=90&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
result = {}
for e in data["data"]:
dt = datetime.datetime.fromtimestamp(int(e["timestamp"]))
result[dt.strftime("%Y-%m-%d")] = int(e["value"])
return result
except Exception as ex:
print(f" [경고] F&G 로드 실패: {ex} → 필터 비활성화")
return {}
# ── 리샘플링 ─────────────────────────────────────────────
def resample_40m(df: pd.DataFrame) -> pd.DataFrame:
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# ── 레짐 시리즈 ──────────────────────────────────────────
def build_regime_series(dfs: dict) -> pd.Series:
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs:
continue
pct = dfs[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
# ── 임계값 ───────────────────────────────────────────────
def calc_entry_threshold(vol_ratio: float) -> float:
for min_r, thr in ENTRY_TIERS:
if vol_ratio >= min_r:
return thr
return THRESH_BASE
# ── ATR ──────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, buy_idx: int) -> float:
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 (기존 sim_45m40.py와 동일) ───────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl, "trailing_stop"
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl, "time_stop"
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl, "end_of_data"
# ── vol-lead 전략 (현재 전략 파라미터 전체 반영) ──────────
def run_vol_lead(df: pd.DataFrame, ticker: str,
fng_map: dict, regime_series: pd.Series) -> list:
trades = []
sig_i = None # 신호 봉 인덱스
sig_p = None # 신호가
sig_vr = 0.0 # 신호 vol_ratio
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── 포지션 보유 중: 청산 체크 ─────────────────────
if in_pos:
is_win, sdt, pnl, reason = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker, reason))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
# ── F&G 필터 ──────────────────────────────────────
date_str = ts.strftime("%Y-%m-%d")
if fng_map:
fv = fng_map.get(date_str, 50)
if fv < FNG_MIN_ENTRY:
sig_i = sig_p = None # 신호 초기화
i += 1
continue
# ── 레짐 필터 ─────────────────────────────────────
score = 0.0
if not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
if score < BEAR_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_NEUTRAL
# ── 신호 타임아웃 ──────────────────────────────────
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
# ── 신호 있음: 진입 체크 ──────────────────────────
if sig_i is not None:
move_pct = (cur - sig_p) / sig_p * 100
age_min = (i - sig_i) * 40 # 봉수 → 분
entry_thr = calc_entry_threshold(sig_vr)
if cur < sig_p:
# 신호가 이하 하락 → 초기화
sig_i = sig_p = None
elif move_pct >= entry_thr:
# 거리 기반 진입
in_pos = True
buy_idx = i
buy_price = cur
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
elif age_min >= VELOCITY_MIN_AGE_M and move_pct >= VELOCITY_MIN_MOVE:
velocity = move_pct / age_min
if velocity >= VELOCITY_THRESHOLD:
# 속도 기반 조기 진입
in_pos = True
buy_idx = i
buy_price = cur
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
i += 1
continue
# ── 신호 없음: 축적 조건 체크 ────────────────────
vol_p = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - 1 - LOCAL_VOL_N:i - 1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i - QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
quiet = chg_qh < QUIET_PCT
spike = vol_r >= vol_mult
if quiet and spike:
if sig_i is None:
sig_i = i
sig_p = cur
sig_vr = vol_r
else:
if sig_i is not None and cur < sig_p:
sig_i = sig_p = None
i += 1
return trades
# ── WF 필터 (기존 동일) ──────────────────────────────────
def apply_wf(trades: list) -> tuple:
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── MAX_POSITIONS (기존 동일) ────────────────────────────
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 (기존 동일) ───────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
reason_cnt = {}
for trade in accepted:
is_win, pnl, buy_dt, sell_dt, ticker, reason = trade
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
reason_cnt[reason] = reason_cnt.get(reason, 0) + 1
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt, "sell_dt": sell_dt, "ticker": ticker,
"is_win": is_win, "pnl_pct": pnl, "reason": reason,
"pos_size": pos_size, "krw_profit": krw_profit,
"portfolio": portfolio,
})
wins = sum(1 for t in accepted if t[0])
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted), "wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly, "trade_log": trade_log,
"reason_cnt": reason_cnt,
}
# ── 메인 ─────────────────────────────────────────────────
def main():
print("=" * 62)
print("현재 전략 기준 시뮬 (F&G + 레짐 + 티어임계 + 속도진입)")
print("=" * 62)
print("F&G 히스토리 로드...")
fng_map = load_fng_history()
if fng_map:
vals = sorted(fng_map.items())
print(f" {vals[0][0]} ~ {vals[-1][0]} ({len(fng_map)}일)")
else:
print(" F&G 데이터 없음 — 필터 비활성화")
print("캐시 로드...")
cache = pickle.load(open(CACHE_FILE, "rb"))
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 200]
print(f" 종목: {len(tickers)}\n")
dfs_40m = {t: resample_40m(cache["10m"][t]) for t in tickers}
print("레짐 시리즈 계산...")
regime_series = build_regime_series(dfs_40m)
sample_df = next(iter(dfs_40m.values()))
start_date = sample_df.index[0].strftime("%Y-%m-%d")
end_date = sample_df.index[-1].strftime("%Y-%m-%d")
print(f" 기간: {start_date} ~ {end_date}\n")
# F&G 차단 일수
if fng_map:
period_dates = [d for d in fng_map if start_date <= d <= end_date]
fng_blocked = sum(1 for d in period_dates if fng_map.get(d, 50) < FNG_MIN_ENTRY)
fng_allowed = len(period_dates) - fng_blocked
else:
fng_blocked = fng_allowed = 0
all_trades = []
wf_blocked = 0
for ticker in tickers:
df40 = dfs_40m[ticker]
raw = run_vol_lead(df40, ticker, fng_map, regime_series)
filtered, blocked = apply_wf(raw)
wf_blocked += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2]) # buy_dt 기준 정렬
accepted, skipped = apply_max_positions(all_trades)
result = simulate(accepted)
# 최대 낙폭
peak = BUDGET
max_dd = 0.0
for t in result["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
total = result["total"]
wins = result["wins"]
print(f"{'='*62}")
print(f" 기간: {start_date} ~ {end_date} ({len(tickers)}종목 / 40분봉)")
print(f" F&G 차단: {fng_blocked}일 / 허용: {fng_allowed}일 (기준 FNG≥{FNG_MIN_ENTRY})")
print(f"{'='*62}")
print(f" 신호 발생: {len(all_trades)+wf_blocked:>4}건 (WF 차단: {wf_blocked}건)")
print(f" 실제 진입: {total:>4}건 ({len(skipped)}건 MAX_POS 스킵)")
print(f" 승 / 패: {wins}{total-wins}패 (승률 {result['wr']:.1f}%)"
if total else " 진입 없음")
print(f" {''*52}")
print(f" 초기 예산: {BUDGET:>15,}")
print(f" 최종 자산: {result['portfolio']:>15,.0f}")
print(f" 순수익: {result['total_krw']:>+15,.0f}")
print(f" 수익률: {result['roi_pct']:>+14.2f}%")
print(f" 최대 낙폭: {-max_dd:>+14.2f}%"
f" ({-max_dd/100*BUDGET:>+,.0f}원)")
monthly_krw = [m["pnl_krw"] for m in result["monthly"].values()]
avg_m = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0
print(f" 월평균 수익: {avg_m:>+13,.0f}")
print(f"\n── 청산 사유 {''*44}")
label_map = {"trailing_stop": "트레일링스탑", "time_stop": "타임스탑",
"end_of_data": "데이터종료"}
for r, cnt in sorted(result["reason_cnt"].items(), key=lambda x: -x[1]):
print(f" {label_map.get(r, r):12}: {cnt:>3}")
print(f"\n── 월별 수익 {''*44}")
print(f" {'':^8}{'거래':>4} {'승률':>5}"
f" {'월수익(KRW)':>13} {'누적수익(KRW)':>14}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │"
f" {m['pnl_krw']:>+13,.0f}{cum:>+14,.0f}")
print(f"\n── 파라미터 {''*46}")
print(f" F&G≥{FNG_MIN_ENTRY} 레짐BEAR<{BEAR_THRESHOLD}% BULL≥{BULL_THRESHOLD}%")
print(f" VOL: {VOL_MULT_NEUTRAL}x(중립)/{VOL_MULT_BULL}x(강세) 횡보<{QUIET_PCT}%")
print(f" 임계: 5x→1% / 3.5x→2% / 2.5x→3% / 기본→{THRESH_BASE}%")
print(f" 속도: ≥{VELOCITY_THRESHOLD}%/분 (≥{VELOCITY_MIN_MOVE}% / ≥{VELOCITY_MIN_AGE_M}분)")
print(f" ATR: ×{ATR_MULT} ({ATR_MIN*100:.0f}~{ATR_MAX*100:.0f}%) 타임스탑: 8h/{TIME_STOP_MIN_PCT}%")
print(f"{'='*62}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,265 @@
"""진입 즉시 limit 매도 주문 → N봉 내 미체결 시 TP/Trail 전환 시뮬 (30일).
전략:
1. 3봉 vol가속 시그널 → 진입
2. 즉시 limit_price = entry_price × (1 + limit_r) 에 limit 매도 주문
3. N봉 안에 high_p >= limit_price → 체결 (limit_price에 청산)
4. N봉 안에 미체결 → TP 2% + Trail Stop 으로 전환
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2
AND close_p>pc1 AND pc1>pc2
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
def fetch_signals(cur, warmup_since, check_since):
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
"""기본 전략: TP + Trail Stop."""
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status=f'TP{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_limit_then_trail(bars, ep, ar, n_bars, limit_r, tp_r=0.02):
"""진입 즉시 limit_price에 매도 주문 → N봉 내 체결 안되면 TP/Trail 전환.
체결 조건: high_p >= limit_price → limit_price에 청산 (실현 가능한 가격)
"""
limit_price = ep * (1 + limit_r)
window = bars[:n_bars]
for i, (ts, cp, hp, lp) in enumerate(window):
if hp >= limit_price:
pnl = (limit_price - ep) / ep * 100
return dict(status=f'limit체결({n_bars}봉)', exit_ts=ts,
exit_price=limit_price, pnl=pnl, held=i + 1)
# N봉 내 미체결 → TP/Trail 전환
fallback = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
fallback['status'] = f'미체결→{fallback["status"]}'
fallback['held'] += n_bars
return fallback
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def stats(taken):
n = len(taken)
if n == 0:
return None
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
fill_n = sum(1 for r in taken if 'limit체결' in r['status'])
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0,
fill_r=fill_n/n*100)
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== Limit 주문 전략 시뮬 (3봉 진입) ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
signals = fetch_signals(cur, warmup_since, check_since)
print(f"시그널 {len(signals)}\n")
# 비교 전략 목록: (label, limit_r, n_bars)
strategies = [
('현재전략: TP 2% + Trail', None, None, None),
('limit 0.5% / 2봉, 미체결→TP/Trail', 0.005, 2, 0.02),
('limit 0.5% / 3봉, 미체결→TP/Trail', 0.005, 3, 0.02),
('limit 0.5% / 5봉, 미체결→TP/Trail', 0.005, 5, 0.02),
('limit 1.0% / 2봉, 미체결→TP/Trail', 0.010, 2, 0.02),
('limit 1.0% / 3봉, 미체결→TP/Trail', 0.010, 3, 0.02),
('limit 1.0% / 5봉, 미체결→TP/Trail', 0.010, 5, 0.02),
('limit 1.5% / 2봉, 미체결→TP/Trail', 0.015, 2, 0.02),
('limit 1.5% / 3봉, 미체결→TP/Trail', 0.015, 3, 0.02),
('limit 1.5% / 5봉, 미체결→TP/Trail', 0.015, 5, 0.02),
('limit 2.0% / 3봉, 미체결→TP/Trail', 0.020, 3, 0.02),
('limit 2.0% / 5봉, 미체결→TP/Trail', 0.020, 5, 0.02),
]
results = {}
for label, limit_r, n_bars, tp_r in strategies:
sim = []
for s in signals:
if limit_r is None:
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
else:
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'],
n_bars, limit_r, tp_r)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
results[label] = taken
# ── 요약표 ──────────────────────────────────────────────────────────────────
print(f"{''*120}")
print(f" {'전략':40s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
f"{'평균보유':>5s} {'체결률':>5s} {'평균수익':>6s} {'평균손실':>6s}")
print(f"{''*120}")
for label, limit_r, n_bars, tp_r in strategies:
taken = results[label]
s = stats(taken)
if not s:
continue
print(f" {label:40s} {s['n']:>3d}{s['wr']:>4.0f}% {s['total']:>+12,.0f}"
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}{s['fill_r']:>4.0f}% "
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
print(f"{''*120}")
# ── 최고 전략 상세 ──────────────────────────────────────────────────────────
best_label = max(results, key=lambda k: sum(PER_POS * (r['pnl']/100) - PER_POS*FEE*2
for r in results[k]))
print(f"\n[최고 전략: {best_label} 건별 상세]")
print(f"{''*110}")
for i, r in enumerate(results[best_label], 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"진입 {r['entry_price']:>10,.0f}"
f"{r['status']:22s} {r['held']:3d}{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,253 @@
"""진입 후 N봉 내 최고가 청산 전략 시뮬 (30일).
전략: 3봉 vol가속 진입 → N봉 내 최고 고가에서 매도
비교: 현재 전략 (TP 2% + Trail Stop)
"""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
pc1,po1,pc2,po2,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2
AND close_p>pc1 AND pc1>pc2
AND vr0>vr1 AND vr1>vr2
ORDER BY ticker, ts
"""
def fetch_signals(cur, warmup_since, check_since):
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
# 최대 10봉만 필요 (peak exit용) + trail stop용 241봉
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
return signals
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
"""기본 전략: TP 2% + Trail Stop."""
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status='익절+2%', exit_ts=ts, exit_price=tp, pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_peak_then_trail(bars, ep, ar, n_bars, tp_r=0.02):
"""진입 후 n_bars 봉 내 이익 구간이 나오면 고가 청산.
이익 없으면 TP 2% + Trail Stop으로 계속 운영.
이익 판단: n_bars 내 어느 봉이든 high_p > entry_price 이면
그 구간의 최고 고가에서 청산.
"""
window = bars[:n_bars]
# n봉 내 이익 구간 탐색
best_high = max((hp for _, _, hp, _ in window), default=ep)
if best_high > ep:
# 이익 나는 최고 고가에서 청산
best_ts = next(ts for ts, cp, hp, lp in window if hp == best_high)
held = next(i + 1 for i, (ts, cp, hp, lp) in enumerate(window) if hp == best_high)
pnl = (best_high - ep) / ep * 100
return dict(status=f'피크청산({n_bars}봉)', exit_ts=best_ts, exit_price=best_high,
pnl=pnl, held=held)
# n봉 내 이익 없음 → TP 2% + Trail Stop으로 전환
return sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def stats(taken):
n = len(taken)
if n == 0:
return None
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0)
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
print(f"=== 3봉 진입 후 N봉 피크청산 전략 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
signals = fetch_signals(cur, warmup_since, check_since)
print(f"시그널 {len(signals)}\n")
strategies = [
('현재전략: TP 2% + Trail Stop', 'tp_trail', None),
('2봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 2 ),
('3봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 3 ),
('5봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 5 ),
]
results = {}
for label, mode, param in strategies:
sim = []
for s in signals:
if mode == 'tp_trail':
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
else:
r = sim_peak_then_trail(s['bars'], s['entry_price'], s['atr_raw'], param)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
results[label] = taken
# ── 요약표 ──────────────────────────────────────────────────────────────────
print(f"{''*105}")
print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
f"{'평균보유':>5s} {'평균수익':>6s} {'평균손실':>6s}")
print(f"{''*105}")
for label, mode, param in strategies:
taken = results[label]
s = stats(taken)
if not s:
continue
print(f" {label:35s} {s['n']:>3d}{s['wr']:>4.0f}% {s['total']:>+12,.0f}"
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}"
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
print(f"{''*105}")
# ── 3봉 피크청산 상세 ────────────────────────────────────────────────────────
label_3 = '3봉 이익시 피크청산, 아니면 TP/Trail'
print(f"\n[{label_3} 건별 상세]")
print(f"{''*100}")
for i, r in enumerate(results[label_3], 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"진입 {r['entry_price']:>10,.0f}"
f"고가청산 {r['exit_price']:>10,.0f}"
f"{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,363 @@
"""DB 기반 하이브리드 시뮬레이션 (신호=10분봉 / 스탑=1분봉).
실행 흐름:
1. pyupbit에서 10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert
2. BACKTEST_OHLCV에서 데이터 로드
3. 하이브리드 시뮬 실행
- 10분봉 타임인덱스로 신호 감지 / 진입
- 각 10분봉 구간 내 1분봉으로 트레일링스탑 체크
4. 결과 출력
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import time
import pyupbit
import pandas as pd
import numpy as np
import oracledb
# ── Oracle 연결 ─────────────────────────────────────────────────────────────
def _get_conn():
kwargs = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
)
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── 전략 파라미터 ────────────────────────────────────────────────────────────
LOCAL_VOL_N = 28
QUIET_N = 12
QUIET_PCT = 2.0
THRESH = 4.8
SIGNAL_TO = 48
VOL_THRESH = 6.0
FNG = 14
ATR_N = 28
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
TS_N = 48
TIME_STOP_PCT = 3.0
BUDGET = 15_000_000
MAX_POS = 3
FEE = 0.0005
PER_POS = BUDGET // MAX_POS
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
SIM_START = '2026-03-03 00:00:00'
SIM_END = '2026-03-04 13:30:00'
# ── BACKTEST_OHLCV 공통 함수 ─────────────────────────────────────────────────
def upsert_ohlcv(conn, ticker: str, interval_cd: str, df: pd.DataFrame) -> int:
"""DataFrame을 BACKTEST_OHLCV에 저장 (기존 TS 스킵). 반환: 신규 삽입 건수."""
cur = conn.cursor()
min_ts = df.index.min().to_pydatetime()
cur.execute(
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd=:iv AND ts >= :since",
{"t": ticker, "iv": interval_cd, "since": min_ts}
)
existing = {r[0] for r in cur.fetchall()}
new_rows = [
(ticker, interval_cd, ts_idx.to_pydatetime(),
float(row["open"]), float(row["high"]), float(row["low"]),
float(row["close"]), float(row["volume"]))
for ts_idx, row in df.iterrows()
if ts_idx.to_pydatetime() not in existing
]
if not new_rows:
return 0
cur.executemany(
"INSERT INTO backtest_ohlcv (ticker, interval_cd, ts, open_p, high_p, low_p, close_p, volume_p) "
"VALUES (:1, :2, :3, :4, :5, :6, :7, :8)",
new_rows
)
conn.commit()
return len(new_rows)
def load_from_db(conn, ticker: str, interval_cd: str, since: str):
"""BACKTEST_OHLCV에서 ticker의 OHLCV 로드."""
cur = conn.cursor()
cur.execute(
"SELECT ts, open_p, high_p, low_p, close_p, volume_p "
"FROM backtest_ohlcv "
"WHERE ticker=:ticker AND interval_cd=:iv "
"AND ts >= TO_TIMESTAMP(:since, 'YYYY-MM-DD HH24:MI:SS') "
"ORDER BY ts",
{"ticker": ticker, "iv": interval_cd, "since": since}
)
rows = cur.fetchall()
if not rows:
return None
df = pd.DataFrame(rows, columns=["ts","open","high","low","close","volume"])
df["ts"] = pd.to_datetime(df["ts"])
df.set_index("ts", inplace=True)
return df.astype(float)
# ── 1단계: DB 업데이트 ───────────────────────────────────────────────────────
def refresh_db(conn) -> None:
"""10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert."""
print("BACKTEST_OHLCV 업데이트 중...", flush=True)
total10, total1 = 0, 0
for tk in TICKERS:
try:
# 10분봉
df10 = pyupbit.get_ohlcv(tk, interval='minute10', count=300)
if df10 is not None and len(df10) >= 10:
total10 += upsert_ohlcv(conn, tk, 'minute10', df10)
time.sleep(0.12)
# 1분봉 (2일치 ≈ 2880봉, 내부 자동 페이지네이션)
df1 = pyupbit.get_ohlcv(tk, interval='minute1', count=2880)
if df1 is not None and len(df1) >= 60:
total1 += upsert_ohlcv(conn, tk, 'minute1', df1)
time.sleep(0.15)
print(f" {tk}: 10m={len(df10) if df10 is not None else 0}봉 / "
f"1m={len(df1) if df1 is not None else 0}", flush=True)
except Exception as e:
print(f" {tk} 오류: {e}")
print(f"DB 업데이트 완료: 10분봉 신규 {total10}행 / 1분봉 신규 {total1}\n", flush=True)
# ── ATR 계산 ────────────────────────────────────────────────────────────────
def calc_atr(df, i, n=28):
start = max(0, i - n)
sub = df.iloc[start:i]
if len(sub) < 5:
return ATR_MIN
hi = sub['high'].values
lo = sub['low'].values
cl = sub['close'].values
tr = [max(hi[j]-lo[j], abs(hi[j]-cl[j-1]) if j>0 else 0,
abs(lo[j]-cl[j-1]) if j>0 else 0) for j in range(len(sub))]
atr_pct = np.mean(tr) / cl[-1] if cl[-1] > 0 else ATR_MIN
return max(ATR_MIN, min(ATR_MAX, atr_pct * ATR_MULT))
# ── 하이브리드 시뮬 ──────────────────────────────────────────────────────────
def run_sim(raw10: dict, raw1: dict) -> None:
"""신호=10분봉 / 스탑=1분봉 하이브리드 시뮬."""
# 10분봉 공통 타임인덱스
all_idx10 = None
for df in raw10.values():
all_idx10 = df.index if all_idx10 is None else all_idx10.union(df.index)
all_idx10 = all_idx10.sort_values()
mask = (all_idx10 >= SIM_START) & (all_idx10 <= SIM_END)
sim_idx10 = all_idx10[mask]
if len(sim_idx10) == 0:
print("시뮬 구간 데이터 없음")
return
print(f"시뮬 구간(10분봉): {sim_idx10[0]} ~ {sim_idx10[-1]} ({len(sim_idx10)}봉)")
# 1분봉 커버리지 확인
n1m = sum(
len(df[(df.index >= SIM_START) & (df.index <= SIM_END)])
for df in raw1.values() if len(raw1) > 0
)
print(f"1분봉 총 {n1m}봉 (스탑 체크용)\n")
positions = {} # ticker → {buy_price, peak, entry_i, invested, atr_stop}
signals = {} # ticker → {price, vol_r, sig_i}
trades = []
for i, ts10 in enumerate(sim_idx10):
ts10_prev = sim_idx10[i - 1] if i > 0 else ts10
# ── 1) 1분봉으로 스탑 체크 ────────────────────────────────────────
for tk in list(positions.keys()):
pos = positions[tk]
# 진입 캔들 종료 전엔 체크 금지
# 10분봉 ts=X 는 [X, X+10min) 구간이므로 실제 진입은 X+9:59
# → X+10min 이후 1분봉부터 체크
entry_candle_end = sim_idx10[pos['entry_i']] + pd.Timedelta(minutes=10)
if tk not in raw1:
# 1분봉 없으면 10분봉 종가로 fallback (단, 진입 다음 봉부터)
if ts10 <= sim_idx10[pos['entry_i']]:
continue
if tk not in raw10 or ts10 not in raw10[tk].index:
continue
current = float(raw10[tk].loc[ts10, 'close'])
_check_stop(pos, tk, current, ts10, i, sim_idx10, trades, positions)
continue
df1 = raw1[tk]
# 진입 캔들 종료(entry_candle_end) 이후 + 현재 10분봉 구간 이내
mask1 = (df1.index >= entry_candle_end) & (df1.index > ts10_prev) & (df1.index <= ts10)
sub1 = df1[mask1]
for ts1m, row1m in sub1.iterrows():
current = float(row1m['close'])
if _check_stop(pos, tk, current, ts1m, i, sim_idx10, trades, positions):
break # 이미 청산됨
# ── 2) 신호 만료 ────────────────────────────────────────────────
for tk in list(signals.keys()):
if i - signals[tk]['sig_i'] > SIGNAL_TO:
del signals[tk]
# ── 3) 신호 감지 + 진입 (10분봉 기준) ──────────────────────────
for tk in TICKERS:
if tk in positions: continue
if len(positions) >= MAX_POS: break
if tk not in raw10: continue
df10 = raw10[tk]
if ts10 not in df10.index: continue
loc = df10.index.get_loc(ts10)
if loc < LOCAL_VOL_N + QUIET_N + 2: continue
vol_prev = float(df10['volume'].iloc[loc - 1])
vol_avg = float(df10['volume'].iloc[loc - LOCAL_VOL_N - 1:loc - 1].mean())
vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0
current = float(df10['close'].iloc[loc])
close_qn = float(df10['close'].iloc[loc - QUIET_N])
chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0
if vol_r >= VOL_THRESH and chg < QUIET_PCT:
if tk not in signals or vol_r > signals[tk]['vol_r']:
signals[tk] = {'price': current, 'vol_r': vol_r, 'sig_i': i}
if tk in signals:
sig_p = signals[tk]['price']
move = (current - sig_p) / sig_p * 100
if move >= THRESH:
atr_stop = calc_atr(df10, loc, ATR_N)
positions[tk] = {
'buy_price': current, 'peak': current,
'entry_i': i, 'invested': PER_POS,
'atr_stop': atr_stop,
}
del signals[tk]
# ── 미청산 강제 청산 ─────────────────────────────────────────────────────
last_ts10 = sim_idx10[-1]
for tk, pos in list(positions.items()):
if tk not in raw10: continue
df10 = raw10[tk]
current = float(df10.loc[last_ts10, 'close']) if last_ts10 in df10.index else pos['buy_price']
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
trades.append({
'date': last_ts10.date(), 'ticker': tk,
'buy_price': pos['buy_price'], 'sell_price': current,
'pnl_pct': pnl, 'krw': krw, 'reason': '미청산(현재가)',
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': last_ts10,
})
# ── 결과 출력 ────────────────────────────────────────────────────────────
if not trades:
print("거래 없음")
return
import collections
by_date = collections.defaultdict(list)
for t in trades:
by_date[t['date']].append(t)
total_krw, total_wins = 0, 0
for date in sorted(by_date.keys()):
day_trades = by_date[date]
day_krw = sum(t['krw'] for t in day_trades)
day_wins = sum(1 for t in day_trades if t['pnl_pct'] > 0)
total_krw += day_krw
total_wins += day_wins
print(f"\n{'='*62}")
print(f"{date}{len(day_trades)}건 | 승률={day_wins/len(day_trades)*100:.0f}% | 일손익={day_krw:+,.0f}")
print(f"{'='*62}")
for t in sorted(day_trades, key=lambda x: x['entry_ts']):
e = '' if t['pnl_pct'] > 0 else ''
print(f" {e} {t['ticker']:12s} "
f"매수={t['buy_price']:,.0f} @ {str(t['entry_ts'])[5:16]}"
f"매도={t['sell_price']:,.0f} @ {str(t['exit_ts'])[5:16]} "
f"| {t['pnl_pct']:+.1f}% ({t['krw']:+,.0f}원) [{t['reason']}]")
print(f"\n{'='*62}")
print(f"【2일 합계】 {len(trades)}건 | "
f"승률={total_wins/len(trades)*100:.0f}% | 총손익={total_krw:+,.0f}")
print(f" [1분봉 스탑 시뮬] VOL≥{VOL_THRESH}x + 횡보<{QUIET_PCT}% → +{THRESH}% 진입 (F&G={FNG})")
def _check_stop(pos, tk, current, ts, i, sim_idx10, trades, positions) -> bool:
"""스탑 체크. 청산 시 True 반환."""
if current > pos['peak']:
pos['peak'] = current
age = i - pos['entry_i']
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
peak_drop = (pos['peak'] - current) / pos['peak'] * 100
atr_stop = pos['atr_stop']
reason = None
if peak_drop >= atr_stop * 100:
reason = f"트레일링스탑({atr_stop*100:.1f}%)"
elif age >= TS_N and pnl < TIME_STOP_PCT:
reason = f"타임스탑(+{pnl:.1f}%<{TIME_STOP_PCT}%)"
if reason:
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
trades.append({
'date': ts.date() if hasattr(ts, 'date') else ts,
'ticker': tk,
'buy_price': pos['buy_price'], 'sell_price': current,
'pnl_pct': pnl, 'krw': krw, 'reason': reason,
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': ts,
})
del positions[tk]
return True
return False
# ── main ─────────────────────────────────────────────────────────────────────
conn = _get_conn()
# 1) DB 업데이트
refresh_db(conn)
# 2) DB에서 데이터 로드
print("DB에서 OHLCV 로드 중...", flush=True)
LOAD_SINCE = '2026-03-01 00:00:00'
raw10, raw1 = {}, {}
for tk in TICKERS:
df10 = load_from_db(conn, tk, 'minute10', LOAD_SINCE)
if df10 is not None and len(df10) > LOCAL_VOL_N + QUIET_N:
raw10[tk] = df10
df1 = load_from_db(conn, tk, 'minute1', LOAD_SINCE)
if df1 is not None and len(df1) > 60:
raw1[tk] = df1
print(f"로드 완료: 10분봉 {len(raw10)}종목 / 1분봉 {len(raw1)}종목\n")
conn.close()
# 3) 시뮬 실행
print("="*62)
print(f"하이브리드 시뮬 (신호=10분봉 / 스탑=1분봉) | 2026-03-03 ~ 03-04")
print("="*62)
run_sim(raw10, raw1)

View File

@@ -0,0 +1,474 @@
"""레짐 기반 1년 시뮬레이션 — BULL 진입 vs Bear차단 vs 필터없음.
sim_45m40.py 검증된 코어 로직 기반.
데이터: data/sim1y_cache.pkl (10분봉 1년치)
data/fng_1y.json (F&G 1년치)
비교 구성:
1. 필터 없음 — 레짐/F&G 무관 진입
2. BEAR 차단 — 레짐 score < -0.5% 이면 차단 (현재 전략)
3. BULL 진입만 — 레짐 score ≥ 1.5% 일 때만 진입 ← 사용자 제안
4. BULL 진입 + F&G≥41 — BULL 조건에 F&G 필터 추가
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
# ── 데이터 경로 ───────────────────────────────────────────
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
# ── 전략 파라미터 (sim_45m40.py 동일) ────────────────────
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_DEFAULT = 2.0 # 기본 (NEUTRAL / 필터없음)
VOL_MULT_BULL = 1.5 # BULL 레짐 완화
QUIET_PCT = 2.0
THRESH = 4.8 # sim_45m40.py 기준값
# 40분봉 봉수 환산
LOCAL_VOL_N = 7 # 5h
QUIET_N = 3 # 2h
SIGNAL_TO_N = 12 # 8h
ATR_N = 7
TS_N = 12 # 8h (타임스탑)
REGIME_N = 3 # 2h (레짐 추세)
# 레짐 임계
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
# 레짐 계산 가중치
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
# WF 파라미터
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── 리샘플링 ─────────────────────────────────────────────
def resample_40m(df: pd.DataFrame) -> pd.DataFrame:
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# ── 레짐 시리즈 ──────────────────────────────────────────
def build_regime_series(dfs40: dict) -> pd.Series:
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40:
continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
# ── ATR ──────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, buy_idx: int) -> float:
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl
# ── 전략 실행 ─────────────────────────────────────────────
def run_strategy(df: pd.DataFrame, ticker: str,
regime_series: pd.Series, fng_map: dict,
mode: str) -> list:
"""
mode:
'none' — 레짐/F&G 필터 없음
'bear_off' — BEAR 차단만 (score < BEAR_THRESHOLD 시 스킵)
'bull_only'— BULL 진입만 (score >= BULL_THRESHOLD 일 때만)
'bull_fng' — BULL + F&G≥41
"""
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── 포지션 보유 중 ────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
# ── 레짐 스코어 계산 ─────────────────────────────
score = 0.0
if not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
# ── 모드별 진입 필터 ─────────────────────────────
if mode == "bear_off":
if score < BEAR_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_DEFAULT
elif mode == "bull_only":
if score < BULL_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL
elif mode == "bull_fng":
if score < BULL_THRESHOLD:
sig_i = sig_p = None
i += 1
continue
date_str = ts.strftime("%Y-%m-%d")
fv = fng_map.get(date_str, 50) if fng_map else 50
if fv < 41:
sig_i = sig_p = None
i += 1
continue
vol_mult = VOL_MULT_BULL
else: # 'none'
vol_mult = VOL_MULT_DEFAULT
# ── 신호 타임아웃 ─────────────────────────────────
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
# ── 신호 있음: 진입 체크 ──────────────────────────
if sig_i is not None:
move_pct = (cur - sig_p) / sig_p * 100
if cur < sig_p:
sig_i = sig_p = None
elif move_pct >= THRESH:
in_pos = True
buy_idx = i
buy_price = cur
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
i += 1
continue
# ── 신호 없음: 축적 조건 체크 ────────────────────
vol_p = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - 1 - LOCAL_VOL_N:i - 1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i - QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
quiet = chg_qh < QUIET_PCT
spike = vol_r >= vol_mult
if quiet and spike:
if sig_i is None:
sig_i = i
sig_p = cur
else:
if sig_i is not None and cur < sig_p:
sig_i = sig_p = None
i += 1
return trades
# ── WF 필터 ──────────────────────────────────────────────
def apply_wf(trades: list) -> tuple:
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
wr = sum(history[-WF_WINDOW:]) / WF_WINDOW
if wr < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS ────────────────────────────────────────
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 ────────────────────────────────────────────
def run_compound(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({"buy_dt": buy_dt, "sell_dt": sell_dt,
"ticker": ticker, "is_win": is_win,
"pnl_pct": pnl, "portfolio": portfolio})
wins = sum(1 for t in accepted if t[0])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
# ── 결과 출력 ────────────────────────────────────────────
def print_result(label: str, result: dict, skipped: int, wf_blocked: int):
r = result
peak = BUDGET
max_dd = 0.0
for t in r["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
monthly_krw = [m["pnl_krw"] for m in r["monthly"].values()]
avg_m = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0
total = r["total"]
wins = r["wins"]
print(f"\n{''*60}")
print(f" [{label}]")
print(f" 진입: {total}건 (WF차단: {wf_blocked} / MAX_POS스킵: {skipped})")
if total:
print(f" 승패: {wins}{total-wins}패 (승률 {r['wr']:.1f}%)")
print(f" 초기 예산: {BUDGET:>15,}")
print(f" 최종 자산: {r['portfolio']:>15,.0f}")
print(f" 순수익: {r['total_krw']:>+15,.0f}")
print(f" 수익률: {r['roi_pct']:>+14.2f}%")
print(f" 최대 낙폭: {-max_dd:>+14.2f}%")
print(f" 월평균 수익: {avg_m:>+13,.0f}")
def print_monthly(result: dict):
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>13} {'누적수익(KRW)':>14}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │"
f" {m['pnl_krw']:>+13,.0f}{cum:>+14,.0f}")
# ── 메인 ─────────────────────────────────────────────────
def main():
# ── 데이터 로드 ───────────────────────────────────────
if not CACHE_FILE.exists():
print(f"[오류] 캐시 없음: {CACHE_FILE}")
print(" 먼저 tests/collect_1y_data.py 를 실행하세요.")
return
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
all_tickers = list(cache["10m"].keys())[:TOP_N]
tickers = [t for t in all_tickers if len(cache["10m"][t]) > 500]
print(f" 유효 종목: {len(tickers)}")
# F&G 로드
fng_map: dict = {}
if FNG_FILE.exists():
fng_map = json.load(open(FNG_FILE))
fng_dates = sorted(fng_map.keys())
print(f" F&G: {fng_dates[0]} ~ {fng_dates[-1]} ({len(fng_map)}일)")
else:
print(" [경고] F&G 데이터 없음")
# 리샘플링
dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers}
# 레짐 시리즈
regime_series = build_regime_series(dfs40)
# 기간 정보
sample = next(iter(dfs40.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
# 레짐 분포 계산
if not regime_series.empty:
valid = regime_series.dropna()
bull_cnt = (valid >= BULL_THRESHOLD).sum()
bear_cnt = (valid < BEAR_THRESHOLD).sum()
neut_cnt = len(valid) - bull_cnt - bear_cnt
total_cnt = len(valid)
print(f"\n 레짐 분포 ({total_cnt}봉 기준):")
print(f" BULL (≥{BULL_THRESHOLD}%) : {bull_cnt:>6}봉 ({bull_cnt/total_cnt*100:.1f}%)")
print(f" NEUTRAL : {neut_cnt:>6}봉 ({neut_cnt/total_cnt*100:.1f}%)")
print(f" BEAR (<{BEAR_THRESHOLD}%) : {bear_cnt:>6}봉 ({bear_cnt/total_cnt*100:.1f}%)")
# F&G 분포 (해당 기간)
if fng_map:
period_fng = {k: v for k, v in fng_map.items() if start_dt <= k <= end_dt}
zones = {"극공포(≤25)": 0, "공포(26~40)": 0, "중립+(≥41)": 0}
for v in period_fng.values():
if v <= 25: zones["극공포(≤25)"] += 1
elif v <= 40: zones["공포(26~40)"] += 1
else: zones["중립+(≥41)"] += 1
tot = sum(zones.values())
print(f"\n F&G 분포 (동 기간 {tot}일):")
for name, cnt in zones.items():
print(f" {name:12} {cnt:>3}일 ({cnt/tot*100:.1f}%)")
print(f"\n{'='*60}")
print(f" 레짐 BULL 진입 시뮬 | 1년 | {len(tickers)}종목 | 40분봉")
print(f" 기간: {start_dt} ~ {end_dt}")
print(f"{'='*60}")
# ── 4가지 시뮬 실행 ───────────────────────────────────
CONFIGS = [
("none", "필터 없음"),
("bear_off", "BEAR 차단 (현재)"),
("bull_only","BULL 진입만"),
("bull_fng", "BULL + F&G≥41"),
]
results = {}
for mode, label in CONFIGS:
all_trades = []
wf_total = 0
for ticker in tickers:
df40 = dfs40[ticker]
raw = run_strategy(df40, ticker, regime_series, fng_map, mode)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_positions(all_trades)
result = run_compound(accepted)
results[label] = result
print_result(label, result, len(skipped), wf_total)
# ── 요약 비교 ─────────────────────────────────────────
print(f"\n{'='*60}")
print(f" 요약 비교")
print(f"{'='*60}")
print(f" {'구성':<22} {'진입':>5} {'승률':>6} {'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}")
print(f" {''*58}")
for mode, label in CONFIGS:
r = results[label]
total = r["total"]
if total == 0:
print(f" {label:<22} {'진입없음':>34}")
continue
peak = BUDGET
max_dd = 0.0
for t in r["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
print(
f" {label:<22} {total:>5}{r['wr']:>5.1f}% "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{max_dd:.1f}%"
)
# ── 월별 상세 (BULL 진입만) ───────────────────────────
print(f"\n{'='*60}")
print(f" 월별 상세 — BULL 진입만")
print(f"{'='*60}")
print_monthly(results["BULL 진입만"])
print(f"\n 월별 상세 — BEAR 차단 (현재)")
print_monthly(results["BEAR 차단 (현재)"])
print(f"{'='*60}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,322 @@
"""레짐 REGIME_N 스윕 — BULL 진입 기준 봉수 최적화.
REGIME_N (pct_change 봉수) 를 1~8봉(40분~320분) 으로 변화시키며
BULL 진입만 / BEAR 차단 / 필터없음 비교.
데이터: data/sim1y_cache.pkl (10분봉 1년치)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_DEFAULT = 2.0
VOL_MULT_BULL = 1.5
QUIET_PCT = 2.0
THRESH = 4.8
LOCAL_VOL_N = 7
QUIET_N = 3
SIGNAL_TO_N = 12
ATR_N = 7
TS_N = 12
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40, regime_n):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40:
continue
pct = dfs40[ticker]["close"].pct_change(regime_n) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def calc_atr(df, buy_idx):
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, mode):
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = None; i = next_i
continue
score = 0.0
if not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
if mode == "bear_off":
if score < BEAR_THRESHOLD:
sig_i = sig_p = None; i += 1; continue
vol_mult = VOL_MULT_BULL if score >= BULL_THRESHOLD else VOL_MULT_DEFAULT
elif mode == "bull_only":
if score < BULL_THRESHOLD:
sig_i = sig_p = None; i += 1; continue
vol_mult = VOL_MULT_BULL
else:
vol_mult = VOL_MULT_DEFAULT
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
if sig_i is not None:
move_pct = (cur - sig_p) / sig_p * 100
if cur < sig_p:
sig_i = sig_p = None
elif move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i); sig_i = sig_p = None
i += 1; continue
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-LOCAL_VOL_N:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= vol_mult:
if sig_i is None:
sig_i = i; sig_p = cur
else:
if sig_i is not None and cur < sig_p:
sig_i = sig_p = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS:
blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0; monthly = {}
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
wins = sum(1 for t in accepted if t[0])
peak = BUDGET; max_dd = 0.0
pf = float(BUDGET)
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pf = max(pf + max(pf, MIN_BUDGET) / MAX_POS * pnl / 100, MIN_BUDGET)
peak = max(peak, pf); max_dd = max(max_dd, (peak-pf)/peak*100)
return {
"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio-BUDGET)/BUDGET*100,
"total": len(accepted), "wins": wins,
"wr": wins/len(accepted)*100 if accepted else 0,
"monthly": monthly, "max_dd": max_dd,
}
def sim_one(dfs40, regime_n, mode):
rs = build_regime_series(dfs40, regime_n)
all_trades = []; wf_total = 0
for ticker, df40 in dfs40.items():
raw = run_strategy(df40, ticker, rs, mode)
filtered, blocked = apply_wf(raw)
wf_total += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
result = run_compound(accepted)
# BULL 비율
if not rs.empty:
valid = rs.dropna()
bull_pct = (valid >= BULL_THRESHOLD).sum() / len(valid) * 100 if len(valid) else 0
bear_pct = (valid < BEAR_THRESHOLD).sum() / len(valid) * 100 if len(valid) else 0
else:
bull_pct = bear_pct = 0
return result, bull_pct, bear_pct, wf_total, len(skipped)
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}\n")
dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers}
sample = next(iter(dfs40.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
SWEEP_N = [1, 2, 3, 4, 5, 6, 8, 10] # 40분 ~ 400분 (6.7h)
# ── BULL 진입만 스윕 ──────────────────────────────────
print(f"{'='*72}")
print(f" REGIME_N 스윕 (40분봉 × N봉 변화율 기준 | BULL≥{BULL_THRESHOLD}%)")
print(f" 기간: {start_dt} ~ {end_dt} / {len(tickers)}종목")
print(f"{'='*72}")
print(f" {'N봉':>4} {'시간':>5}{'BULL%':>6} {'BEAR%':>6}"
f"{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}")
print(f" {''*68}")
bull_results = {}
for n in SWEEP_N:
r, bull_pct, bear_pct, wf_b, skip = sim_one(dfs40, n, "bull_only")
bull_results[n] = r
mins = n * 40
h = mins // 60
m = mins % 60
time_label = f"{h}h{m:02d}m" if m else f"{h}h"
if r["total"] == 0:
print(f" {n:>4}{time_label:>5}{bull_pct:>5.1f}% {bear_pct:>5.1f}% │ "
f"{'진입없음':>34}")
else:
print(f" {n:>4}{time_label:>5}{bull_pct:>5.1f}% {bear_pct:>5.1f}% │ "
f"{r['total']:>5}{r['wr']:>4.1f}% │ "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{r['max_dd']:>4.1f}%")
# ── BEAR 차단 스윕 ────────────────────────────────────
print(f"\n{'='*72}")
print(f" REGIME_N 스윕 (BEAR 차단 모드 | BEAR<{BEAR_THRESHOLD}%)")
print(f"{'='*72}")
print(f" {'N봉':>4} {'시간':>5}{'BULL%':>6} {'BEAR%':>6}"
f"{'진입':>5} {'승률':>5}{'수익률':>8} {'순수익(KRW)':>14} {'낙폭':>7}")
print(f" {''*68}")
bear_results = {}
for n in SWEEP_N:
r, bull_pct, bear_pct, wf_b, skip = sim_one(dfs40, n, "bear_off")
bear_results[n] = r
mins = n * 40
h = mins // 60; m = mins % 60
time_label = f"{h}h{m:02d}m" if m else f"{h}h"
print(f" {n:>4}{time_label:>5}{bull_pct:>5.1f}% {bear_pct:>5.1f}% │ "
f"{r['total']:>5}{r['wr']:>4.1f}% │ "
f"{r['roi_pct']:>+7.2f}% {r['total_krw']:>+13,.0f}원 -{r['max_dd']:>4.1f}%")
# ── 베이스라인 (필터없음) ─────────────────────────────
r_none, _, _, _, _ = sim_one(dfs40, 1, "none")
print(f"\n 베이스라인 (필터없음): {r_none['total']}{r_none['wr']:.1f}% "
f"{r_none['roi_pct']:+.2f}% {r_none['total_krw']:+,.0f}원 -{r_none['max_dd']:.1f}%")
# ── 최적 BULL 구간 ────────────────────────────────────
valid_bull = {n: r for n, r in bull_results.items() if r["total"] >= 5}
if valid_bull:
best_n = max(valid_bull, key=lambda n: valid_bull[n]["roi_pct"])
best_r = valid_bull[best_n]
print(f"\n ★ BULL 진입 최적 N: {best_n}봉({best_n*40}분) "
f"수익률 {best_r['roi_pct']:+.2f}% 진입 {best_r['total']}"
f"승률 {best_r['wr']:.1f}%")
valid_bear = {n: r for n, r in bear_results.items() if r["total"] >= 5}
if valid_bear:
best_n = max(valid_bear, key=lambda n: valid_bear[n]["roi_pct"])
best_r = valid_bear[best_n]
print(f" ★ BEAR 차단 최적 N: {best_n}봉({best_n*40}분) "
f"수익률 {best_r['roi_pct']:+.2f}% 진입 {best_r['total']}"
f"승률 {best_r['wr']:.1f}%")
print(f"{'='*72}")
if __name__ == "__main__":
main()

244
archive/tests/sim_tp_sl.py Normal file
View File

@@ -0,0 +1,244 @@
"""TP + 고정 손절 비율 비교 시뮬 (30일)."""
import sys, os
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import oracledb
LOOKBACK_DAYS = 30
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030
ATR_MAX_R = 0.050
MAX_TRAIL_BARS = 240
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
FEE = 0.0005
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
SIGNAL_SQL = f"""
WITH base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3,
LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3,
GREATEST(high_p-low_p,
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
FROM backtest_ohlcv
WHERE interval_cd='minute1'
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
ind AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p,
volume_p / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3,
pc1,po1,pc2,po2,pc3,po3,
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
FROM base
)
SELECT ticker, ts, vr0,vr1,vr2,vr3, atr_raw
FROM ind
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
AND vr0 >= {VOL_MIN}
AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3
AND close_p>pc1 AND pc1>pc2 AND pc2>pc3
AND vr0>vr1 AND vr1>vr2 AND vr2>vr3
ORDER BY ticker, ts
"""
def sim_trail(bars, ep, ar):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_trail(bars, ep, ar, tp_r):
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
tp = ep * (1 + tp_r)
peak = ep
for i, (ts, cp, hp, lp) in enumerate(bars):
if hp >= tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
peak = max(peak, cp)
if (peak - cp) / peak >= stop:
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
pnl=(cp - ep) / ep * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def sim_tp_sl(bars, ep, tp_r, sl_r):
"""고정 익절 + 고정 손절. 같은 봉에서 둘 다 터치하면 손절 우선."""
tp = ep * (1 + tp_r)
sl = ep * (1 - sl_r)
for i, (ts, cp, hp, lp) in enumerate(bars):
hit_sl = lp <= sl
hit_tp = hp >= tp
if hit_sl:
return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl,
pnl=-sl_r * 100, held=i + 1)
if hit_tp:
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
pnl=tp_r * 100, held=i + 1)
lts, lcp = bars[-1][0], bars[-1][1]
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
def pos_limit(sim):
opens, taken, skipped = [], [], []
for r in sim:
opens = [ex for ex in opens if ex > r['entry_ts']]
if len(opens) < MAX_POS:
opens.append(r['exit_ts'])
taken.append(r)
else:
skipped.append(r)
return taken, skipped
def main():
now = datetime.now()
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
conn = get_conn()
cur = conn.cursor()
cur.arraysize = 10000
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
rows = cur.fetchall()
signals = []
for row in rows:
ticker, sig_ts, vr0, vr1, vr2, vr3, atr_raw = row
cur.execute(
"""SELECT close_p, ts FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
{'t': ticker, 'sig': sig_ts}
)
er = cur.fetchone()
if not er:
continue
ep, ets = float(er[0]), er[1]
cur.execute(
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
WHERE ticker=:t AND interval_cd='minute1'
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
)
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
if not bars:
continue
signals.append({
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
'atr_raw': float(atr_raw) if atr_raw else 0.0,
'bars': bars,
})
signals.sort(key=lambda x: x['entry_ts'])
print(f"=== TP / 손절 비율 비교 시뮬 ===")
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} | 시그널 {len(signals)}\n")
strategies = [
('A. Trail Stop [3~5%]', 'trail', None, None ),
('B. TP 3% + Trail Stop', 'tp_trail', 0.03, None ),
('C. TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ),
('D. TP 2% + SL 1.5%', 'tp_sl', 0.02, 0.015),
('E. TP 2% + SL 1%', 'tp_sl', 0.02, 0.010),
('F. TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ),
('G. TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ),
]
print(f"{''*105}")
print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}")
print(f"{''*105}")
all_results = {}
for label, mode, tp_r, sl_r in strategies:
sim = []
for s in signals:
if mode == 'trail':
r = sim_trail(s['bars'], s['entry_price'], s['atr_raw'])
elif mode == 'tp_trail':
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
else:
r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r)
sim.append({**s, **r})
taken, _ = pos_limit(sim)
all_results[label] = taken
n = len(taken)
if n == 0:
print(f" {label:32s} 거래없음")
continue
wins = sum(1 for r in taken if r['pnl'] > 0)
losses = sum(1 for r in taken if r['pnl'] < 0)
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
avg_h = sum(r['held'] for r in taken) / n
ret = total / BUDGET * 100
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
rr = avg_w / avg_l
print(f" {label:32s} {n:>3d}{wins/n*100:>4.0f}% {total:>+12,.0f}"
f"{ret:>+5.2f}% {avg_h:>5.0f}{rr:>4.1f}:1")
print(f"{''*105}")
# ── C 상세 (TP2%+SL2%) ────────────────────────────────────────────────────
label_c = 'C. TP 2% + SL 2%'
print(f"\n[{label_c} 건별 상세]")
print(f"{''*100}")
for i, r in enumerate(all_results[label_c], 1):
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
sign = '' if r['pnl'] > 0 else ''
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
f"{r['status']:14s} {r['held']:3d}{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,353 @@
"""볼륨 강도 기반 레짐+F&G 오버라이드 시뮬 — 1년치.
우선순위 로직:
1순위: vol_ratio ≥ VOL_OVERRIDE_THRESH → 레짐/F&G 무관 즉시 진입 허용
2순위: F&G < FNG_MIN_ENTRY → 차단
3순위: 레짐 BEAR → 차단
4순위: 일반 vol-lead 로직
비교 구성:
1. 필터 없음
2. F&G≥41 + BEAR차단N5 (현재 전략 레짐 적용)
3. F&G≥41 + BEAR차단N5 + vol≥5x 오버라이드 (레짐+F&G 동시 오버라이드)
4. F&G≥41 + BEAR차단N5 + vol≥4x 오버라이드
5. F&G≥41 + BEAR차단N5 + vol≥3x 오버라이드
데이터: data/sim1y_cache.pkl / data/fng_1y.json
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import json
import pickle
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
CACHE_FILE = Path(__file__).parent.parent / "data" / "sim1y_cache.pkl"
FNG_FILE = Path(__file__).parent.parent / "data" / "fng_1y.json"
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT_DEFAULT = 2.0
VOL_MULT_BULL = 1.5
QUIET_PCT = 2.0
THRESH = 4.8
LOCAL_VOL_N = 7
QUIET_N = 3
SIGNAL_TO_N = 12
ATR_N = 7
TS_N = 12
BEAR_THRESHOLD = -0.5
BULL_THRESHOLD = 1.5
REGIME_N = 5
FNG_MIN_ENTRY = 41
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
REGIME_WEIGHTS = {"KRW-BTC": 0.40, "KRW-ETH": 0.30,
"KRW-SOL": 0.15, "KRW-XRP": 0.15}
def resample_40m(df):
return (df.resample("40min")
.agg({"open":"first","high":"max","low":"min",
"close":"last","volume":"sum"})
.dropna(subset=["close"]))
def build_regime_series(dfs40):
weighted = None
for ticker, w in REGIME_WEIGHTS.items():
if ticker not in dfs40: continue
pct = dfs40[ticker]["close"].pct_change(REGIME_N) * 100
weighted = pct * w if weighted is None else weighted.add(pct * w, fill_value=0.0)
return weighted if weighted is not None else pd.Series(dtype=float)
def calc_atr(df, buy_idx):
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3: return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df, buy_idx, buy_price, stop_pct):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak: peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"]*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return pnl > 0, df.index[-1], pnl
def run_strategy(df, ticker, regime_series, fng_map,
use_fng, use_regime, vol_override_thresh):
"""
우선순위 구조:
① 포지션 청산 체크
② 볼륨 스파이크 감지 → 신호 기록 (F&G/레짐 무관, 항상 실행)
③ 진입 시점에서:
vol_strong(sig_vr≥thresh) → F&G+레짐 필터 전부 건너뜀
아니면 → F&G≥41 AND 레짐 BEAR 아닐 때만 진입 허용
"""
trades = []
sig_i = sig_p = sig_vr = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
ts = df.index[i]
row = df.iloc[i]
cur = row["close"]
# ── ① 포지션 청산 ────────────────────────────────
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False; sig_i = sig_p = sig_vr = None; i = next_i
continue
# 신호 타임아웃
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = sig_vr = None
# ── ② 신호 없을 때: 축적 감지 (필터 무관, 항상) ──
# F&G=14 극공포여도 vol 스파이크면 신호 기록 → ③에서 override 결정
if sig_i is None:
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-1-LOCAL_VOL_N:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i-QUIET_N]["close"]
chg_qh = abs(cur - close_qh) / close_qh * 100 if close_qh > 0 else 999
if chg_qh < QUIET_PCT and vol_r >= VOL_MULT_DEFAULT:
sig_i = i; sig_p = cur; sig_vr = vol_r
i += 1
continue
# 신호가 이하 하락 → 초기화
if cur < sig_p:
sig_i = sig_p = sig_vr = None
i += 1
continue
# ── ③ 진입 체크 — vol_strong이면 필터 전부 스킵 ──
vol_strong = (vol_override_thresh > 0
and sig_vr is not None
and sig_vr >= vol_override_thresh)
if not vol_strong:
# F&G 필터 (신호 유지, 진입만 보류)
if use_fng and fng_map:
fv = fng_map.get(ts.strftime("%Y-%m-%d"), 50)
if fv < FNG_MIN_ENTRY:
i += 1; continue
# 레짐 필터 (신호 유지, 진입만 보류)
if use_regime and not regime_series.empty and ts in regime_series.index:
v = regime_series.loc[ts]
score = float(v) if not pd.isna(v) else 0.0
if score < BEAR_THRESHOLD:
i += 1; continue
move_pct = (cur - sig_p) / sig_p * 100
if move_pct >= THRESH:
in_pos = True; buy_idx = i; buy_price = cur
stop_pct = calc_atr(df, i); sig_i = sig_p = sig_vr = None
i += 1
return trades
def apply_wf(trades):
history = []; shadow = 0; blocked = False; accepted = []; cnt = 0
for t in trades:
is_win = int(t[0])
if not blocked:
accepted.append(t); history.append(is_win)
if len(history) >= WF_WINDOW and sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True; shadow = 0
else:
cnt += 1
if is_win:
shadow += 1
if shadow >= WF_SHADOW_WINS: blocked = False; history = []; shadow = 0
else:
shadow = 0
return accepted, cnt
def apply_max_pos(trades):
open_exits = []; accepted = []; skipped = []
for t in trades:
buy_dt, sell_dt = t[2], t[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt); accepted.append(t)
else:
skipped.append(t)
return accepted, skipped
def run_compound(accepted):
portfolio = float(BUDGET); total_krw = 0.0; monthly = {}; trade_log = []
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1; monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({"portfolio": portfolio})
wins = sum(1 for t in accepted if t[0])
peak = BUDGET; max_dd = 0.0
for t in trade_log:
peak = max(peak, t["portfolio"])
max_dd = max(max_dd, (peak - t["portfolio"]) / peak * 100)
return {"portfolio": portfolio, "total_krw": total_krw,
"roi_pct": (portfolio-BUDGET)/BUDGET*100,
"total": len(accepted), "wins": wins,
"wr": wins/len(accepted)*100 if accepted else 0,
"monthly": monthly, "max_dd": max_dd}
def sim_one(dfs40, regime_series, fng_map, use_fng, use_regime, vol_override):
all_trades = []; wf_total = 0
for ticker, df40 in dfs40.items():
raw = run_strategy(df40, ticker, regime_series, fng_map,
use_fng, use_regime, vol_override)
filtered, blocked = apply_wf(raw)
wf_total += blocked; all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_pos(all_trades)
return run_compound(accepted), wf_total, len(skipped)
def print_monthly(result, label):
print(f"\n ── 월별 상세: {label}")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>13} {'누적(KRW)':>14}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
flag = "" if m["pnl_krw"] > 0 else ""
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │"
f" {m['pnl_krw']:>+13,.0f}{cum:>+13,.0f}{flag}")
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
tickers = [t for t in list(cache["10m"].keys())[:TOP_N]
if len(cache["10m"][t]) > 500]
print(f" 종목: {len(tickers)}")
fng_map = {}
if FNG_FILE.exists():
fng_map = json.load(open(FNG_FILE))
dates = sorted(fng_map.keys())
print(f" F&G: {dates[0]} ~ {dates[-1]} ({len(fng_map)}일)")
dfs40 = {t: resample_40m(cache["10m"][t]) for t in tickers}
regime_series = build_regime_series(dfs40)
sample = next(iter(dfs40.values()))
start_dt = sample.index[0].strftime("%Y-%m-%d")
end_dt = sample.index[-1].strftime("%Y-%m-%d")
# 필터 적용 일수 통계
if fng_map:
period_fng = {k: v for k, v in fng_map.items() if start_dt <= k <= end_dt}
fng_blocked = sum(1 for v in period_fng.values() if v < FNG_MIN_ENTRY)
fng_allowed = len(period_fng) - fng_blocked
print(f" F&G 차단: {fng_blocked}일 / 허용: {fng_allowed}일 (기준 ≥{FNG_MIN_ENTRY})")
valid = regime_series.dropna()
bear_pct = (valid < BEAR_THRESHOLD).sum() / len(valid) * 100
print(f" 레짐 BEAR: {bear_pct:.1f}%봉 (REGIME_N={REGIME_N}봉={REGIME_N*40}분)\n")
# ── 시뮬 구성 ─────────────────────────────────────────
CONFIGS = [
# (use_fng, use_regime, vol_override, label)
(False, False, 0, "① 필터 없음"),
(True, True, 0, f"② F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
(True, True, 5.0, f"③ [1순위:vol≥5x] F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
(True, True, 4.0, f"④ [1순위:vol≥4x] F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
(True, True, 3.0, f"⑤ [1순위:vol≥3x] F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"),
]
print(f"{'='*72}")
print(f" vol 오버라이드 (레짐+F&G 동시) 시뮬 | 1년 | {len(tickers)}종목")
print(f" 기간: {start_dt} ~ {end_dt}")
print(f" 우선순위: vol≥Nx(오버라이드) > F&G필터 > 레짐필터 > vol-lead 로직")
print(f"{'='*72}")
print(f" {'구성':<48} {'진입':>5} {'승률':>5} {'수익률':>8} {'순수익':>12} {'낙폭':>6}")
print(f" {''*70}")
results = {}
for use_fng, use_regime, vol_ov, label in CONFIGS:
r, wf_b, skip = sim_one(dfs40, regime_series, fng_map,
use_fng, use_regime, vol_ov)
results[label] = r
n = r["total"]
print(f" {label:<48} {n:>5}{r['wr']:>4.1f}%"
f" {r['roi_pct']:>+7.2f}% {r['total_krw']:>+11,.0f}원 -{r['max_dd']:.1f}%")
# ── 월별 상세 ─────────────────────────────────────────
print(f"\n{'='*72}")
for use_fng, use_regime, vol_ov, label in CONFIGS:
if label in results:
print_monthly(results[label], label)
# ── 비교 요약 ─────────────────────────────────────────
print(f"\n{'='*72}")
base_label = f"② F&G≥{FNG_MIN_ENTRY} + BEAR차단N{REGIME_N}"
base_r = results.get(base_label)
if base_r:
print(f" 오버라이드 효과 (vs {base_label}):")
for _, _, vol_ov, label in CONFIGS[2:]:
r = results.get(label)
if r and r["total"] > 0:
d_roi = r["roi_pct"] - base_r["roi_pct"]
d_n = r["total"] - base_r["total"]
d_wr = r["wr"] - base_r["wr"]
d_dd = r["max_dd"] - base_r["max_dd"]
print(f" vol≥{vol_ov:.0f}x: 수익률 {d_roi:>+.2f}%p "
f"진입 {d_n:>+d}건 승률 {d_wr:>+.1f}%p 낙폭 {d_dd:>+.1f}%p")
best_label = max(results, key=lambda k: results[k]["roi_pct"])
best = results[best_label]
print(f"\n ★ 최고 수익률: {best_label}")
print(f" 수익률 {best['roi_pct']:+.2f}% / 순수익 {best['total_krw']:+,.0f}"
f"/ 낙폭 -{best['max_dd']:.1f}%")
print(f"{'='*72}")
if __name__ == "__main__":
main()

260
archive/tests/stop_sweep.py Normal file
View File

@@ -0,0 +1,260 @@
"""트레일링 스탑 크기 스윕 백테스트
현행 모멘텀 진입 전략에서
트레일링 스탑 크기(1% ~ 6%)를 바꿔가며 성과 비교.
핵심 질문: "2% 스탑이 너무 좁아서 정상 노이즈에 스탑이 걸리는 건 아닌가?"
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import pyupbit, time, sys
from dataclasses import dataclass, field
from collections import defaultdict
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
]
# 공통
VOL_MULT = 2.0
QUIET_2H = 2.0
SIGNAL_TO_H = 8
MOMENTUM_THR = 3.0
SIGNAL_CANCEL = 3.0
TIME_STOP_H = 24
TIME_STOP_MIN = 3.0 # 24h 후 +3% 미달 시 청산
# 테스트할 스탑 크기 목록
STOP_SIZES = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0]
@dataclass
class Trade:
pnl: float
h: int
exit: str # trail | time
def simulate(df, trail_stop_pct: float) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
trail = trail_stop_pct / 100.0
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = None
for i in range(7, len(closes) - max(TIME_STOP_H + 4, 10)):
# ── 포지션 관리 ──────────────────────────────────────
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
# 트레일링 스탑
if (pos_peak - cur) / pos_peak >= trail:
pnl = (cur - pos_buy) / pos_buy * 100
trades.append(Trade(pnl, i - pos_i, "trail"))
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
continue
# 타임 스탑
if i - pos_i >= TIME_STOP_H:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < TIME_STOP_MIN:
trades.append(Trade(pnl, i - pos_i, "time"))
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
continue
continue
# ── 신호 유효성 ──────────────────────────────────────
if sig_px is not None:
if i - sig_i > SIGNAL_TO_H:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
sig_px = sig_i = None
# ── 축적 감지 ─────────────────────────────────────────
if sig_px is None:
vol_avg = vols[i-6:i-1].mean()
if vol_avg <= 0:
continue
if vols[i-1] / vol_avg >= VOL_MULT:
if abs(closes[i] - closes[i-2]) / closes[i-2] * 100 < QUIET_2H:
sig_px = closes[i]
sig_i = i
continue
# ── 모멘텀 진입 ───────────────────────────────────────
if (closes[i] - sig_px) / sig_px * 100 >= MOMENTUM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
sig_px = sig_i = None
return trades
def stats(trades: list[Trade]) -> dict:
if not trades:
return dict(n=0, wins=0, wr=0, avg_pnl=0, avg_win=0, avg_loss=0,
rr=0, total_pnl=0, avg_h=0, max_dd=0, trail_pct=0, time_pct=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
avg_win = sum(t.pnl for t in wins) / len(wins) if wins else 0
avg_loss = sum(t.pnl for t in losses) / len(losses) if losses else 0
rr = abs(avg_win / avg_loss) if avg_loss else 0
# 누적 수익 흐름으로 Max Drawdown 계산
cum = 0.0; peak_cum = 0.0; max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > peak_cum:
peak_cum = cum
dd = peak_cum - cum
if dd > max_dd:
max_dd = dd
trail_n = sum(1 for t in trades if t.exit == "trail")
time_n = sum(1 for t in trades if t.exit == "time")
return dict(
n = len(trades),
wins = len(wins),
wr = len(wins) / len(trades) * 100,
avg_pnl = sum(t.pnl for t in trades) / len(trades),
avg_win = avg_win,
avg_loss = avg_loss,
rr = rr,
total_pnl = sum(t.pnl for t in trades),
avg_h = sum(t.h for t in trades) / len(trades),
max_dd = max_dd,
trail_pct = trail_n / len(trades) * 100,
time_pct = time_n / len(trades) * 100,
)
def main() -> None:
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
datasets: dict = {}
for i, ticker in enumerate(TICKERS):
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
if df is not None and len(df) > 50:
datasets[ticker] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
time.sleep(0.08)
except Exception:
pass
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 스탑 크기별 전체 집계 ─────────────────────────────────
print("=" * 80)
print(" 트레일링 스탑 크기별 성과 비교 (60일 / 모멘텀 진입 기준)")
print("=" * 80)
print(f" {'스탑%':>5} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} "
f"{'평균보유':>7} {'Trail%':>7} {'Time%':>6}")
print(" " + "-" * 77)
best_total = -9999
best_stop = None
all_stats: dict[float, dict] = {}
for stop in STOP_SIZES:
all_trades: list[Trade] = []
for df in datasets.values():
all_trades.extend(simulate(df, stop))
s = stats(all_trades)
all_stats[stop] = s
marker = ""
if stop == 2.0:
marker = " ◀ 현행"
print(
f" {stop:>4.1f}% {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
f"{s['avg_h']:>6.1f}h {s['trail_pct']:>6.1f}% "
f"{s['time_pct']:>5.1f}%{marker}"
)
if s["total_pnl"] > best_total and s["n"] >= 30:
best_total = s["total_pnl"]
best_stop = stop
# ── 손익 분포 시각화 ─────────────────────────────────────
print()
print(" 총 손익 트렌드 (스탑 크기별):")
for stop in STOP_SIZES:
s = all_stats[stop]
if s["n"] == 0:
continue
bar_val = s["total_pnl"]
bar_len = int(abs(bar_val) / 5)
bar = ("" * min(bar_len, 30)) if bar_val > 0 else ("" * min(bar_len, 30))
sign = "+" if bar_val > 0 else "-"
marker = " ◀ 현행" if stop == 2.0 else ""
print(f" {stop:>4.1f}% {sign}{bar} ({bar_val:+.1f}%){marker}")
# ── 이익/손실 평균 비교 ─────────────────────────────────
print()
print(" 이익 vs 손실 평균 비교:")
print(f" {'스탑%':>5} {'평균이익':>8} {'평균손실':>9} {'손익비':>6} {'설명'}")
print(" " + "-" * 55)
for stop in STOP_SIZES:
s = all_stats[stop]
if s["n"] == 0:
continue
marker = " ◀ 현행" if stop == 2.0 else ""
# 손익비 해석
if s["rr"] >= 1.5: interp = "양호"
elif s["rr"] >= 1.0: interp = "보통"
elif s["rr"] >= 0.7: interp = "불량"
else: interp = "매우불량"
print(
f" {stop:>4.1f}% {s['avg_win']:>+7.3f}% {s['avg_loss']:>+8.3f}% "
f"{s['rr']:>5.2f} {interp}{marker}"
)
if best_stop:
s = all_stats[best_stop]
print(f"\n ★ 최적 스탑: {best_stop:.1f}% "
f"(거래 {s['n']}건 | 승률 {s['wr']:.1f}% | "
f"총손익 {s['total_pnl']:+.1f}% | 손익비 {s['rr']:.2f})")
# ── 핵심 분석: 2% 스탑이 노이즈에 걸리는 비율 ───────────
print()
print(" 진단: 스탑 발동 후 실제 추가 하락 vs 반등 분석")
print(" (트레일링 스탑이 걸린 시점 기준 이후 4h 추적)")
for stop_target in [2.0, 3.0, 4.0]:
if stop_target not in all_stats:
continue
trades_checked = 0
recoveries = 0
for df in datasets.values():
tlist = simulate(df, stop_target)
closes = df["close"].values
# 스탑 발동 건 중 이후 4h 안에 스탑가격 이상으로 회복한 비율
# (여기서는 근사: 스탑 발동 후 trail stop 기준점인 peak * (1 - stop) 대비)
# 간단히: trail 발동 후 다음 4개 캔들이 매수가 대비 양수인지 확인
# simulate의 trade에는 exit price가 없으므로, 전체 흐름에서 재확인 필요
# 여기서는 avg_pnl과 avg_win으로 대신 설명
s = all_stats[stop_target]
# 스탑 발동 건 중 손실 비율
trail_loss_approx = s["n"] * (s["trail_pct"]/100) * (1 - s["wr"]/100)
print(f" 스탑 {stop_target:.1f}%: trail 발동 {s['trail_pct']:.0f}% | "
f"time 발동 {s['time_pct']:.0f}% | "
f"avg손실 {s['avg_loss']:+.3f}% | avg이익 {s['avg_win']:+.3f}%")
if __name__ == "__main__":
main()

310
archive/tests/sweep_1min.py Normal file
View File

@@ -0,0 +1,310 @@
"""1분봉 연속 상승 vol spike 전략 파라미터 스윕.
시그널 조건:
봉[n-1]: vol_ratio >= VOL_MIN, 양봉 (close > open)
봉[n] : vol_ratio >= VOL_MIN, 양봉, close > 봉[n-1].close (연속 상승)
진입: 봉[n] 다음 봉 (봉[n+1]) close에서 즉시
추적: 1분봉 trail stop (ATR 기반) + time stop
DB 계산: 지표·시그널·진입·running_peak 모두 Oracle SQL / 월별 배치
"""
import sys, os, itertools
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pandas as pd
import oracledb
import time as _time
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61 # vol_ratio 기준: 이전 60봉 평균
ATR_LOOKBACK = 28 # ATR 계산 봉 수
TS_N = 240 # 타임스탑 봉수 (240분 = 4시간)
TIME_STOP_PCT = 0.0 / 100
FEE = 0.0005
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
SWEEP = {
"VOL": [15.0, 20.0, 25.0, 30.0, 40.0, 50.0], # 시그널 거래량 배율 (상위 범위 탐색)
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
"ATR_MIN": [0.005, 0.010, 0.015],
"ATR_MAX": [0.020, 0.025, 0.030],
}
VOL_MIN = min(SWEEP["VOL"]) # SQL pre-filter (15x 이상만 로드)
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
SIM_START = datetime(2025, 8, 1)
SIM_END = datetime(2026, 3, 4)
WARMUP_MINS = 120
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def _months(start: datetime, end: datetime):
m = start.replace(day=1)
while m < end:
nxt = (m + timedelta(days=32)).replace(day=1)
if nxt > end:
nxt = end
yield m, nxt
m = nxt
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── 핵심 SQL ──────────────────────────────────────────────────────────────────
# 연속 2봉 vol spike + 상승 확인 후 다음 봉 즉시 진입
TRADE_SQL = f"""
WITH
-- 1) 1분봉 + TR + 이전 봉 정보
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close,
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open,
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_volume,
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
-- 2) 지표: vol_ratio (현재봉), prev_vol_ratio (이전봉), atr_raw
indicators AS (
SELECT ticker, ts, open_p, close_p, prev_close, prev_open,
-- 현재 봉 vol_ratio
volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio,
-- 이전 봉 vol_ratio (LAG로 한 봉 앞)
prev_volume / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) prev_vol_ratio,
-- ATR
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close, 0) atr_raw
FROM base
),
-- 3) 연속 2봉 조건:
-- 봉[n-1]: prev_vol_ratio >= min_vol, 이전 봉 양봉
-- 봉[n] : vol_ratio >= min_vol, 양봉, close > prev_close (상승 지속)
signals AS (
SELECT ticker, ts sig_ts, close_p sig_price, vol_ratio, atr_raw
FROM indicators
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
-- 현재 봉 조건
AND vol_ratio >= :min_vol
AND close_p > open_p
-- 이전 봉 조건
AND prev_vol_ratio >= :min_vol
AND prev_close > prev_open
-- 연속 상승
AND close_p > prev_close
),
-- 4) 진입: 시그널 다음 1분봉 즉시
entry_cands AS (
SELECT
s.ticker, s.sig_ts, s.sig_price, s.vol_ratio, s.atr_raw,
e.ts entry_ts,
e.close_p entry_price,
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
FROM signals s
JOIN backtest_ohlcv e
ON e.ticker = s.ticker
AND e.interval_cd = 'minute1'
AND e.ts > s.sig_ts
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
),
-- 5) 첫 봉만
entries AS (
SELECT ticker, sig_ts, sig_price, vol_ratio, atr_raw,
entry_ts, entry_price
FROM entry_cands WHERE rn = 1
),
-- 6) 진입 후 TS_N분 1분봉 + 롤링 피크
post_entry AS (
SELECT
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
e.vol_ratio, e.atr_raw,
b.close_p bar_price,
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
ORDER BY b.ts
ROWS UNBOUNDED PRECEDING) running_peak
FROM entries e
JOIN backtest_ohlcv b
ON b.ticker = e.ticker
AND b.interval_cd = 'minute1'
AND b.ts >= e.entry_ts
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
)
SELECT ticker, sig_ts, entry_ts, entry_price,
vol_ratio, atr_raw,
bar_n, bar_price, running_peak
FROM post_entry
WHERE bar_n <= :ts_n + 1
ORDER BY ticker, entry_ts, bar_n
"""
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
print(f"연속 2봉 vol spike 전략 (VOL>={VOL_MIN}x, 연속 상승 후 즉시 진입)\n", flush=True)
print("월별 DB 로드...\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
ENTRIES: dict = {}
t_load = _time.time()
for m_start, m_end in _months(SIM_START, SIM_END):
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
t0 = _time.time()
cur.execute(TRADE_SQL, {
"load_since": load_since,
"sim_start": sim_start,
"sim_end": sim_end,
"min_vol": VOL_MIN,
"ts_n": TS_N,
})
rows = cur.fetchall()
t1 = _time.time()
n_new = 0
for row in rows:
(ticker, sig_ts, entry_ts, entry_price,
vol_ratio, atr_raw,
bar_n, bar_price, running_peak) = row
key = (ticker, entry_ts)
if key not in ENTRIES:
ENTRIES[key] = {
'entry_price': float(entry_price),
'vol_ratio': float(vol_ratio),
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
'bars': [],
}
n_new += 1
ENTRIES[key]['bars'].append((float(bar_price), float(running_peak)))
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}", flush=True)
conn.close()
print(f"\n총 진입 이벤트: {len(ENTRIES):,}건 | 로드 {_time.time()-t_load:.1f}s\n", flush=True)
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
for n, (bp, pk) in enumerate(bars):
drop = (pk - bp) / pk if pk > 0 else 0.0
pnl = (bp - entry_price) / entry_price
if drop >= atr_stop:
return pnl * 100
if n + 1 >= TS_N and pnl < TIME_STOP_PCT:
return pnl * 100
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
# ── 스윕 ──────────────────────────────────────────────────────────────────────
ENTRY_LIST = list(ENTRIES.values())
keys = list(SWEEP.keys())
combos = list(itertools.product(*SWEEP.values()))
print(f"{len(combos)}가지 조합 스윕...\n", flush=True)
t_sweep = _time.time()
results = []
for combo in combos:
params = dict(zip(keys, combo))
if params['ATR_MIN'] >= params['ATR_MAX']:
continue
vol_thr = params['VOL']
atr_mult = params['ATR_MULT']
atr_min = params['ATR_MIN']
atr_max = params['ATR_MAX']
trades = []
for e in ENTRY_LIST:
if e['vol_ratio'] < vol_thr:
continue
ar = e['atr_raw']
atr_s = (atr_min if (ar != ar)
else max(atr_min, min(atr_max, ar * atr_mult)))
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
trades.append((pnl_pct, krw))
if not trades:
results.append({**params, 'trades': 0, 'wins': 0,
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
continue
wins = sum(1 for p, _ in trades if p > 0)
results.append({
**params,
'trades': len(trades),
'wins': wins,
'win_rate': wins / len(trades) * 100,
'avg_pnl': sum(p for p, _ in trades) / len(trades),
'total_krw': sum(k for _, k in trades),
})
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
df_r = pd.DataFrame(results)
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
print("=" * 100)
print(f"{'순위':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
print("=" * 100)
for rank, (_, row) in enumerate(df_r.head(20).iterrows(), 1):
print(f"{rank:>4} {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
f"{int(row['trades']):>5}{row['win_rate']:>5.0f}% "
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}")
# VOL별 최상위 요약
print("\n" + "" * 75)
print(f" {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
print("" * 75)
for vol in SWEEP["VOL"]:
sub = df_r[df_r['VOL'] == vol]
if sub.empty:
continue
best = sub.iloc[0]
print(f" {vol:>4.0f}x {int(best['trades']):>5}{best['win_rate']:>4.0f}% "
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}"
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")

357
archive/tests/sweep_nbar.py Normal file
View File

@@ -0,0 +1,357 @@
"""1분봉 N봉 연속 상승 vol spike 전략 파라미터 스윕.
N=2/3/4 연속 조건 (양봉 + 상승 + vol spike) 모두 Oracle SQL에서 처리.
Python은 ATR 파라미터 + VOL 임계값 스윕만 담당.
시그널 조건 (N_BARS=N):
봉[n-(N-1)]~봉[n]: 모두 vol_ratio >= VOL_MIN, 양봉, 연속 상승
진입: 봉[n+1] close 즉시
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
"""
import sys, os, itertools
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pandas as pd
import oracledb
import time as _time
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
TS_N = 240
TIME_STOP_PCT = 0.0 / 100
FEE = 0.0005
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
VOL_SWEEP = [1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 25.0]
ATR_SWEEP = {
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
"ATR_MIN": [0.005, 0.010, 0.015],
"ATR_MAX": [0.020, 0.025, 0.030],
}
N_BARS_LIST = [2, 3, 4]
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter (모든 봉 >= 1.5x) (모든 봉에 적용)
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
SIM_START = datetime(2025, 8, 1)
SIM_END = datetime(2026, 3, 4)
WARMUP_MINS = 120
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def _months(start: datetime, end: datetime):
m = start.replace(day=1)
while m < end:
nxt = (m + timedelta(days=32)).replace(day=1)
if nxt > end:
nxt = end
yield m, nxt
m = nxt
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
# 공통 CTE: base(TR + LAG N개), indicators(vol_ratio N개 + ATR)
# signals: N봉 연속 조건 모두 SQL에서 처리
# vol_ratio 컬럼은 Python VOL 스윕용으로 모두 반환
def build_sql(n: int) -> str:
"""n봉 연속 조건을 SQL로 구현. 반환 컬럼에 vol_ratio 0~n-1 포함."""
# LAG 컬럼 정의 (base CTE)
lag_cols = "\n".join(
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
for i in range(1, n)
)
# indicators CTE: vol_ratio 0 ~ n-1
vr_cols = []
vr_cols.append(f""" volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_0,""")
for i in range(1, n):
vr_cols.append(f""" prev_vol_{i} / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_{i},""")
vr_cols_str = "\n".join(vr_cols)
# pass-through LAG 컬럼 from base → indicators
lag_passthrough = "\n".join(
f" prev_close_{i}, prev_open_{i},"
for i in range(1, n)
)
# signals WHERE 조건: 현재봉 + 이전 n-1봉 all 양봉 + 연속 상승 + vol
cond_lines = [
" AND vol_ratio_0 >= :min_vol",
" AND close_p > open_p", # 현재봉 양봉
]
for i in range(1, n):
cond_lines.append(f" AND vol_ratio_{i} >= :min_vol")
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 양봉
if i == 1:
cond_lines.append(f" AND close_p > prev_close_{i}") # 현재봉 > 1봉전
else:
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 상승 연속
cond_str = "\n".join(cond_lines)
# SELECT: vol_ratio 0~n-1 반환 (Python VOL 스윕용)
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
return f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
{lag_cols}
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
{lag_passthrough}
{vr_cols_str}
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
),
signals AS (
SELECT ticker, ts sig_ts, close_p sig_price,
{vr_select}, atr_raw
FROM indicators
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
{cond_str}
),
entry_cands AS (
SELECT s.ticker, s.sig_ts, {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
e.ts entry_ts, e.close_p entry_price,
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
FROM signals s
JOIN backtest_ohlcv e
ON e.ticker = s.ticker
AND e.interval_cd = 'minute1'
AND e.ts > s.sig_ts
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
),
entries AS (
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
FROM entry_cands WHERE rn = 1
),
post_entry AS (
SELECT
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
b.close_p bar_price,
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
FROM entries e
JOIN backtest_ohlcv b
ON b.ticker = e.ticker
AND b.interval_cd = 'minute1'
AND b.ts >= e.entry_ts
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
)
SELECT ticker, sig_ts, entry_ts, entry_price,
{vr_select}, atr_raw,
bar_n, bar_price, running_peak
FROM post_entry
WHERE bar_n <= :ts_n + 1
ORDER BY ticker, entry_ts, bar_n
"""
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
print(f"N봉 연속 vol spike 전략 (VOL>={VOL_MIN}x, N={N_BARS_LIST})\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
# N별로 별도 딕셔너리
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
t_load = _time.time()
for n in N_BARS_LIST:
sql = build_sql(n)
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
n_total = 0
for m_start, m_end in _months(SIM_START, SIM_END):
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
t0 = _time.time()
cur.execute(sql, {
"load_since": load_since,
"sim_start": sim_start,
"sim_end": sim_end,
"min_vol": VOL_MIN,
"ts_n": TS_N,
})
rows = cur.fetchall()
t1 = _time.time()
n_new = 0
for row in rows:
# ticker, sig_ts, entry_ts, entry_price, vr0..vr(n-1), atr_raw, bar_n, bar_price, peak
ticker = row[0]
entry_ts = row[2]
entry_price= float(row[3])
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
atr_raw = row[4 + n]
bar_price = float(row[4 + n + 2]) # bar_n is at 4+n+1
running_peak = float(row[4 + n + 3])
key = (ticker, entry_ts)
if key not in ALL_ENTRIES[n]:
ALL_ENTRIES[n][key] = {
'entry_price': entry_price,
'vr': vr_vals,
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
'bars': [],
}
n_new += 1
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
n_total += n_new
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}", flush=True)
print(f"{n}봉 합계: {n_total}\n", flush=True)
conn.close()
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
for i, (bp, pk) in enumerate(bars):
drop = (pk - bp) / pk if pk > 0 else 0.0
pnl = (bp - entry_price) / entry_price
if drop >= atr_stop:
return pnl * 100
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
return pnl * 100
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
# ── 스윕 ──────────────────────────────────────────────────────────────────────
atr_keys = list(ATR_SWEEP.keys())
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
print(f"{total_combos}가지 조합 스윕...\n", flush=True)
t_sweep = _time.time()
results = []
for n in N_BARS_LIST:
entry_list = list(ALL_ENTRIES[n].values())
for vol in VOL_SWEEP:
for atr_combo in atr_combos:
atr_params = dict(zip(atr_keys, atr_combo))
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
continue
atr_mult = atr_params['ATR_MULT']
atr_min = atr_params['ATR_MIN']
atr_max = atr_params['ATR_MAX']
trades = []
for e in entry_list:
# Python: vol 임계값 최종 필터 (모든 봉 vol >= vol)
if min(e['vr']) < vol:
continue
ar = e['atr_raw']
atr_s = (atr_min if (ar != ar)
else max(atr_min, min(atr_max, ar * atr_mult)))
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
trades.append((pnl_pct, krw))
if not trades:
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
'trades': 0, 'wins': 0,
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
continue
wins = sum(1 for p, _ in trades if p > 0)
results.append({
'N_BARS': n,
'VOL': vol,
**atr_params,
'trades': len(trades),
'wins': wins,
'win_rate': wins / len(trades) * 100,
'avg_pnl': sum(p for p, _ in trades) / len(trades),
'total_krw': sum(k for _, k in trades),
})
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
df_r = pd.DataFrame(results)
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
print("=" * 105)
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
print("=" * 105)
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
print(f"{rank:>4} {int(row['N_BARS']):>2}{row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
f"{int(row['trades']):>5}{row['win_rate']:>5.0f}% "
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}")
# N봉 × VOL별 최상위 요약
print("\n" + "" * 85)
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
print("" * 85)
for n in N_BARS_LIST:
for vol in VOL_SWEEP:
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
if sub.empty:
continue
best = sub.iloc[0]
if best['trades'] == 0:
continue
print(f" {int(n):>2}{vol:>4.0f}x {int(best['trades']):>5}{best['win_rate']:>4.0f}% "
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}"
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
print()

View File

@@ -0,0 +1,355 @@
"""1분봉 볼륨 가속 전략 파라미터 스윕.
시그널 조건: N봉 연속으로 가격 AND 거래량이 함께 증가
봉[n-(N-1)] < 봉[n-(N-2)] < ... < 봉[n]
- 각 봉: 양봉 (close > open)
- 가격 연속 상승: close[k] > close[k-1]
- 볼륨 연속 증가: vol_ratio[k] > vol_ratio[k-1]
- 현재봉(가장 강한 봉) vol_ratio >= VOL_MIN (pre-filter)
진입: 봉[n+1] close 즉시
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
"""
import sys, os, itertools
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pandas as pd
import oracledb
import time as _time
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
TS_N = 240
TIME_STOP_PCT = 0.0 / 100
FEE = 0.0005
BUDGET = 15_000_000
MAX_POS = 3
PER_POS = BUDGET // MAX_POS
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
# 현재봉(가장 강한 봉)의 vol_ratio 최솟값 — 가속의 "끝점" 기준
VOL_SWEEP = [2.0, 3.0, 4.0, 5.0, 8.0, 10.0, 15.0]
ATR_SWEEP = {
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
"ATR_MIN": [0.005, 0.010, 0.015],
"ATR_MAX": [0.020, 0.025, 0.030],
}
N_BARS_LIST = [2, 3, 4]
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
SIM_START = datetime(2025, 8, 1)
SIM_END = datetime(2026, 3, 4)
WARMUP_MINS = 120
TICKERS = [
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE',
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP',
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL',
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G',
]
_TK = ",".join(f"'{t}'" for t in TICKERS)
def _months(start: datetime, end: datetime):
m = start.replace(day=1)
while m < end:
nxt = (m + timedelta(days=32)).replace(day=1)
if nxt > end:
nxt = end
yield m, nxt
m = nxt
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
# 조건: 가격 연속 상승 + 볼륨 연속 증가 (+ 양봉)
# vol_ratio_0 > vol_ratio_1 > ... > vol_ratio_(n-1)
# close_0 > close_1 > ... > close_(n-1)
# 모두 Oracle SQL에서 처리
def build_sql(n: int) -> str:
lag_cols = "\n".join(
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
for i in range(1, n)
)
vr_cols = []
vr_cols.append(f""" volume_p / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_0,""")
for i in range(1, n):
vr_cols.append(f""" prev_vol_{i} / NULLIF(
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
) vol_ratio_{i},""")
vr_cols_str = "\n".join(vr_cols)
lag_passthrough = "\n".join(
f" prev_close_{i}, prev_open_{i},"
for i in range(1, n)
)
# 조건: 양봉 + 가격 연속 상승 + 볼륨 연속 증가
cond_lines = [
" AND vol_ratio_0 >= :min_vol", # pre-filter: 현재봉(최강봉)
" AND close_p > open_p", # 현재봉 양봉
]
for i in range(1, n):
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 이전봉 양봉
if i == 1:
cond_lines.append(f" AND close_p > prev_close_{i}") # 가격 상승
cond_lines.append(f" AND vol_ratio_0 > vol_ratio_{i}") # 볼륨 증가
else:
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 가격 상승
cond_lines.append(f" AND vol_ratio_{i-1} > vol_ratio_{i}") # 볼륨 증가
cond_str = "\n".join(cond_lines)
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
return f"""
WITH
base AS (
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
{lag_cols}
GREATEST(
high_p - low_p,
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
) tr
FROM backtest_ohlcv
WHERE interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
AND ticker IN ({_TK})
),
indicators AS (
SELECT ticker, ts, open_p, close_p,
{lag_passthrough}
{vr_cols_str}
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
/ NULLIF(prev_close_1, 0) atr_raw
FROM base
),
signals AS (
SELECT ticker, ts sig_ts, close_p sig_price,
{vr_select}, atr_raw
FROM indicators
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
{cond_str}
),
entry_cands AS (
SELECT s.ticker, s.sig_ts,
{vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
e.ts entry_ts, e.close_p entry_price,
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
FROM signals s
JOIN backtest_ohlcv e
ON e.ticker = s.ticker
AND e.interval_cd = 'minute1'
AND e.ts > s.sig_ts
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
),
entries AS (
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
FROM entry_cands WHERE rn = 1
),
post_entry AS (
SELECT
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
b.close_p bar_price,
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
FROM entries e
JOIN backtest_ohlcv b
ON b.ticker = e.ticker
AND b.interval_cd = 'minute1'
AND b.ts >= e.entry_ts
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
)
SELECT ticker, sig_ts, entry_ts, entry_price,
{vr_select}, atr_raw,
bar_n, bar_price, running_peak
FROM post_entry
WHERE bar_n <= :ts_n + 1
ORDER BY ticker, entry_ts, bar_n
"""
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
print(f"볼륨 가속 전략 (현재봉 VOL>={VOL_MIN}x, N={N_BARS_LIST}, 가격·볼륨 동시 가속)\n", flush=True)
conn = _get_conn()
cur = conn.cursor()
cur.arraysize = 100_000
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
t_load = _time.time()
for n in N_BARS_LIST:
sql = build_sql(n)
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
n_total = 0
for m_start, m_end in _months(SIM_START, SIM_END):
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
t0 = _time.time()
cur.execute(sql, {
"load_since": load_since,
"sim_start": sim_start,
"sim_end": sim_end,
"min_vol": VOL_MIN,
"ts_n": TS_N,
})
rows = cur.fetchall()
t1 = _time.time()
n_new = 0
for row in rows:
ticker = row[0]
entry_ts = row[2]
entry_price = float(row[3])
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
atr_raw = row[4 + n]
bar_price = float(row[4 + n + 2])
running_peak= float(row[4 + n + 3])
key = (ticker, entry_ts)
if key not in ALL_ENTRIES[n]:
ALL_ENTRIES[n][key] = {
'entry_price': entry_price,
'vr0': vr_vals[0], # 현재봉 vol (가장 강한 봉)
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
'bars': [],
}
n_new += 1
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
n_total += n_new
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}", flush=True)
print(f"{n}봉 합계: {n_total}\n", flush=True)
conn.close()
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
for i, (bp, pk) in enumerate(bars):
drop = (pk - bp) / pk if pk > 0 else 0.0
pnl = (bp - entry_price) / entry_price
if drop >= atr_stop:
return pnl * 100
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
return pnl * 100
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
# ── 스윕 ──────────────────────────────────────────────────────────────────────
atr_keys = list(ATR_SWEEP.keys())
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
print(f"{total_combos}가지 조합 스윕...\n", flush=True)
t_sweep = _time.time()
results = []
for n in N_BARS_LIST:
entry_list = list(ALL_ENTRIES[n].values())
for vol in VOL_SWEEP:
for atr_combo in atr_combos:
atr_params = dict(zip(atr_keys, atr_combo))
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
continue
atr_mult = atr_params['ATR_MULT']
atr_min = atr_params['ATR_MIN']
atr_max = atr_params['ATR_MAX']
trades = []
for e in entry_list:
# 현재봉(최강봉) vol 기준으로 필터
if e['vr0'] < vol:
continue
ar = e['atr_raw']
atr_s = (atr_min if (ar != ar)
else max(atr_min, min(atr_max, ar * atr_mult)))
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
trades.append((pnl_pct, krw))
if not trades:
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
'trades': 0, 'wins': 0,
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
continue
wins = sum(1 for p, _ in trades if p > 0)
results.append({
'N_BARS': n,
'VOL': vol,
**atr_params,
'trades': len(trades),
'wins': wins,
'win_rate': wins / len(trades) * 100,
'avg_pnl': sum(p for p, _ in trades) / len(trades),
'total_krw': sum(k for _, k in trades),
})
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
df_r = pd.DataFrame(results)
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
print("=" * 105)
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
print("=" * 105)
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
print(f"{rank:>4} {int(row['N_BARS']):>2}{row['VOL']:>4.1f}x {row['ATR_MULT']:>6.1f} "
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
f"{int(row['trades']):>5}{row['win_rate']:>5.0f}% "
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}")
# N봉 × VOL별 최상위 요약
print("\n" + "" * 85)
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
print("" * 85)
for n in N_BARS_LIST:
for vol in VOL_SWEEP:
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
if sub.empty:
continue
best = sub.iloc[0]
if best['trades'] == 0:
continue
print(f" {int(n):>2}{vol:>4.1f}x {int(best['trades']):>5}{best['win_rate']:>4.0f}% "
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}"
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
print()

View File

@@ -0,0 +1,187 @@
"""core/llm_advisor 단위 테스트.
실행:
.venv/bin/python3 -m pytest tests/test_llm_advisor.py -v
"""
import json
import sys
import os
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('ORACLE_USER', 'x')
os.environ.setdefault('ORACLE_PASSWORD', 'x')
os.environ.setdefault('ORACLE_DSN', 'x')
os.environ.setdefault('ANTHROPIC_API_KEY', 'test-key')
from core.llm_advisor import (
get_exit_price,
_describe_bars,
_build_prompt,
_execute_tool,
)
TICKER = 'KRW-XRP'
def _make_bars(n: int = 20, base: float = 2100.0) -> list[dict]:
now = datetime.now()
bars = []
for i in range(n):
p = base + i * 0.5
bars.append({
'open': p,
'high': p + 2,
'low': p - 2,
'close': p + 1,
'volume': 100.0,
'ts': now - timedelta(seconds=(n - i) * 20),
})
return bars
def _make_pos(entry: float = 2084.0, seconds_ago: int = 120,
sell_price: float = 2126.0) -> dict:
return {
'entry_price': entry,
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
'sell_price': sell_price,
'sell_uuid': 'uuid-1',
'qty': 2399.0,
'stage': 0,
'llm_last_ts': None,
}
def _mock_response(action: str, price: float = 0):
"""Anthropic API 응답 Mock."""
if action == 'hold':
text = '{"action": "hold"}'
else:
text = json.dumps({'action': 'sell', 'price': price})
content_block = MagicMock()
content_block.type = 'text'
content_block.text = text
response = MagicMock()
response.content = [content_block]
response.stop_reason = 'end_turn'
return response
# ── _describe_bars ────────────────────────────────────────────────────────────
class TestDescribeBars:
def test_returns_string(self):
bars = _make_bars(20, base=2100.0)
desc = _describe_bars(bars, current_price=2110.0)
assert isinstance(desc, str)
assert '패턴 요약' in desc
def test_empty_bars_returns_fallback(self):
desc = _describe_bars([], current_price=2100.0)
assert desc == '봉 데이터 없음'
def test_trend_direction_detected(self):
bars = _make_bars(20, base=2100.0) # 상승 bars (base + i*0.5)
desc = _describe_bars(bars, current_price=2110.0)
assert '상승▲' in desc
# ── get_exit_price: hold ──────────────────────────────────────────────────────
class TestGetExitPriceHold:
def test_returns_none_on_hold(self):
pos = _make_pos()
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('hold')
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
def test_returns_none_when_no_api_key(self):
pos = _make_pos()
bar_list = _make_bars()
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}):
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
# ── get_exit_price: sell ─────────────────────────────────────────────────────
class TestGetExitPriceSell:
def test_returns_llm_suggested_price(self):
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2112.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2112.0
def test_llm_can_suggest_below_current_price(self):
"""LLM의 판단을 신뢰 — 현재가 이하 제안도 그대로 반환."""
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2080.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2080.0 # 가드 없음 — LLM 신뢰
def test_llm_can_suggest_high_price(self):
"""LLM의 판단을 신뢰 — 진입가 대비 10% 높은 제안도 그대로 반환."""
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2300.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2300.0 # 상한 가드 없음 — LLM 신뢰
# ── get_exit_price: 오류 처리 ─────────────────────────────────────────────────
class TestGetExitPriceErrors:
def test_returns_none_on_json_error(self):
"""JSON 파싱 실패 → None (cascade fallback)."""
pos = _make_pos()
bar_list = _make_bars()
bad_resp = MagicMock()
bad_resp.content = [MagicMock(type='text', text='not json')]
bad_resp.stop_reason = 'end_turn'
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = bad_resp
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
def test_returns_none_on_api_exception(self):
"""API 오류 → None (cascade fallback)."""
pos = _make_pos()
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.side_effect = Exception('API Error')
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
# ── tool 실행 ─────────────────────────────────────────────────────────────────
class TestExecuteTool:
def test_unknown_tool_returns_error_string(self):
result = _execute_tool('unknown_tool', {'ticker': TICKER})
assert '알 수 없는 tool' in result
def test_get_price_ticks_db_error_returns_string(self):
"""DB 연결 실패 시 오류 문자열 반환 (예외 아님)."""
result = _execute_tool('get_price_ticks', {'ticker': TICKER, 'minutes': 5})
assert isinstance(result, str)
def test_get_ohlcv_db_error_returns_string(self):
result = _execute_tool('get_ohlcv', {'ticker': TICKER, 'limit': 10})
assert isinstance(result, str)

View File

@@ -0,0 +1,216 @@
"""tick_trader 핵심 로직 단위 테스트.
실행:
.venv/bin/python3 -m pytest tests/test_tick_trader.py -v
테스트 대상:
- update_positions: Trail Stop 발동 시점 / peak 초기화
- _advance_stage: cascade 단계 전환 / trail 전환
- check_filled_positions: 체결 확인 / 단계 시간 초과
- enter_position: sell_uuid=None(주문실패)일 때 즉시 Trail Stop 방지
"""
import sys, os
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 환경변수 Mock — 실제 API 키 불필요
os.environ.setdefault('ACCESS_KEY', 'test')
os.environ.setdefault('SECRET_KEY', 'test')
os.environ.setdefault('SIMULATION_MODE', 'true')
os.environ.setdefault('MAX_POSITIONS', '3')
os.environ.setdefault('MAX_BUDGET', '15000000')
os.environ.setdefault('ORACLE_USER', 'x')
os.environ.setdefault('ORACLE_PASSWORD', 'x')
os.environ.setdefault('ORACLE_DSN', 'x')
os.environ.setdefault('TELEGRAM_TRADE_TOKEN', 'x')
os.environ.setdefault('TELEGRAM_CHAT_ID', '0')
# pyupbit 임포트 전 Mock 처리
with patch('pyupbit.Upbit'), patch('pyupbit.WebSocketManager'):
import importlib
import daemons.tick_trader as tt
TICKER = 'KRW-TEST'
def _make_pos(entry_price=1000.0, seconds_ago=0, sell_uuid='uuid-1', stage=0):
"""테스트용 포지션 딕셔너리 생성."""
return {
'entry_price': entry_price,
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
'running_peak': entry_price,
'qty': 100.0,
'stage': stage,
'sell_uuid': sell_uuid,
'sell_price': entry_price * 1.02,
'trail_peak_set': False,
}
# ── update_positions ──────────────────────────────────────────────────────────
class TestUpdatePositions:
def setup_method(self):
tt.positions.clear()
def test_trail_stop_does_not_fire_before_stage3_end(self):
"""③ 종료(300s) 이전에는 sell_uuid=None이어도 Trail Stop 발동 안 함."""
tt.positions[TICKER] = _make_pos(
entry_price=1000, seconds_ago=10, sell_uuid=None, stage=4
)
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 900.0}) # -10% 하락이어도
mock_sell.assert_not_called()
def test_trail_stop_fires_after_stage3_end(self):
"""300s 경과 후 peak 대비 0.8% 이상 하락 시 Trail Stop 발동."""
tt.positions[TICKER] = _make_pos(
entry_price=1000, seconds_ago=310, sell_uuid=None, stage=4
)
# 300s 이후 peak 설정 — 첫 틱에 running_peak = 1100 초기화
tt.positions[TICKER]['trail_peak_set'] = True
tt.positions[TICKER]['running_peak'] = 1100.0
with patch.object(tt, 'do_sell_market', return_value=1091.0) as mock_sell, \
patch.object(tt, '_record_exit') as mock_exit:
tt.update_positions({TICKER: 1091.0}) # 1100→1091 = -0.82%
mock_sell.assert_called_once()
mock_exit.assert_called_once_with(TICKER, 1091.0, 'trail')
def test_trail_stop_not_fire_with_sell_uuid_set(self):
"""sell_uuid가 있으면(지정가 대기 중) Trail Stop 발동 안 함."""
tt.positions[TICKER] = _make_pos(
entry_price=1000, seconds_ago=400, sell_uuid='uuid-1', stage=3
)
tt.positions[TICKER]['trail_peak_set'] = True
tt.positions[TICKER]['running_peak'] = 1100.0
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 900.0})
mock_sell.assert_not_called()
def test_peak_initialized_to_current_price_at_300s(self):
"""300s 첫 틱에서 running_peak이 진입가 아닌 현재가로 초기화된다."""
pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4)
pos['trail_peak_set'] = False
tt.positions[TICKER] = pos
with patch.object(tt, 'do_sell_market'):
tt.update_positions({TICKER: 950.0}) # 진입가보다 낮은 현재가
assert tt.positions[TICKER]['running_peak'] == 950.0, \
"running_peak이 현재가(950)로 초기화되어야 함"
assert tt.positions[TICKER]['trail_peak_set'] is True
def test_trail_stop_does_not_fire_on_peak_init_tick(self):
"""peak 초기화 첫 틱에서는 drop=0이므로 Trail Stop 발동 안 함."""
pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4)
pos['trail_peak_set'] = False
tt.positions[TICKER] = pos
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 850.0}) # 진입가 대비 -15%
mock_sell.assert_not_called() # 초기화 틱이므로 발동 안 함
def test_submit_fail_sell_uuid_none_no_trail_before_300s(self):
"""
[회귀] HOLO 버그 재현: ① 지정가 제출 실패(sell_uuid=None) + 진입 2초 후
→ Trail Stop이 발동하면 안 됨.
"""
tt.positions[TICKER] = _make_pos(
entry_price=97, seconds_ago=2, sell_uuid=None, stage=0
)
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 95.0}) # -2.06% 하락
mock_sell.assert_not_called() # 300s 이전이므로 발동 안 함
# ── _advance_stage ────────────────────────────────────────────────────────────
class TestAdvanceStage:
def setup_method(self):
tt.positions.clear()
def test_advance_from_stage0_to_stage1(self):
"""① → ② 단계 전환 시 sell_uuid 갱신."""
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='old-uuid')
with patch.object(tt, 'cancel_order_safe'), \
patch.object(tt, 'submit_limit_sell', return_value='new-uuid'):
tt._advance_stage(TICKER)
pos = tt.positions[TICKER]
assert pos['stage'] == 1
assert pos['sell_uuid'] == 'new-uuid'
assert abs(pos['sell_price'] - 1000 * 1.01) < 0.01
def test_advance_to_trail_stage(self):
"""마지막 cascade 단계 → ⑤ Trail 전환 시 sell_uuid=None."""
tt.positions[TICKER] = _make_pos(stage=len(tt.CASCADE_STAGES) - 1)
with patch.object(tt, 'cancel_order_safe'), \
patch.object(tt, 'submit_limit_sell'):
tt._advance_stage(TICKER)
pos = tt.positions[TICKER]
assert pos['stage'] == len(tt.CASCADE_STAGES)
assert pos['sell_uuid'] is None
def test_advance_submit_fail_sell_uuid_none(self):
"""지정가 재주문 실패(submit_limit_sell=None) 시 sell_uuid=None — Trail 비활성 확인."""
tt.positions[TICKER] = _make_pos(stage=0, seconds_ago=50)
with patch.object(tt, 'cancel_order_safe'), \
patch.object(tt, 'submit_limit_sell', return_value=None):
tt._advance_stage(TICKER)
pos = tt.positions[TICKER]
assert pos['sell_uuid'] is None
# Trail Stop은 300s 미경과이므로 update_positions에서 발동 안 해야 함
with patch.object(tt, 'do_sell_market') as mock_sell:
tt.update_positions({TICKER: 500.0}) # -50% 하락이어도
mock_sell.assert_not_called()
# ── check_filled_positions ────────────────────────────────────────────────────
class TestCheckFilledPositions:
def setup_method(self):
tt.positions.clear()
def test_done_order_records_exit(self):
"""체결 완료(done) 시 _record_exit 호출 (실거래 모드)."""
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', seconds_ago=10)
with patch.object(tt, 'check_order_state', return_value=('done', 1020.0)), \
patch.object(tt, '_record_exit') as mock_exit, \
patch.object(tt, 'SIM_MODE', False):
tt.check_filled_positions()
mock_exit.assert_called_once_with(TICKER, 1020.0, '')
def test_timeout_advances_stage(self):
"""단계 시간 초과 시 _advance_stage 호출."""
stage_end = tt.CASCADE_STAGES[0][1] # 40s
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1',
seconds_ago=stage_end + 5)
with patch.object(tt, 'check_order_state', return_value=('wait', None)), \
patch.object(tt, '_advance_stage') as mock_advance:
tt.check_filled_positions()
mock_advance.assert_called_once_with(TICKER)
def test_cancelled_order_resubmits(self):
"""주문 취소(cancel) 감지 시 _advance_stage 호출 (실거래 모드)."""
tt.positions[TICKER] = _make_pos(stage=1, sell_uuid='u1', seconds_ago=50)
with patch.object(tt, 'check_order_state', return_value=('cancel', None)), \
patch.object(tt, '_advance_stage') as mock_advance, \
patch.object(tt, 'SIM_MODE', False):
tt.check_filled_positions()
mock_advance.assert_called_once_with(TICKER)
def test_trail_stage_skipped(self):
"""Trail 단계(sell_uuid=None)는 check_filled_positions에서 스킵."""
tt.positions[TICKER] = _make_pos(stage=4, sell_uuid=None, seconds_ago=4000)
with patch.object(tt, 'check_order_state') as mock_state:
tt.check_filled_positions()
mock_state.assert_not_called()

View File

@@ -0,0 +1,208 @@
"""5% 익절 전략 백테스트 (손절 없음)
규칙:
- 진입: 직전 포지션 청산 다음 캔들 종가 매수
- 청산: 고가 기준 +5% 달성 시 TP 가격으로 매도
- 손절: 없음 — 손실 중 무조건 보유
- 자본: 1,000,000 KRW (복리 없음 / 고정)
- 캔들: 1h
Output:
- 종목별: 거래 수, TP 성공, 승률, 총 손익, 평균 보유시간, 최대 보유시간
- 현재 미결 포지션 현황
- 전략 한계 분석
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Optional
import pyupbit
import pandas as pd
from datetime import datetime
# ─── 파라미터 ──────────────────────────────────────────────────
TICKERS = [
"KRW-WET", "KRW-FLOW", "KRW-BIO",
"KRW-CYBER", "KRW-JTO", "KRW-AERO",
"KRW-LAYER", "KRW-DKA",
]
TP_PCT = 0.05 # 익절 목표 5%
CAPITAL = 1_000_000 # 거래당 고정 자본 (복리 없음)
INTERVAL = "minute60"
COUNT = 1440 # 60일치 1h 캔들
# ─── 데이터 클래스 ──────────────────────────────────────────────
@dataclass
class Trade:
entry_time: datetime
exit_time: datetime
entry_px: float
exit_px: float
pnl: float
holding_h: int
status: str # TP | HOLDING_LOSS | HOLDING_PROFIT
# ─── 백테스트 ──────────────────────────────────────────────────
def backtest_ticker(ticker: str) -> Optional[dict]:
df = pyupbit.get_ohlcv(ticker, interval=INTERVAL, count=COUNT)
if df is None or len(df) < 20:
return None
trades: list[Trade] = []
i = 0
while i < len(df) - 1:
# 매수: 현재 캔들 종가
entry_px = float(df["close"].iloc[i])
entry_time = df.index[i]
tp_px = entry_px * (1 + TP_PCT)
hit = False
for j in range(i + 1, len(df)):
if float(df["high"].iloc[j]) >= tp_px:
trades.append(Trade(
entry_time = entry_time,
exit_time = df.index[j],
entry_px = entry_px,
exit_px = tp_px,
pnl = CAPITAL * TP_PCT,
holding_h = j - i,
status = "TP",
))
i = j # 다음 진입은 j 캔들 종가
hit = True
break
if not hit:
# 데이터 끝까지 TP 미달성
last_px = float(df["close"].iloc[-1])
pnl = CAPITAL * (last_px - entry_px) / entry_px
status = "HOLDING_PROFIT" if pnl >= 0 else "HOLDING_LOSS"
trades.append(Trade(
entry_time = entry_time,
exit_time = df.index[-1],
entry_px = entry_px,
exit_px = last_px,
pnl = pnl,
holding_h = len(df) - i - 1,
status = status,
))
break
closed = [t for t in trades if t.status == "TP"]
holding = [t for t in trades if t.status != "TP"]
total_pnl = sum(t.pnl for t in trades)
realized_pnl = sum(t.pnl for t in closed)
return {
"ticker": ticker,
"n_total": len(trades),
"n_tp": len(closed),
"win_rate": len(closed) / len(trades) * 100 if trades else 0,
"realized_pnl": realized_pnl,
"total_pnl": total_pnl,
"avg_h": sum(t.holding_h for t in closed) / len(closed) if closed else 0,
"max_h": max(t.holding_h for t in trades) if trades else 0,
"holding": holding,
"trades": trades,
}
# ─── 메인 ──────────────────────────────────────────────────────
def main() -> None:
print("=" * 62)
print(" 5% 익절 전략 백테스트 (손절 없음)")
print(f" 기간: 최근 {COUNT}h ({COUNT//24}일) | 자본: {CAPITAL:,}원 | TP: {TP_PCT*100:.0f}%")
print("=" * 62)
all_results = []
for ticker in TICKERS:
r = backtest_ticker(ticker)
time.sleep(0.1)
if r:
all_results.append(r)
# ─── 종목별 출력 ───────────────────────────────────────────
print(f"\n{'종목':<12} {'거래':<5} {'TP':>5} {'승률':>7} {'실현손익':>12} "
f"{'미결손익':>12} {'평균보유':>8} {'최장보유':>8}")
print("-" * 80)
total_realized = 0
total_open_pnl = 0
for r in all_results:
h_pnl = sum(t.pnl for t in r["holding"])
hold_str = ""
if r["holding"]:
h = r["holding"][0]
mark = "⚠️ " if h.status == "HOLDING_LOSS" else "📈"
hold_str = f" {mark}{h.pnl:+,.0f}원 ({h.holding_h}h 보유중)"
print(f"{r['ticker']:<12} {r['n_total']:<5} {r['n_tp']:>5} "
f"{r['win_rate']:>6.1f}% {r['realized_pnl']:>+12,.0f} "
f"{h_pnl:>+12,.0f} {r['avg_h']:>7.1f}h {r['max_h']:>7}h"
+ hold_str)
total_realized += r["realized_pnl"]
total_open_pnl += h_pnl
print("-" * 80)
print(f"{'합계':<12} {'':5} {'':>5} {'':>7} {total_realized:>+12,.0f} "
f"{total_open_pnl:>+12,.0f}")
# ─── 미결 포지션 상세 ──────────────────────────────────────
open_trades = [(r["ticker"], h) for r in all_results for h in r["holding"]]
if open_trades:
print("\n▶ 현재 미결 포지션")
print(f" {'종목':<12} {'진입시각':<20} {'진입가':>12} {'현재가':>12} {'수익률':>8} {'보유시간':>8}")
for ticker, h in open_trades:
chg = (h.exit_px - h.entry_px) / h.entry_px * 100
print(f" {ticker:<12} {str(h.entry_time)[:16]:<20} "
f"{h.entry_px:>12,.2f} {h.exit_px:>12,.2f} "
f"{chg:>+7.2f}% {h.holding_h:>7}h")
# ─── 최장 잠김 분석 ────────────────────────────────────────
all_trades_flat = [(r["ticker"], t) for r in all_results for t in r["trades"]]
if all_trades_flat:
worst = max(all_trades_flat, key=lambda x: x[1].holding_h)
print(f"\n▶ 최장 자본 잠김")
print(f" {worst[0]}: {worst[1].holding_h}h ({worst[1].holding_h/24:.1f}일) "
f"진입 {str(worst[1].entry_time)[:16]}"
f"{'TP 달성' if worst[1].status == 'TP' else '미결'}")
# ─── 전략 평가 ─────────────────────────────────────────────
if all_results:
total_trades = sum(r["n_total"] for r in all_results)
total_tp = sum(r["n_tp"] for r in all_results)
avg_hold_all = [r["avg_h"] for r in all_results if r["avg_h"] > 0]
still_holding = sum(1 for r in all_results if r["holding"])
print("\n▶ 종합 평가")
print(f" 전체 거래: {total_trades}건 | TP 성공: {total_tp}건 | "
f"승률: {total_tp/total_trades*100:.1f}%")
if avg_hold_all:
print(f" TP까지 평균 보유: {sum(avg_hold_all)/len(avg_hold_all):.1f}h "
f"({sum(avg_hold_all)/len(avg_hold_all)/24:.1f}일)")
print(f" 현재 미결 포지션: {still_holding}개 종목")
print(f" 총 실현 손익: {total_realized:+,.0f}")
print(f" 총 평가 손익: {total_realized + total_open_pnl:+,.0f}")
print("\n▶ 전략 한계")
if still_holding > 0:
print(f" ⚠️ {still_holding}개 종목이 TP 미달성 상태로 자본이 잠겨있습니다.")
print(" ⚠️ 손절 없음 → 하락장에서 자본이 무한정 묶일 수 있습니다.")
print(" ⚠️ 기회비용: 잠긴 자본으로 다른 거래 불가.")
print(" 💡 개선안: 최대 보유기간(시간) 제한 또는 % 손절 추가 권장.")
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,6 @@
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from dotenv import load_dotenv
load_dotenv()

View File

@@ -12,6 +12,9 @@
진입/청산: 10분봉 단위로 체크 (실제 시스템의 15 폴링 근사)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import pickle
from pathlib import Path
import pandas as pd

View File

@@ -6,6 +6,9 @@
C (단순): 거래량 급증만 (베이스라인, 노이즈 확인용)
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
import pickle
import time

View File

@@ -6,6 +6,9 @@ WF_WINDOW 크기(2, 3, 5)에 따라 차단/허용 여부를 시뮬레이션.
허용된 거래 실제 P&L 반영
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from dotenv import load_dotenv
load_dotenv()

View File

@@ -12,6 +12,9 @@ Phase 2: 마지막 실거래 이후 price_history 기반 신호로 추가 거래
D: WF 없음
"""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os, time
from datetime import datetime
from dotenv import load_dotenv

433
backtest_march.py Normal file
View File

@@ -0,0 +1,433 @@
"""3월 백테스트 — 현재 tick_trader 설정 기준 (1분봉 시뮬레이션)
20초봉 → 1분봉 환산:
VOL_LOOKBACK 61(20s) → 20(1min)
트레일/손절은 가격 기반이라 동일
타임아웃 4h = 240분
"""
import time
from collections import defaultdict, deque
from datetime import datetime, timedelta
import pyupbit
from dotenv import load_dotenv
load_dotenv()
# ── 설정 (현재 tick_trader 기준) ──
TICKERS = [
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
]
VOL_LOOKBACK = 20 # 1분봉 기준
VOL_MIN = 5.0
VOL_KRW_MIN = 5_000_000 # 1분봉 거래대금 (20초봉 500만 × 3)
SPREAD_MIN = 0.3 # 횡보 필터: 5봉 변동폭 % (15봉/3)
HIGHPOS_BARS = 30 # 고점 필터: 30봉(30분)
HIGHPOS_THR = 0.9
HIGHPOS_MOVE = 1.0 # 구간 변동 1%+
TRAIL_PCT = 0.015
MIN_PROFIT_PCT = 0.005
STOP_LOSS_PCT = 0.02
TIMEOUT_BARS = 240 # 4시간
MAX_POS = 3
PER_POS = 33_333
FEE = 0.0005
def calc_vr(bars, idx):
start = max(0, idx - VOL_LOOKBACK)
end = max(0, idx - 1)
baseline = sorted(bars[i]['volume'] for i in range(start, end))
if not baseline:
return 0.0
trim = max(1, len(baseline) // 10)
trimmed = baseline[:len(baseline) - trim]
if not trimmed:
return 0.0
avg = sum(trimmed) / len(trimmed)
return bars[idx]['volume'] / avg if avg > 0 else 0.0
def load_data(ticker, start_date='2026-03-01', end_date='2026-03-07'):
"""Oracle backtest_ohlcv에서 1분봉 로드, 없으면 pyupbit 페이징."""
import os
bars = []
# Oracle DB 시도
try:
import oracledb
kwargs = dict(
user=os.environ['ORACLE_USER'],
password=os.environ['ORACLE_PASSWORD'],
dsn=os.environ['ORACLE_DSN'],
)
if w := os.environ.get('ORACLE_WALLET'):
kwargs['config_dir'] = w
conn = oracledb.connect(**kwargs)
cur = conn.cursor()
cur.execute("""
SELECT ts, open_p, high_p, low_p, close_p, volume_p
FROM backtest_ohlcv
WHERE ticker = :t AND interval_cd = 'minute1'
AND ts >= TO_TIMESTAMP(:s, 'YYYY-MM-DD')
AND ts < TO_TIMESTAMP(:e, 'YYYY-MM-DD')
ORDER BY ts
""", {'t': ticker, 's': start_date, 'e': end_date})
rows = cur.fetchall()
conn.close()
if rows:
for ts, o, h, l, c, v in rows:
bars.append({'ts': ts, 'open': float(o), 'high': float(h),
'low': float(l), 'close': float(c), 'volume': float(v)})
return bars
except Exception as e:
pass
# Fallback: pyupbit 페이징
import pandas as pd
all_df = []
to = end_date + ' 23:59:59'
for _ in range(60):
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=200, to=to)
if df is None or df.empty:
break
all_df.append(df)
earliest = df.index[0]
if str(earliest)[:10] < start_date:
break
to = (earliest - timedelta(minutes=1)).strftime('%Y-%m-%d %H:%M:%S')
time.sleep(0.12)
if not all_df:
return []
merged = pd.concat(all_df).sort_index()
merged = merged[~merged.index.duplicated(keep='first')]
merged = merged[start_date:end_date]
for ts, row in merged.iterrows():
bars.append({'ts': ts, 'open': row['open'], 'high': row['high'],
'low': row['low'], 'close': row['close'], 'volume': row['volume']})
return bars
def run_backtest():
print("=" * 80)
print("3월 백테스트 (1분봉 시뮬레이션)")
print(f"종목: {len(TICKERS)}개 | VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M")
print(f"트레일: 고점-{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%) | 손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_BARS//60}h")
print(f"포지션: 최대 {MAX_POS} | 종목당 {PER_POS:,}")
print("=" * 80)
# 데이터 로드
all_bars = {}
for t in TICKERS:
print(f" 로딩 {t}...", end=' ')
bars = load_data(t)
print(f"{len(bars)}")
all_bars[t] = bars
time.sleep(0.2)
# 모든 종목의 타임스탬프를 합쳐서 시간순 이벤트 생성
events = []
for t in TICKERS:
for i, b in enumerate(all_bars[t]):
events.append((b['ts'], t, i))
events.sort(key=lambda x: x[0])
# 시뮬레이션
positions = {}
trades = []
signals_count = 0
skipped_spread = 0
skipped_highpos = 0
skipped_maxpos = 0
skipped_greens = 0
for ts, ticker, idx in events:
bar_list = all_bars[ticker]
b = bar_list[idx]
# ── 포지션 관리 (매 봉마다) ──
for t in list(positions.keys()):
pos = positions[t]
# 해당 종목의 현재 봉 찾기
t_bars = all_bars[t]
t_idx = pos['last_idx']
# 시간이 현재 이벤트 이후인 봉이 있으면 업데이트
while t_idx + 1 < len(t_bars) and t_bars[t_idx + 1]['ts'] <= ts:
t_idx += 1
pos['last_idx'] = t_idx
cur_bar = t_bars[t_idx]
cur_price = cur_bar['close']
cur_high = cur_bar['high']
cur_low = cur_bar['low']
# peak 갱신 (봉 고가 기준)
pos['running_peak'] = max(pos['running_peak'], cur_high)
bars_held = t_idx - pos['entry_idx']
# 봉 내 손절 체크 (저가 기준)
loss = (cur_low - pos['entry_price']) / pos['entry_price']
if loss <= -STOP_LOSS_PCT:
exit_p = pos['entry_price'] * (1 - STOP_LOSS_PCT)
pnl = -STOP_LOSS_PCT * 100
krw = PER_POS * (-STOP_LOSS_PCT) - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
'entry': pos['entry_price'], 'exit': exit_p,
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'stoploss',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
del positions[t]
continue
# 트레일링 체크 (종가 기준)
profit = (cur_price - pos['entry_price']) / pos['entry_price']
drop = (pos['running_peak'] - cur_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
if profit >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
pnl = profit * 100
krw = PER_POS * profit - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
'entry': pos['entry_price'], 'exit': cur_price,
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'trail',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
del positions[t]
continue
# 타임아웃
if bars_held >= TIMEOUT_BARS:
pnl = profit * 100
krw = PER_POS * profit - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
'entry': pos['entry_price'], 'exit': cur_price,
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'timeout',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
del positions[t]
continue
# ── 시그널 감지 ──
if idx < VOL_LOOKBACK + 3:
continue
if ticker in positions:
continue
if len(positions) >= MAX_POS:
continue
# 양봉
if b['close'] <= b['open']:
continue
# 거래량 배수
vr = calc_vr(bar_list, idx)
if vr < VOL_MIN:
continue
# 거래대금 하한
bar_krw = b['close'] * b['volume']
if bar_krw < VOL_KRW_MIN:
continue
# 횡보 필터 (5봉)
recent = bar_list[idx-4:idx+1]
p_high = max(x['high'] for x in recent)
p_low = min(x['low'] for x in recent)
if p_low > 0:
spread = (p_high - p_low) / p_low * 100
if spread < SPREAD_MIN:
skipped_spread += 1
continue
# 고점 필터
long_start = max(0, idx - HIGHPOS_BARS)
long_bars = bar_list[long_start:idx+1]
l_high = max(x['high'] for x in long_bars)
l_low = min(x['low'] for x in long_bars)
if l_high > l_low:
pos_in_range = (b['close'] - l_low) / (l_high - l_low)
move_pct = (l_high - l_low) / l_low * 100
if pos_in_range > HIGHPOS_THR and move_pct > HIGHPOS_MOVE:
skipped_highpos += 1
continue
# 진입 전 연속 양봉 수
prev_greens = 0
for k in range(idx-1, max(idx-10, 0), -1):
if bar_list[k]['close'] > bar_list[k]['open']:
prev_greens += 1
else:
break
# 연속 양봉 필터: 2봉 이상 연속 양봉 필요
if prev_greens < 2:
skipped_greens += 1
continue
signals_count += 1
# 진입봉 상승폭
bar_rise_pct = (b['close'] - b['open']) / b['open'] * 100 if b['open'] > 0 else 0
# 매수 (현재가)
entry_price = b['close']
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': b['ts'],
'entry_idx': idx,
'last_idx': idx,
'running_peak': b['high'],
'vol_ratio': vr,
'prev_greens': prev_greens,
'bar_rise_pct': bar_rise_pct,
}
# 미청산 포지션 강제 종료
for t, pos in list(positions.items()):
t_bars = all_bars[t]
last_bar = t_bars[-1]
cur_price = last_bar['close']
profit = (cur_price - pos['entry_price']) / pos['entry_price']
krw = PER_POS * profit - PER_POS * FEE * 2
trades.append({
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': last_bar['ts'],
'entry': pos['entry_price'], 'exit': cur_price,
'pnl_pct': profit * 100, 'pnl_krw': krw,
'bars': len(t_bars) - 1 - pos['entry_idx'], 'tag': 'open',
'peak': pos['running_peak'],
'vol_ratio': pos.get('vol_ratio', 0),
'prev_greens': pos.get('prev_greens', 0),
'bar_rise_pct': pos.get('bar_rise_pct', 0),
})
# ── 결과 출력 ──
print("\n" + "=" * 80)
print("결과")
print("=" * 80)
print(f"\n시그널 발생: {signals_count}")
print(f"필터 제외: 횡보 {skipped_spread}건, 고점 {skipped_highpos}건, 연속양봉<2 {skipped_greens}")
print(f"총 거래: {len(trades)}\n")
if not trades:
print("거래 없음")
return
wins = [t for t in trades if t['pnl_pct'] > 0]
losses = [t for t in trades if t['pnl_pct'] <= 0]
total_krw = sum(t['pnl_krw'] for t in trades)
win_rate = len(wins) / len(trades) * 100
print(f"승률: {win_rate:.1f}% ({len(wins)}{len(losses)}패)")
print(f"총 PNL: {total_krw:+,.0f}")
print(f"평균 PNL: {sum(t['pnl_pct'] for t in trades)/len(trades):+.2f}%")
if wins:
print(f"평균 수익 (승): +{sum(t['pnl_pct'] for t in wins)/len(wins):.2f}%")
if losses:
print(f"평균 손실 (패): {sum(t['pnl_pct'] for t in losses)/len(losses):+.2f}%")
# 청산 유형별
by_tag = defaultdict(list)
for t in trades:
by_tag[t['tag']].append(t)
print("\n[청산 유형별]")
for tag, tlist in sorted(by_tag.items()):
avg_pnl = sum(t['pnl_pct'] for t in tlist) / len(tlist)
total = sum(t['pnl_krw'] for t in tlist)
print(f" {tag:10s}: {len(tlist):3d}건 avg {avg_pnl:+.2f}% 합계 {total:+,.0f}")
# 종목별
by_ticker = defaultdict(list)
for t in trades:
by_ticker[t['ticker']].append(t)
print("\n[종목별]")
for ticker in TICKERS:
tlist = by_ticker.get(ticker, [])
if not tlist:
continue
w = sum(1 for t in tlist if t['pnl_pct'] > 0)
total = sum(t['pnl_krw'] for t in tlist)
avg = sum(t['pnl_pct'] for t in tlist) / len(tlist)
print(f" {ticker:12s}: {len(tlist):3d}{w}{len(tlist)-w}패 avg {avg:+.2f}% 합계 {total:+,.0f}")
# 승패별 시그널 특성 분석
print("\n[승패별 시그널 특성]")
win_vrs = [t['vol_ratio'] for t in trades if t['pnl_pct'] > 0]
loss_vrs = [t['vol_ratio'] for t in trades if t['pnl_pct'] <= 0]
print(f" 승리 거래량비: 평균 {sum(win_vrs)/len(win_vrs):.1f}x 중간값 {sorted(win_vrs)[len(win_vrs)//2]:.1f}x")
print(f" 패배 거래량비: 평균 {sum(loss_vrs)/len(loss_vrs):.1f}x 중간값 {sorted(loss_vrs)[len(loss_vrs)//2]:.1f}x")
# 거래량비 구간별 승률
print("\n[거래량비 구간별 승률]")
for lo, hi in [(5,7),(7,10),(10,15),(15,30),(30,999)]:
subset = [t for t in trades if lo <= t['vol_ratio'] < hi]
if not subset:
continue
w = sum(1 for t in subset if t['pnl_pct'] > 0)
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
print(f" {lo:3d}~{hi:3d}x: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
# 연속 양봉 여부별 승률
print("\n[진입 전 연속 양봉 수별 승률]")
for n_green in [1, 2, 3]:
subset = [t for t in trades if t.get('prev_greens', 0) >= n_green]
if not subset:
continue
w = sum(1 for t in subset if t['pnl_pct'] > 0)
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
print(f" {n_green}봉+ 연속양봉: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
# 진입봉 상승폭별 승률
print("\n[진입봉 상승폭별 승률]")
for lo, hi in [(0,0.3),(0.3,0.7),(0.7,1.5),(1.5,100)]:
subset = [t for t in trades if lo <= t.get('bar_rise_pct', 0) < hi]
if not subset:
continue
w = sum(1 for t in subset if t['pnl_pct'] > 0)
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
print(f" {lo:.1f}~{hi:.1f}%: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
# 상위 거래
trades_sorted = sorted(trades, key=lambda x: -x['pnl_pct'])
print("\n[TOP 10 수익]")
for t in trades_sorted[:10]:
peak_pnl = (t['peak'] - t['entry']) / t['entry'] * 100
print(f" {t['ticker']:12s} {str(t['entry_ts'])[5:16]} {t['entry']:>12,.1f}{t['exit']:>12,.1f} "
f"PNL {t['pnl_pct']:+.2f}% 고점 +{peak_pnl:.1f}% {t['bars']}봉 [{t['tag']}]")
print("\n[WORST 10 손실]")
for t in trades_sorted[-10:]:
print(f" {t['ticker']:12s} {str(t['entry_ts'])[5:16]} {t['entry']:>12,.1f}{t['exit']:>12,.1f} "
f"PNL {t['pnl_pct']:+.2f}% {t['bars']}봉 [{t['tag']}]")
# 일별 PNL
by_date = defaultdict(float)
by_date_cnt = defaultdict(int)
for t in trades:
d = str(t['exit_ts'])[:10]
by_date[d] += t['pnl_krw']
by_date_cnt[d] += 1
print("\n[일별 PNL]")
for d in sorted(by_date.keys()):
print(f" {d}: {by_date_cnt[d]:3d}{by_date[d]:+,.0f}")
if __name__ == '__main__':
run_backtest()

696
core/llm_advisor.py Normal file
View File

@@ -0,0 +1,696 @@
"""OpenRouter LLM 기반 매수 어드바이저.
시그널 감지 후 LLM이 매수 여부를 판단한다.
매도는 트레일링 스탑으로 대체되어 LLM을 사용하지 않는다.
DB Tool (OpenAI function calling):
- get_price_ticks: Oracle price_tick (최근 N분 가격 틱)
- get_ohlcv: Oracle backtest_ohlcv 1분봉
- get_ticker_context: 종목 평판 (가격 변동, 뉴스)
- get_btc_trend: BTC 최근 동향
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Optional
log = logging.getLogger(__name__)
# 프롬프트에 포함할 봉 수
INPUT_BARS = 20
# ── Oracle DB Tools ──────────────────────────────────────────────────────────
def _get_conn():
"""Oracle ADB 연결 (.env 기반)."""
import oracledb
kwargs = dict(
user = os.environ['ORACLE_USER'],
password = os.environ['ORACLE_PASSWORD'],
dsn = os.environ['ORACLE_DSN'],
)
if w := os.environ.get('ORACLE_WALLET'):
kwargs['config_dir'] = w
return oracledb.connect(**kwargs)
def _tool_get_price_ticks(ticker: str, minutes: int = 10) -> str:
"""Oracle price_tick 테이블에서 최근 N분 가격 틱 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT ts, price
FROM price_tick
WHERE ticker = :t
AND ts >= SYSTIMESTAMP - NUMTODSINTERVAL(:m, 'MINUTE')
ORDER BY ts DESC
FETCH FIRST 100 ROWS ONLY""",
{'t': ticker, 'm': minutes},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 최근 {minutes}분 틱 데이터 없음"
lines = [f" {r[0].strftime('%H:%M:%S')} {float(r[1]):>12,.2f}" for r in rows]
return f"{ticker} 최근 {minutes}분 가격 틱 ({len(rows)}건):\n" + "\n".join(lines)
except Exception as e:
return f"DB 오류: {e}"
def _tool_get_ohlcv(ticker: str, limit: int = 30) -> str:
"""Oracle backtest_ohlcv 1분봉 최근 N개 조회 (지지/저항 파악용)."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT ts, open_p, high_p, low_p, close_p, volume_p
FROM backtest_ohlcv
WHERE ticker = :t
AND interval_cd = 'minute1'
ORDER BY ts DESC
FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'n': limit},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 1분봉 데이터 없음"
lines = [
f" {r[0].strftime('%H:%M')} O{float(r[1]):>10,.0f} H{float(r[2]):>10,.0f}"
f" L{float(r[3]):>10,.0f} C{float(r[4]):>10,.0f} V{float(r[5]):.0f}"
for r in reversed(rows)
]
return f"{ticker} 1분봉 최근 {len(rows)}개:\n" + "\n".join(lines)
except Exception as e:
return f"DB 오류: {e}"
# ── 종목 컨텍스트 조회 ────────────────────────────────────────────────────────
def _tool_get_context(ticker: str) -> str:
"""ticker_context 테이블에서 종목의 가격 통계 + 뉴스 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"SELECT context_type, content FROM ticker_context WHERE ticker = :t",
{'t': ticker},
)
rows = cur.fetchall()
if not rows:
conn.close()
return f"{ticker} 컨텍스트 데이터 없음"
parts = []
for ctx_type, content in rows:
# CLOB(LOB) → str 변환 (conn 닫기 전에 읽어야 함)
text = content.read() if hasattr(content, 'read') else str(content)
parts.append(f"[{ctx_type}]\n{text}")
conn.close()
return f"{ticker} 종목 컨텍스트:\n" + "\n\n".join(parts)
except Exception as e:
return f"DB 오류: {e}"
# ── 거래 이력 조회 ────────────────────────────────────────────────────────────
def _tool_get_trade_history(ticker: str, limit: int = 10) -> str:
"""trade_results 테이블에서 해당 종목 최근 거래 이력 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT traded_at, is_win, pnl_pct, buy_price, sell_price, sell_reason
FROM trade_results
WHERE ticker = :t
ORDER BY traded_at DESC
FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'n': limit},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 거래 이력 없음"
lines = []
wins = sum(1 for r in rows if r[1])
for r in rows:
ts = r[0].strftime('%m/%d %H:%M') if r[0] else '?'
wl = '' if r[1] else ''
pnl = float(r[2]) if r[2] else 0
reason = r[5] or ''
lines.append(f" {ts} {wl} {pnl:+.2f}% {reason}")
header = f"{ticker} 최근 {len(rows)}건 (승률 {wins}/{len(rows)}={wins/len(rows)*100:.0f}%):"
note = "\n ※ 과거 성과는 참고용입니다. 현재 시그널 강도가 높으면 과거 연패와 무관하게 진입하세요."
return header + "\n" + "\n".join(lines) + note
except Exception as e:
return f"DB 오류: {e}"
def _tool_get_btc_trend() -> str:
"""BTC 최근 동향 (1시간봉 6개 + 일봉)."""
try:
import pyupbit
lines = []
# 일봉 2개
day_df = pyupbit.get_ohlcv('KRW-BTC', interval='day', count=2)
if day_df is not None and len(day_df) >= 2:
today = day_df.iloc[-1]
prev = day_df.iloc[-2]
chg = (today['close'] - prev['close']) / prev['close'] * 100
lines.append(f"[BTC 일봉] 현재 {today['close']:,.0f}원 (전일 대비 {chg:+.2f}%)")
lines.append(f" 고가 {today['high']:,.0f} 저가 {today['low']:,.0f}")
# 1시간봉 6개
h1_df = pyupbit.get_ohlcv('KRW-BTC', interval='minute60', count=6)
if h1_df is not None and not h1_df.empty:
first_c = float(h1_df['close'].iloc[0])
last_c = float(h1_df['close'].iloc[-1])
h_chg = (last_c - first_c) / first_c * 100
trend = '상승' if h_chg > 0.5 else '하락' if h_chg < -0.5 else '횡보'
lines.append(f"[BTC 6시간 추세] {trend} ({h_chg:+.2f}%)")
for ts, row in h1_df.iterrows():
lines.append(
f" {ts.strftime('%H:%M')}{row['close']:>12,.0f} "
f"거래량{row['volume']:,.2f}"
)
return "\n".join(lines) if lines else "BTC 데이터 조회 실패"
except Exception as e:
return f"BTC 조회 오류: {e}"
# ── Tool 정의 (OpenAI function calling 형식) ─────────────────────────────────
_TOOLS = [
{
'type': 'function',
'function': {
'name': 'get_price_ticks',
'description': (
'Oracle DB에서 특정 종목의 최근 N분간 가격 틱 데이터를 조회합니다. '
'단기 가격 흐름과 지지/저항 수준 파악에 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'minutes': {'type': 'integer', 'description': '최근 N분 데이터 (기본 10)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_ohlcv',
'description': (
'Oracle DB에서 특정 종목의 1분봉 OHLCV 데이터를 조회합니다. '
'지지선/저항선 파악과 추세 분석에 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'limit': {'type': 'integer', 'description': '조회할 봉 수 (기본 30)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_ticker_context',
'description': (
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
'매매 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_trade_history',
'description': (
'특정 종목의 최근 거래 이력을 조회합니다. '
'승률, 손익, 매도 사유 등을 확인해 이 종목의 과거 성과를 파악하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'limit': {'type': 'integer', 'description': '조회 건수 (기본 10)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_btc_trend',
'description': (
'BTC(비트코인)의 최근 가격 동향을 조회합니다. '
'알트코인 매수 전 BTC 추세를 반드시 확인하세요. '
'BTC 하락 시 알트코인 동반 하락 위험이 높습니다.'
),
'parameters': {
'type': 'object',
'properties': {},
},
},
},
]
def _execute_tool(tool_name: str, tool_input: dict) -> str:
"""LLM이 요청한 tool을 실행하고 결과를 반환."""
if tool_name == 'get_price_ticks':
return _tool_get_price_ticks(
ticker = tool_input['ticker'],
minutes = tool_input.get('minutes', 10),
)
if tool_name == 'get_ohlcv':
return _tool_get_ohlcv(
ticker = tool_input['ticker'],
limit = tool_input.get('limit', 30),
)
if tool_name == 'get_ticker_context':
return _tool_get_context(ticker=tool_input['ticker'])
if tool_name == 'get_trade_history':
return _tool_get_trade_history(
ticker=tool_input['ticker'],
limit=tool_input.get('limit', 10),
)
if tool_name == 'get_btc_trend':
return _tool_get_btc_trend()
return f'알 수 없는 tool: {tool_name}'
# ── 시장 컨텍스트 수집 ───────────────────────────────────────────────────────
def _get_market_context(ticker: str) -> str:
"""오늘 일봉 요약 + 최근 4시간 1시간봉을 LLM 프롬프트용 텍스트로 반환.
pyupbit API 호출 실패 시 빈 문자열 반환 (graceful degradation).
"""
try:
import pyupbit
lines: list[str] = []
# 오늘 일봉 (오늘 + 전일 2개)
day_df = pyupbit.get_ohlcv(ticker, interval='day', count=2)
if day_df is not None and not day_df.empty:
today = day_df.iloc[-1]
prev = day_df.iloc[-2] if len(day_df) > 1 else None
vol_note = ''
if prev is not None and prev['volume'] > 0:
vol_note = f' (전일 대비 {today["volume"] / prev["volume"]:.1f}x)'
lines.append('[오늘 일봉]')
lines.append(f' 고가 {today["high"]:>12,.0f}원 저가 {today["low"]:>12,.0f}')
lines.append(f' 시가 {today["open"]:>12,.0f}원 거래량 {today["volume"]:,.0f}{vol_note}')
# 최근 4시간 1시간봉
h1_df = pyupbit.get_ohlcv(ticker, interval='minute60', count=4)
if h1_df is not None and not h1_df.empty:
lines.append(f'[최근 {len(h1_df)}시간봉]')
for ts, row in h1_df.iterrows():
lines.append(
f' {ts.strftime("%H:%M")} '
f'{row["high"]:>10,.0f}{row["low"]:>10,.0f} '
f'{row["close"]:>10,.0f} 거래량{row["volume"]:,.0f}'
)
return '\n'.join(lines)
except Exception as e:
log.debug(f'[LLM] 시장 컨텍스트 조회 실패: {e}')
return ''
# ── 프롬프트 빌더 ─────────────────────────────────────────────────────────────
def _describe_bars(bar_list: list[dict], current_price: float) -> str:
"""봉 데이터를 LLM이 읽기 쉬운 텍스트로 변환."""
recent = bar_list[-INPUT_BARS:] if len(bar_list) >= INPUT_BARS else bar_list
if not recent:
return '봉 데이터 없음'
lines = []
for b in recent:
ts_str = b['ts'].strftime('%H:%M:%S') if isinstance(b.get('ts'), datetime) else ''
lines.append(
f' {ts_str} 종가{b["close"]:>10,.0f} 고가{b["high"]:>10,.0f}'
f' 저가{b["low"]:>10,.0f}'
)
highs5 = [b['high'] for b in recent[-5:]]
closes5 = [b['close'] for b in recent[-5:]]
period_high = max(b['high'] for b in recent)
from_peak = (current_price - period_high) / period_high * 100
trend_h = '하락▼' if highs5[-1] < highs5[0] else '상승▲' if highs5[-1] > highs5[0] else '횡보─'
trend_c = '하락▼' if closes5[-1] < closes5[0] else '상승▲' if closes5[-1] > closes5[0] else '횡보─'
summary = (
f'\n[패턴 요약]\n'
f'- 고가 추세: {trend_h} ({highs5[0]:,.0f}{highs5[-1]:,.0f})\n'
f'- 종가 추세: {trend_c} ({closes5[0]:,.0f}{closes5[-1]:,.0f})\n'
f'- 구간 최고가: {period_high:,.0f}원 현재가 대비: {from_peak:+.2f}%'
)
return '\n'.join(lines) + summary
def _calc_momentum(bar_list: list[dict], current_price: float, bar_sec: int = 20) -> str:
"""다중 타임프레임 모멘텀 요약. 매도 LLM에 상승 곡선 판단 근거 제공."""
if len(bar_list) < 10:
return ''
lines = []
# 1분(3봉), 3분(9봉), 5분(15봉), 10분(30봉) 전 가격 대비 변화율
intervals = [
('1분', 3), ('3분', 9), ('5분', 15), ('10분', 30),
]
for label, n_bars in intervals:
if len(bar_list) < n_bars + 1:
continue
past_price = bar_list[-(n_bars + 1)]['close']
chg = (current_price - past_price) / past_price * 100
arrow = '' if chg > 0.3 else '' if chg < -0.3 else ''
lines.append(f' {label}전 대비: {chg:+.2f}% {arrow} ({past_price:,.0f}{current_price:,.0f})')
# 최근 15봉 연속 상승/하락 카운트
recent = bar_list[-15:]
up_count = sum(1 for b in recent if b['close'] > b['open'])
dn_count = sum(1 for b in recent if b['close'] < b['open'])
# 최근 15봉 최저가 → 현재가 상승폭
period_low = min(b['low'] for b in recent)
rise_from_low = (current_price - period_low) / period_low * 100
lines.append(f' 최근 15봉: 양봉 {up_count}개 / 음봉 {dn_count}')
lines.append(f' 구간 저점 대비: {rise_from_low:+.2f}% ({period_low:,.0f}{current_price:,.0f})')
if not lines:
return ''
return '[모멘텀 분석]\n' + '\n'.join(lines)
def _build_prompt(
ticker: str,
entry_price: float,
current_price: float,
elapsed_min: float,
current_target: float,
bar_desc: str,
market_context: str = '',
momentum_desc: str = '',
) -> str:
pnl_pct = (current_price - entry_price) / entry_price * 100
target_gap = (current_target - current_price) / current_price * 100
market_section = f'\n{market_context}\n' if market_context else ''
momentum_section = f'\n{momentum_desc}\n' if momentum_desc else ''
return f"""당신은 암호화폐 단기 트레이더입니다.
아래 포지션과 가격 흐름을 분석해 **지정가 매도 목표가**를 판단하세요.
필요하면 제공된 DB tool을 호출해 추가 데이터를 조회하세요.
특히 get_ticker_context로 종목의 24h/7d 가격 변동, 거래량 추이, 최근 뉴스를 확인하세요.
[현재 포지션]
종목 : {ticker}
진입가 : {entry_price:,.0f}
현재가 : {current_price:,.0f}원 ({pnl_pct:+.2f}%)
보유시간: {elapsed_min:.0f}
현재 지정가: {current_target:,.0f}원 (현재가 대비 {target_gap:+.2f}%, 미체결)
{market_section}
[최근 {INPUT_BARS}봉 (20초봉)]
{bar_desc}
{momentum_section}
[운용 정책 참고 — 최종 판단은 당신이 결정]
- 단기 거래량 가속 신호 진입 후 cascade 청산 전략 (지정가 단계적 조정)
- 수익 목표: 진입가 대비 +0.5% ~ +2% 구간
- 손절 기준: 진입가 대비 -2% 이하이면 즉시 시장가 매도를 강력 권고 (action=sell, price=현재가)
- 체결 가능성이 낮으면 현실적인 목표가로 조정 권장
- **모멘텀이 강하면(1분~10분 전 대비 계속 상승 중) 성급하게 팔지 말고 hold하세요**
- **양봉 비율이 높고 구간 저점 대비 상승폭이 크면 추세가 살아있는 것입니다**
- 상승 여력이 있으면 hold 권장
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
매도 지정가를 설정할 경우:
{{"action": "sell", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": false}}
현재 주문을 유지할 경우:
{{"action": "hold", "reason": "유지 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": true/false}}
watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)"""
# ── 공통 LLM 호출 ────────────────────────────────────────────────────────────
def _call_llm(prompt: str, ticker: str) -> Optional[dict]:
"""OpenRouter API를 호출하고 JSON 응답을 반환. 실패 시 None."""
import requests as _req
import re
api_key = os.environ.get('OPENROUTER_API_KEY', '')
if not api_key:
log.debug('[LLM] OPENROUTER_API_KEY 없음')
return None
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4.5')
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
messages = [{'role': 'user', 'content': prompt}]
max_rounds = 5
called_tools: set = set() # 중복 tool 호출 방지
try:
for round_i in range(max_rounds):
body = {
'model': model,
'max_tokens': 512,
'messages': messages,
'response_format': {'type': 'json_object'},
}
# 마지막 라운드 또는 모든 tool 이미 호출 → tool 제거해 강제 텍스트 응답
if round_i < max_rounds - 1 and len(called_tools) < len(_TOOLS):
body['tools'] = _TOOLS
resp = _req.post(
'https://openrouter.ai/api/v1/chat/completions',
headers=headers, json=body, timeout=30,
)
resp.raise_for_status()
result = resp.json()
choice = result['choices'][0]
message = choice['message']
tool_calls = message.get('tool_calls')
if tool_calls:
messages.append(message)
for tc in tool_calls:
fn_name = tc['function']['name']
fn_args = json.loads(tc['function']['arguments'])
call_key = f'{fn_name}:{json.dumps(fn_args, sort_keys=True)}'
if call_key in called_tools:
# 중복 호출 → 캐시된 결과 반환
fn_result = f'(이미 조회한 데이터입니다. 위 결과를 참고하세요.)'
else:
fn_result = _execute_tool(fn_name, fn_args)
called_tools.add(call_key)
log.info(f'[LLM-Tool] {ticker} {fn_name} 호출')
messages.append({
'role': 'tool',
'tool_call_id': tc['id'],
'content': fn_result,
})
continue
raw = (message.get('content') or '').strip()
if not raw:
log.warning(f'[LLM] {ticker} 빈 응답')
return None
# 코드블록 안 JSON 추출
if '```' in raw:
m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL)
if m:
raw = m.group(1)
# 텍스트에 섞인 JSON 객체 추출
try:
return json.loads(raw)
except json.JSONDecodeError as e:
m = re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', raw, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
pass
log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:200]}')
return None
else:
log.warning(f'[LLM] {ticker} tool 루프 초과')
return None
except Exception as e:
log.warning(f'[LLM] {ticker} 오류: {e}')
return None
# ── 매수 판단 ────────────────────────────────────────────────────────────────
def get_entry_price(
ticker: str,
signal: dict,
bar_list: list[dict],
current_price: float,
fng: int = 50,
num_positions: int = 0,
max_positions: int = 3,
) -> Optional[float]:
"""LLM에게 매수 여부 + 지정가를 물어본다.
Args:
ticker: 종목 코드
signal: 시그널 정보 {vol_ratio, prices, ...}
bar_list: 최근 20초봉 리스트
current_price: 현재 가격
fng: 현재 F&G 지수
num_positions: 현재 보유 포지션 수
max_positions: 최대 포지션 수
Returns:
float → 지정가 매수 가격
None → 매수하지 않음
"""
bar_desc = _describe_bars(bar_list, current_price)
mkt_ctx = _get_market_context(ticker)
vol_ratio = signal.get('vol_ratio', 0)
market_section = f'\n{mkt_ctx}\n' if mkt_ctx else ''
prompt = f"""당신은 암호화폐 단기 트레이더입니다.
아래 시그널을 분석해 **매수 여부**를 판단하세요. (매수 가격은 현재가로 자동 설정됩니다)
반드시 제공된 DB tool을 호출해 추가 데이터를 조회하세요:
- get_btc_trend: BTC 추세 확인 (필수 — BTC 급락 시 알트 매수 위험)
- get_ticker_context: 종목 24h/7d 변동, 뉴스 확인
- get_ohlcv: 1분봉으로 현재 추세 확인
[시그널 감지]
종목 : {ticker}
현재가 : {current_price:,.0f}
거래량비: {vol_ratio:.1f}x (61봉 평균 대비)
F&G지수: {fng} ({'공포' if fng <= 40 else '중립' if fng <= 50 else '탐욕'})
포지션 : {num_positions}/{max_positions}
{market_section}
[최근 {INPUT_BARS}봉 (20초봉)]
{bar_desc}
[판단 기준]
- 거래량 급증 시그널이 왔으면 빠르게 올라타는 것이 핵심
- BTC가 급락 중(-2% 이상)이면 자제하되, 횡보/소폭하락은 진입 OK
- 상승 추세가 이미 많이 진행됐으면(+5% 이상) 진입 자제
- **과거 연패/승률은 절대 고려하지 마세요. 현재 시그널만 보고 판단하세요.**
- **get_trade_history를 호출하지 마세요. 과거 거래 이력은 판단에 불필요합니다.**
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
매수할 경우:
{{"action": "buy", "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}
매수하지 않을 경우:
{{"action": "skip", "reason": "매수하지 않는 이유 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}"""
data = _call_llm(prompt, ticker)
if data is None:
log.info(f'[LLM매수] {ticker} → LLM 오류/무응답')
return None
reason = data.get('reason', '')
status = data.get('market_status', '')
confidence = data.get('confidence', '?')
if data.get('action') == 'skip':
log.info(f'[LLM매수] {ticker} → skip | {confidence} | {status} | {reason}')
return data # action=skip
if data.get('action') == 'buy':
data['price'] = float(data['price'])
log.info(f'[LLM매수] {ticker} → buy {data["price"]:,.2f}원 | {confidence} | {status} | {reason}')
return data # action=buy, price=float
log.warning(f'[LLM매수] {ticker} 알 수 없는 action: {data}')
return None
# ── 매도 판단 ────────────────────────────────────────────────────────────────
def get_exit_price(
ticker: str,
pos: dict,
bar_list: list[dict],
current_price: float,
) -> Optional[float]:
"""LLM에게 매도 목표가를 물어본다. (DB tool 사용 가능)
Args:
ticker: 종목 코드 (예: 'KRW-XRP')
pos: positions[ticker] 딕셔너리
bar_list: list(bars[ticker]) — 최신봉이 마지막
current_price: 현재 틱 가격
Returns:
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
None → hold 또는 오류
"""
entry_price = pos['entry_price']
elapsed_min = (datetime.now() - pos['entry_ts']).total_seconds() / 60
current_target = pos.get('sell_price') or entry_price * 1.005
bar_desc = _describe_bars(bar_list, current_price)
mkt_ctx = _get_market_context(ticker)
momentum_desc = _calc_momentum(bar_list, current_price)
prompt = _build_prompt(
ticker, entry_price, current_price,
elapsed_min, current_target, bar_desc,
market_context=mkt_ctx,
momentum_desc=momentum_desc,
)
data = _call_llm(prompt, ticker)
if data is None:
log.info(f'[LLM매도] {ticker} → LLM 오류/무응답 → cascade fallback')
return None
reason = data.get('reason', '')
status = data.get('market_status', '')
confidence = data.get('confidence', '?')
watch = data.get('watch_needed', False)
if data.get('action') == 'hold':
log.info(f'[LLM매도] {ticker} → hold | {confidence} | {status} | watch={watch} | {reason}')
return data # action=hold
data['price'] = float(data['price'])
pnl_from_entry = (data['price'] - entry_price) / entry_price * 100
log.info(
f'[LLM매도] {ticker} 지정가 교체: {current_target:,.2f}{data["price"]:,.2f}'
f'(진입 대비 {pnl_from_entry:+.2f}%) | {confidence} | {status} | watch={watch} | {reason}'
)
return data # action=sell, price=float

View File

@@ -31,16 +31,27 @@ def _send(text: str) -> None:
def notify_buy(
ticker: str, price: float, amount: float, invested_krw: int,
max_budget: int = 0, per_position: int = 0,
fng: int = 0,
) -> None:
budget_line = (
f"운용예산: {max_budget:,}원 (포지션당 {per_position:,}원)\n"
if max_budget else ""
)
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
_send(
f"📈 <b>[매수]</b> {ticker}\n"
f"가격: {price:,.2f}\n"
f"수량: {amount:.8f}\n"
f"투자금: {invested_krw:,.2f}\n"
f"{fng_line}"
f"{budget_line}"
)
@@ -62,13 +73,47 @@ def notify_sell(
)
def notify_signal(ticker: str, signal_price: float, vol_mult: float) -> None:
def notify_signal(ticker: str, signal_price: float, vol_mult: float, fng: int = 0) -> None:
"""거래량 축적 신호 감지 알림."""
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
from .strategy import TREND_AFTER_VOL
target = signal_price * (1 + TREND_AFTER_VOL / 100)
_send(
f"🔍 <b>[축적감지]</b> {ticker}\n"
f"신호가: {signal_price:,.2f}\n"
f"거래량: {vol_mult:.1f}x 급증 + 2h 횡보\n"
f"진입 목표: {signal_price * 1.048:,.2f}원 (+4.8%)"
f"거래량: {vol_mult:.1f}x 급증 + 횡보\n"
f"{fng_line}"
f"진입 목표: {target:,.2f}원 (+{TREND_AFTER_VOL}%)"
)
def notify_watch(
ticker: str, price: float, vol_r: float, vth: float, chg: float, fng: int = 0
) -> None:
"""거래량 근접 관찰 알림 (신호 임계값에 가까워진 종목)."""
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
need = vth - vol_r
_send(
f"👀 <b>[관찰]</b> {ticker}\n"
f"거래량: {vol_r:.1f}x (신호까지 +{need:.1f}x 부족)\n"
f"현재가: {price:,.2f}원 | 횡보: {chg:.1f}%\n"
f"{fng_line}"
)
@@ -98,6 +143,20 @@ def notify_status(
f"| 조건 TREND≥{regime['trend_pct']}% / VOL≥{regime['vol_mult']}x\n"
)
# F&G 지수
from .fng import get_fng, FNG_MIN_ENTRY
fv = get_fng()
fng_label = (
"극탐욕" if fv >= 76 else
"탐욕" if fv >= 56 else
"중립" if fv >= 46 else
"약공포" if fv >= 41 else
"공포" if fv >= 26 else
"극공포"
)
fng_status = "✅진입허용" if fv >= FNG_MIN_ENTRY else "🚫진입차단"
fng_line = f"😨 F&amp;G: {fv} ({fng_label}) {fng_status}\n"
# 1시간 이상 보유 포지션만 필터
long_positions = {
ticker: pos for ticker, pos in positions.items()
@@ -112,7 +171,7 @@ def notify_status(
)
# 포지션 없어도 레짐 정보는 전송
header = f"📊 <b>[{now} 현황]</b>\n{regime_line}{budget_info}"
header = f"📊 <b>[{now} 현황]</b>\n{regime_line}{fng_line}{budget_info}"
if not long_positions:
_send(header + "1h+ 보유 포지션 없음")

178
core/order.py Normal file
View File

@@ -0,0 +1,178 @@
"""Upbit 주문 실행 모듈.
주문 제출, 취소, 체결 조회, 시장가 매도 등
Upbit REST API와 직접 통신하는 로직을 담당한다.
"""
from __future__ import annotations
import logging
import math
import time
from typing import Optional, Tuple
import pyupbit
log = logging.getLogger(__name__)
def round_price(price: float) -> float:
"""Upbit 호가 단위로 내림 처리.
Args:
price: 원본 가격.
Returns:
호가 단위에 맞춰 내림된 가격.
"""
if price >= 2_000_000: unit = 1000
elif price >= 1_000_000: unit = 500
elif price >= 100_000: unit = 100
elif price >= 10_000: unit = 10
elif price >= 1_000: unit = 5
elif price >= 100: unit = 1
elif price >= 10: unit = 0.1
else: unit = 0.01
return math.floor(price / unit) * unit
def submit_limit_buy(
client: pyupbit.Upbit,
ticker: str,
price: float,
qty: float,
sim_mode: bool = False,
) -> Optional[str]:
"""지정가 매수 주문 제출.
Returns:
주문 UUID. 실패 시 None.
"""
price = round_price(price)
if sim_mode:
return f"sim-buy-{ticker}"
try:
order = client.buy_limit_order(ticker, price, qty)
if not order or 'error' in str(order):
log.error(f"지정가 매수 실패 {ticker}: {order}")
return None
return order.get('uuid')
except (ConnectionError, TimeoutError, ValueError) as e:
log.error(f"지정가 매수 오류 {ticker}: {e}")
return None
def submit_limit_sell(
client: pyupbit.Upbit,
ticker: str,
qty: float,
price: float,
sim_mode: bool = False,
) -> Optional[str]:
"""지정가 매도 주문 제출.
Returns:
주문 UUID. 실패 시 None.
"""
price = round_price(price)
if sim_mode:
return f"sim-{ticker}"
try:
order = client.sell_limit_order(ticker, price, qty)
if not order or 'error' in str(order):
log.error(f"지정가 매도 실패 {ticker}: price={price} qty={qty} -> {order}")
return None
return order.get('uuid')
except (ConnectionError, TimeoutError, ValueError) as e:
log.error(f"지정가 매도 오류 {ticker}: {e}")
return None
def cancel_order(
client: pyupbit.Upbit,
uuid: Optional[str],
sim_mode: bool = False,
) -> None:
"""주문 취소. sim_mode이거나 uuid가 없으면 무시."""
if sim_mode or not uuid or uuid.startswith('sim-'):
return
try:
client.cancel_order(uuid)
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"주문 취소 실패 {uuid}: {e}")
def check_order_state(
client: pyupbit.Upbit,
uuid: str,
) -> Tuple[Optional[str], Optional[float]]:
"""주문 상태 조회.
Returns:
(state, avg_price) 튜플. state: 'done'|'wait'|'cancel'|None.
"""
try:
detail = client.get_order(uuid)
if not detail:
return None, None
state = detail.get('state')
avg_price = float(detail.get('avg_price') or 0) or None
return state, avg_price
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"주문 조회 실패 {uuid}: {e}")
return None, None
def _avg_price_from_order(
client: pyupbit.Upbit,
uuid: str,
) -> Optional[float]:
"""체결 내역에서 가중평균 체결가를 계산."""
try:
detail = client.get_order(uuid)
if not detail:
return None
trades = detail.get('trades', [])
if trades:
total_funds = sum(float(t['funds']) for t in trades)
total_vol = sum(float(t['volume']) for t in trades)
return total_funds / total_vol if total_vol > 0 else None
avg = detail.get('avg_price')
return float(avg) if avg else None
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"체결가 조회 실패 {uuid}: {e}")
return None
def sell_market(
client: pyupbit.Upbit,
ticker: str,
qty: float,
sim_mode: bool = False,
) -> Optional[float]:
"""시장가 매도. 체결가를 반환.
Args:
client: Upbit 클라이언트.
ticker: 종목 코드.
qty: 매도 수량.
sim_mode: 시뮬레이션 모드.
Returns:
체결 평균가. 실패 시 None.
"""
if sim_mode:
price = pyupbit.get_current_price(ticker)
log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}")
return price
try:
order = client.sell_market_order(ticker, qty)
if not order or 'error' in str(order):
log.error(f"시장가 매도 실패: {order}")
return None
uuid = order.get('uuid')
time.sleep(1.5)
avg_price = _avg_price_from_order(client, uuid) if uuid else None
return avg_price or pyupbit.get_current_price(ticker)
except (ConnectionError, TimeoutError, ValueError) as e:
log.error(f"시장가 매도 오류 {ticker}: {e}")
return None

264
core/position_manager.py Normal file
View File

@@ -0,0 +1,264 @@
"""포지션 + 미체결 매수 관리 모듈.
포지션 활성화, 트레일링 스탑/손절/타임아웃 체크,
미체결 매수 체결 확인, 예산 계산 등을 담당한다.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Optional
import oracledb
import os
log = logging.getLogger(__name__)
# ── DB 연결 (position_sync) ──────────────────────────────────────────────────
_db_conn: Optional[oracledb.Connection] = None
def _get_db() -> oracledb.Connection:
"""Oracle ADB 연결을 반환. 끊어졌으면 재연결."""
global _db_conn
if _db_conn is None:
kwargs = dict(
user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"],
)
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
_db_conn = oracledb.connect(**kwargs)
return _db_conn
def sync_position(
ticker: str,
state: str,
*,
buy_price: Optional[float] = None,
sell_price: Optional[float] = None,
qty: Optional[float] = None,
order_uuid: Optional[str] = None,
invested_krw: Optional[int] = None,
) -> None:
"""position_sync 테이블에 포지션 상태를 기록/삭제.
Args:
ticker: 종목 코드.
state: 'PENDING_BUY' | 'PENDING_SELL' | 'IDLE'.
"""
try:
conn = _get_db()
cur = conn.cursor()
if state == 'IDLE':
cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker])
else:
now = datetime.now()
cur.execute(
"""MERGE INTO position_sync ps
USING (SELECT :1 AS ticker FROM dual) src
ON (ps.ticker = src.ticker)
WHEN MATCHED THEN UPDATE SET
state = :2, buy_price = :3, sell_price = :4,
qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
WHEN NOT MATCHED THEN INSERT
(ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
[ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now,
ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now],
)
conn.commit()
except oracledb.Error as e:
log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
global _db_conn
_db_conn = None
def calc_remaining_budget(
positions: dict,
pending_buys: dict,
max_budget: int,
) -> float:
"""남은 투자 가능 금액을 계산.
Args:
positions: 현재 포지션 dict.
pending_buys: 미체결 매수 dict.
max_budget: 총 예산.
Returns:
남은 투자 가능 금액 (원).
"""
invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
return max_budget - invested
def check_exit_conditions(
pos: dict,
current_price: float,
*,
trail_pct: float = 0.015,
min_profit_pct: float = 0.005,
stop_loss_pct: float = 0.02,
timeout_secs: float = 14400,
) -> Optional[str]:
"""포지션 청산 조건을 체크.
Args:
pos: 포지션 dict (entry_price, entry_ts, running_peak).
current_price: 현재 가격.
Returns:
청산 사유 ('stoploss' | 'trail' | 'timeout') 또는 None.
"""
entry = pos['entry_price']
profit_pct = (current_price - entry) / entry
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
# 1. 손절
if profit_pct <= -stop_loss_pct:
return 'stoploss'
# 2. 트레일링 스탑
peak = pos['running_peak']
if peak > 0:
drop = (peak - current_price) / peak
if profit_pct >= min_profit_pct and drop >= trail_pct:
return 'trail'
# 3. 타임아웃
if elapsed >= timeout_secs:
return 'timeout'
return None
def restore_from_upbit(
client,
tickers: list[str],
positions: dict,
pending_buys: dict,
*,
cancel_fn,
fp_fn,
tg_fn,
) -> None:
"""Upbit 잔고에서 포지션과 미체결 매수를 복구.
Args:
client: pyupbit.Upbit 인스턴스.
tickers: 감시 종목 리스트.
positions: 포지션 dict (in-place 수정).
pending_buys: 미체결 매수 dict (in-place 수정).
cancel_fn: 주문 취소 함수.
fp_fn: 가격 포맷 함수.
tg_fn: 텔레그램 알림 함수.
"""
_restore_positions(client, tickers, positions, cancel_fn, fp_fn, tg_fn)
_restore_pending_buys(client, tickers, positions, pending_buys, fp_fn)
_sync_restored(positions, pending_buys)
def _restore_positions(
client, tickers: list[str], positions: dict,
cancel_fn, fp_fn, tg_fn,
) -> None:
"""잔고에서 보유 포지션을 복구."""
balances = client.get_balances()
log.info(f"[복구] 잔고 조회: {len(balances)}")
for b in balances:
currency = b.get('currency', '')
bal = float(b.get('balance', 0))
locked = float(b.get('locked', 0))
avg = float(b.get('avg_buy_price', 0))
total = bal + locked
if currency == 'KRW' or total <= 0 or avg <= 0:
continue
ticker = f'KRW-{currency}'
if ticker not in tickers or ticker in positions:
if ticker not in tickers:
log.info(f"[복구] {ticker} TICKERS 외 -> 스킵")
continue
log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp_fn(avg)}")
# 기존 미체결 매도 주문 취소
try:
old_orders = client.get_order(ticker, state='wait') or []
for o in (old_orders if isinstance(old_orders, list) else []):
if o.get('side') == 'ask':
cancel_fn(o.get('uuid'))
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
time.sleep(0.5)
actual_bal = client.get_balance(currency)
if not actual_bal or actual_bal <= 0:
actual_bal = total
log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용")
positions[ticker] = {
'entry_price': avg,
'entry_ts': datetime.now(),
'running_peak': avg,
'qty': actual_bal,
}
log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp_fn(avg)}원 트레일링")
tg_fn(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp_fn(avg)}원 수량: {actual_bal:.6f}")
def _restore_pending_buys(
client, tickers: list[str], positions: dict,
pending_buys: dict, fp_fn,
) -> None:
"""미체결 매수 주문을 복구."""
for ticker in tickers:
if ticker in positions or ticker in pending_buys:
continue
try:
orders = client.get_order(ticker, state='wait') or []
for o in (orders if isinstance(orders, list) else []):
if o.get('side') == 'bid':
price = float(o.get('price', 0))
rem = float(o.get('remaining_volume', 0))
if price > 0 and rem > 0:
pending_buys[ticker] = {
'uuid': o.get('uuid'),
'price': price,
'qty': rem,
'ts': datetime.now(),
'vol_ratio': 0,
}
log.info(f"[복구] {ticker} 미체결 매수 복구: {fp_fn(price)}원 수량:{rem:.6f}")
break
except (ConnectionError, TimeoutError, ValueError):
log.warning(f"[복구] {ticker} 미체결 매수 조회 실패")
def _sync_restored(positions: dict, pending_buys: dict) -> None:
"""복구된 포지션을 position_sync DB에 반영."""
restored = len(positions) + len(pending_buys)
if restored:
log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
for ticker, pos in positions.items():
sync_position(
ticker, 'PENDING_SELL',
buy_price=pos['entry_price'],
qty=pos['qty'],
invested_krw=int(pos['qty'] * pos['entry_price']),
)
for ticker, pb in pending_buys.items():
sync_position(
ticker, 'PENDING_BUY',
buy_price=pb['price'],
qty=pb['qty'],
order_uuid=pb.get('uuid'),
invested_krw=int(pb['qty'] * pb['price']),
)

139
core/signal.py Normal file
View File

@@ -0,0 +1,139 @@
"""시그널 감지 + 지표 계산 모듈.
20초봉 데이터에서 양봉 + 거래량 + 사전 필터를 적용하여
매수 시그널 후보를 반환한다.
"""
from __future__ import annotations
import logging
from typing import Optional
log = logging.getLogger(__name__)
def calc_vr(bar_list: list[dict], idx: int, lookback: int = 61) -> float:
"""거래량비(Volume Ratio) 계산. 상위 10% 트리밍.
Args:
bar_list: 봉 리스트.
idx: 현재 봉 인덱스.
lookback: 기준 봉 수.
Returns:
현재 봉 거래량 / trimmed mean 비율.
"""
start = max(0, idx - lookback)
end = max(0, idx - 2)
baseline = sorted(bar_list[i]['volume'] for i in range(start, end))
if not baseline:
return 0.0
trim = max(1, len(baseline) // 10)
trimmed = baseline[:len(baseline) - trim]
if not trimmed:
return 0.0
avg = sum(trimmed) / len(trimmed)
return bar_list[idx]['volume'] / avg if avg > 0 else 0.0
def calc_atr(bar_list: list[dict], lookback: int = 28) -> float:
"""ATR(Average True Range) 비율 계산.
Args:
bar_list: 봉 리스트.
lookback: ATR 계산 봉 수.
Returns:
ATR / 직전 종가 비율 (0~1 범위).
"""
if len(bar_list) < lookback + 2:
return 0.0
trs = []
for i in range(-lookback - 1, -1):
b = bar_list[i]
bp = bar_list[i - 1]
tr = max(
b['high'] - b['low'],
abs(b['high'] - bp['close']),
abs(b['low'] - bp['close']),
)
trs.append(tr)
prev_close = bar_list[-2]['close']
return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0
def detect_signal(
ticker: str,
bar_list: list[dict],
*,
vol_min: float = 5.0,
vol_lookback: int = 61,
vol_krw_min: float = 5_000_000,
spread_min: float = 0.3,
) -> Optional[dict]:
"""양봉 + 거래량 + 사전 필터 3종을 적용하여 시그널 후보를 반환.
Args:
ticker: 종목 코드.
bar_list: 봉 리스트 (list로 변환된 deque).
vol_min: 최소 거래량 배수.
vol_lookback: 거래량 평균 기준 봉 수.
vol_krw_min: 최소 거래대금 (원).
spread_min: 횡보 필터 최소 변동폭 (%).
Returns:
시그널 dict 또는 None.
"""
n = len(bar_list)
if n < vol_lookback + 5:
return None
b = bar_list[-1]
if b['close'] <= b['open']:
return None
vr = calc_vr(bar_list, n - 1, lookback=vol_lookback)
if vr < vol_min:
return None
bar_krw = b['close'] * b['volume']
if bar_krw < vol_krw_min:
return None
# 1) 횡보 필터: 최근 15봉 변동폭 < 0.3%
recent = bar_list[-15:]
period_high = max(x['high'] for x in recent)
period_low = min(x['low'] for x in recent)
if period_low > 0:
spread_pct = (period_high - period_low) / period_low * 100
if spread_pct < spread_min:
log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% -> 스킵")
return None
# 2) 고점 필터: 30분 구간 90%+ 위치 & 변동 1%+
long_bars = bar_list[-90:]
long_high = max(x['high'] for x in long_bars)
long_low = min(x['low'] for x in long_bars)
if long_high > long_low:
pos_in_range = (b['close'] - long_low) / (long_high - long_low)
move_pct = (long_high - long_low) / long_low * 100
if pos_in_range > 0.9 and move_pct > 1.0:
log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {move_pct:.1f}% -> 스킵")
return None
# 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉
prev_greens = 0
for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1):
if bar_list[k]['close'] > bar_list[k]['open']:
prev_greens += 1
else:
break
if prev_greens < 2:
log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 -> 스킵")
return None
return {
'ticker': ticker,
'price': b['close'],
'vol_ratio': vr,
'bar_list': bar_list,
}

View File

@@ -1,232 +0,0 @@
"""Volume Lead 전략: 거래량 축적(급증+횡보) 감지 후 +TREND_AFTER_VOL% 상승 시 선진입.
흐름:
1. 직전 40분봉 거래량 > 로컬 5h(7봉) 평균 × VOL_MULT AND
2h 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적)
→ 신호가(signal_price) + 거래량비율(vol_ratio) 기록
2. signal_price 대비 +임계값% 이상 상승 시 진입
임계값은 vol_ratio 강도에 따라 자동 조정 (강한 신호 → 낮은 임계값 → 조기 진입)
- vol_ratio ≥ 5.0x → +1.0%
- vol_ratio ≥ 3.5x → +2.0%
- vol_ratio ≥ 2.5x → +3.0%
- 기본 → +TREND_AFTER_VOL%
3. SIGNAL_TIMEOUT_H 내 임계값 미달 또는 신호가 이하 하락 시 신호 초기화
캔들: minute10 데이터를 40분봉으로 리샘플링하여 사용
"""
from __future__ import annotations
import logging
import os
import time
import pyupbit
from .market import get_current_price
from .market_regime import get_regime
from .notify import notify_signal
from .price_db import get_price_n_hours_ago
logger = logging.getLogger(__name__)
# 축적 감지 파라미터
PRICE_QUIET_PCT = float(os.getenv("PRICE_QUIET_PCT", "2.0")) # 2h 횡보 기준 (%)
TREND_AFTER_VOL = float(os.getenv("TREND_AFTER_VOL", "5.0")) # 진입 임계값 (신호가 대비 %)
SIGNAL_TIMEOUT_H = float(os.getenv("SIGNAL_TIMEOUT_H", "8.0")) # 신호 유효 시간 (h)
# 거래량 파라미터
LOCAL_VOL_CANDLES = 7 # 5h를 40분봉으로 환산 (int(5 * 60/40) = 7)
VOLUME_MULTIPLIER = float(os.getenv("VOLUME_MULTIPLIER", "2.0"))
# 40분봉 리샘플링 파라미터
_CANDLE_MIN = 40
_FETCH_10M = (LOCAL_VOL_CANDLES + 3) * (_CANDLE_MIN // 10) # 40 개의 10분봉
# 신호 강도별 진입 임계값 단계 (vol_ratio 최소값, 진입 임계값%)
# 강한 신호일수록 낮은 임계값으로 조기 진입
_ENTRY_TIERS: list[tuple[float, float]] = [
(5.0, 1.0), # 매우 강한 신호 (≥5x) → +1.0% 즉시 진입
(3.5, 2.0), # 강한 신호 (≥3.5x) → +2.0% 조기 진입
(2.5, 3.0), # 중간 신호 (≥2.5x) → +3.0% 반조기 진입
]
# 위 조건 미충족 시 TREND_AFTER_VOL 사용
# 속도(velocity) 기반 조기 진입 파라미터
# 신호 후 가격이 빠르게 상승 중이면 거리 임계값 도달 전에 선진입
VELOCITY_THRESHOLD = float(os.getenv("VELOCITY_THRESHOLD", "0.10")) # %/분 (0.10 = 6%/h)
VELOCITY_MIN_MOVE = float(os.getenv("VELOCITY_MIN_MOVE", "0.5")) # 최소 이동 % (잡음 제거)
VELOCITY_MIN_AGE_M = float(os.getenv("VELOCITY_MIN_AGE_M", "5.0")) # 최소 경과 시간(분)
def _calc_entry_threshold(vol_ratio: float) -> float:
"""거래량 비율에 따른 진입 임계값 반환. 강한 신호일수록 낮은 값."""
for min_ratio, threshold in _ENTRY_TIERS:
if vol_ratio >= min_ratio:
return threshold
return TREND_AFTER_VOL
def _resample_40m(df):
"""minute10 DataFrame → 40분봉으로 리샘플링."""
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# 축적 신호 상태: ticker → {"price": float, "time": float(unix), "vol_ratio": float}
_accum_signals: dict[str, dict] = {}
def get_active_signals() -> dict[str, dict]:
"""현재 활성화된 신호 딕셔너리 반환 (fast-poll 루프용).
Returns:
{ticker: {"price": float, "time": float, "vol_ratio": float}}
"""
return dict(_accum_signals)
def _check_vol_spike(ticker: str, vol_mult: float) -> bool:
"""직전 완성 40분봉 거래량이 로컬 5h(7봉) 평균의 vol_mult 배 이상인지 확인."""
try:
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M)
except Exception:
return False
if df10 is None or len(df10) < _CANDLE_MIN // 10 * 2:
return False
df = _resample_40m(df10)
if len(df) < LOCAL_VOL_CANDLES + 1:
return False
recent_vol = df["volume"].iloc[-2] # 직전 완성된 40분봉
local_avg = df["volume"].iloc[-(LOCAL_VOL_CANDLES + 1):-2].mean() # 이전 7봉(≈5h) 평균
if local_avg <= 0:
return False
ratio = recent_vol / local_avg
result = ratio >= vol_mult
if result:
logger.debug(
f"[거래량↑] {ticker} 40m={recent_vol:.0f} / 5h평균={local_avg:.0f} ({ratio:.2f}x ≥ {vol_mult}x)"
)
else:
logger.debug(
f"[거래량✗] {ticker} {ratio:.2f}x < {vol_mult}x"
)
return result
def should_buy(ticker: str) -> bool:
"""Volume Lead 전략.
1단계: 거래량 급증 + 2h 횡보 → 신호가 기록
2단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입
"""
regime = get_regime()
vol_mult = regime["vol_mult"]
current = get_current_price(ticker)
if not current:
return False
now = time.time()
# ── 기존 신호 유효성 확인 ────────────────────────────────
sig = _accum_signals.get(ticker)
if sig is not None:
age_h = (now - sig["time"]) / 3600
if age_h > SIGNAL_TIMEOUT_H:
del _accum_signals[ticker]
sig = None
logger.debug(f"[축적타임아웃] {ticker} {age_h:.1f}h 경과 → 신호 초기화")
# ── 신호 없음: 축적 조건 체크 ────────────────────────────
if sig is None:
# 2h 가격 횡보 확인 (DB 가격 활용)
price_2h = get_price_n_hours_ago(ticker, 2)
if price_2h is None:
return False
quiet = abs(current - price_2h) / price_2h * 100 < PRICE_QUIET_PCT
if not quiet:
logger.debug(
f"[횡보✗] {ticker} 2h변동={(current - price_2h) / price_2h * 100:+.1f}% "
f"(기준={PRICE_QUIET_PCT}%)"
)
return False
# 거래량 급증 확인
if not _check_vol_spike(ticker, vol_mult):
return False
# 거래량 비율 계산 후 신호 기록
ratio = 0.0
try:
df10 = pyupbit.get_ohlcv(ticker, interval="minute10", count=_FETCH_10M)
df_h = _resample_40m(df10) if df10 is not None else None
if df_h is not None and len(df_h) >= LOCAL_VOL_CANDLES + 1:
recent_vol = df_h["volume"].iloc[-2]
local_avg = df_h["volume"].iloc[-(LOCAL_VOL_CANDLES + 1):-2].mean()
ratio = recent_vol / local_avg if local_avg > 0 else 0.0
except Exception:
pass
entry_thr = _calc_entry_threshold(ratio)
_accum_signals[ticker] = {"price": current, "time": now, "vol_ratio": ratio}
logger.info(
f"[축적감지] {ticker} 거래량 급증 + 2h 횡보 → 신호가={current:,.2f}"
f"(거래량 {ratio:.2f}x → 진입임계={entry_thr:.1f}%)"
)
notify_signal(ticker, current, ratio)
return False # 신호 첫 발생 시는 진입 안 함
# ── 신호 있음: 상승 확인 → 진입 ─────────────────────────
signal_price = sig["price"]
vol_ratio = sig.get("vol_ratio", 0.0)
entry_thr = _calc_entry_threshold(vol_ratio)
move_pct = (current - signal_price) / signal_price * 100
age_min = (now - sig["time"]) / 60
if current < signal_price:
# 신호가 이하 하락 → 축적 실패
del _accum_signals[ticker]
logger.debug(
f"[축적실패] {ticker} 신호가={signal_price:,.2f} → 현재={current:,.2f} (하락) → 초기화"
)
return False
# ── 거리 기반 진입 ─────────────────────────────────────
if move_pct >= entry_thr:
del _accum_signals[ticker]
logger.info(
f"[축적진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}"
f"(+{move_pct:.1f}% ≥ {entry_thr:.1f}% | 거래량={vol_ratio:.2f}x)"
)
return True
# ── 속도 기반 조기 진입 ────────────────────────────────
# 신호 후 N분 이상 경과 + 최소 이동 + 분당 상승률 기준 충족 시 선진입
if age_min >= VELOCITY_MIN_AGE_M and move_pct >= VELOCITY_MIN_MOVE:
velocity = move_pct / age_min # %/분
if velocity >= VELOCITY_THRESHOLD:
del _accum_signals[ticker]
logger.info(
f"[속도진입] {ticker} 신호가={signal_price:,.2f}원 → 현재={current:,.2f}"
f"(+{move_pct:.1f}% | {velocity:.3f}%/분 ≥ {VELOCITY_THRESHOLD}%/분 | "
f"경과={age_min:.1f}분 | 거래량={vol_ratio:.2f}x)"
)
return True
logger.debug(
f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} "
f"(+{move_pct:.1f}% / 목표={entry_thr:.1f}% | "
f"속도={move_pct/age_min:.3f}%/분 | 경과={age_min:.1f}분)"
if age_min > 0 else
f"[축적대기] {ticker} 신호가={signal_price:,.2f} 현재={current:,.2f} "
f"(+{move_pct:.1f}% / 목표={entry_thr:.1f}%)"
)
return False

View File

@@ -0,0 +1,226 @@
"""종목 컨텍스트 수집 데몬.
1시간마다 각 종목의 평판 정보를 수집해 Oracle ticker_context 테이블에 저장.
LLM 어드바이저가 매도 판단 시 참조.
수집 항목:
- price_stats: 24h/7d 가격 변동률, 거래량 추이
- news: SearXNG 웹 검색 뉴스 요약
실행:
.venv/bin/python3 daemons/context_collector.py
로그:
/tmp/context_collector.log
"""
import sys, os, time, logging, json, requests
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
]
COLLECT_INTERVAL = 3600 # 1시간
SEARXNG_URL = 'https://searxng.cloud-handson.com/search'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/context_collector.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
# 코인명 매핑 (검색 키워드용)
COIN_NAMES = {
'KRW-BTC': 'Bitcoin', 'KRW-ETH': 'Ethereum', 'KRW-XRP': 'Ripple XRP',
'KRW-SOL': 'Solana', 'KRW-DOGE': 'Dogecoin', 'KRW-ADA': 'Cardano',
'KRW-SUI': 'SUI', 'KRW-NEAR': 'NEAR Protocol', 'KRW-KAVA': 'KAVA',
'KRW-SXP': 'Solar SXP', 'KRW-AKT': 'Akash', 'KRW-SONIC': 'Sonic',
'KRW-IP': 'Story IP', 'KRW-ORBS': 'ORBS', 'KRW-VIRTUAL': 'Virtuals Protocol',
'KRW-BARD': 'BARD', 'KRW-XPL': 'XPL', 'KRW-KITE': 'KITE',
'KRW-ENSO': 'ENSO', 'KRW-0G': '0G', 'KRW-MANTRA': 'MANTRA OM',
'KRW-EDGE': 'EDGE', 'KRW-CFG': 'Centrifuge', 'KRW-ARDR': 'Ardor',
'KRW-SIGN': 'SIGN', 'KRW-AZTEC': 'AZTEC', 'KRW-ATH': 'Aethir',
'KRW-HOLO': 'Holochain', 'KRW-BREV': 'BREV', 'KRW-SHIB': 'Shiba Inu',
}
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
def upsert_context(conn, ticker: str, context_type: str, content: str):
cur = conn.cursor()
cur.execute(
"""MERGE INTO ticker_context tc
USING (SELECT :t AS ticker, :ct AS context_type FROM dual) s
ON (tc.ticker = s.ticker AND tc.context_type = s.context_type)
WHEN MATCHED THEN
UPDATE SET content = :c, updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (ticker, context_type, content)
VALUES (:t, :ct, :c)""",
{'t': ticker, 'ct': context_type, 'c': content},
)
def collect_price_stats(conn):
"""각 종목의 24h/7d 가격 변동률 + 거래량 추이를 수집."""
log.info("[price_stats] 수집 시작")
count = 0
for ticker in TICKERS:
try:
# 일봉 7개 (7일치)
df_day = pyupbit.get_ohlcv(ticker, interval='day', count=8)
if df_day is None or len(df_day) < 2:
continue
today = df_day.iloc[-1]
yesterday = df_day.iloc[-2]
# 24h 변동률
chg_24h = (today['close'] - yesterday['close']) / yesterday['close'] * 100
# 7d 변동률
if len(df_day) >= 8:
week_ago = df_day.iloc[-8]
chg_7d = (today['close'] - week_ago['close']) / week_ago['close'] * 100
else:
chg_7d = None
# 거래량 추이 (최근 3일 vs 이전 3일)
if len(df_day) >= 7:
recent_vol = df_day['volume'].iloc[-3:].mean()
prev_vol = df_day['volume'].iloc[-6:-3].mean()
vol_trend = recent_vol / prev_vol if prev_vol > 0 else 1.0
else:
vol_trend = None
# 1시간봉 최근 24개 (24시간)
df_h1 = pyupbit.get_ohlcv(ticker, interval='minute60', count=24)
h1_high = h1_low = None
if df_h1 is not None and not df_h1.empty:
h1_high = float(df_h1['high'].max())
h1_low = float(df_h1['low'].min())
stats = {
'price': float(today['close']),
'chg_24h_pct': round(chg_24h, 2),
'chg_7d_pct': round(chg_7d, 2) if chg_7d is not None else None,
'vol_today': float(today['volume']),
'vol_trend_3d': round(vol_trend, 2) if vol_trend is not None else None,
'h24_high': h1_high,
'h24_low': h1_low,
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
}
upsert_context(conn, ticker, 'price_stats', json.dumps(stats, ensure_ascii=False))
count += 1
time.sleep(0.2)
except Exception as e:
log.warning(f"[price_stats] {ticker} 오류: {e}")
conn.commit()
log.info(f"[price_stats] 완료 {count}/{len(TICKERS)} 종목")
def search_news(coin_name: str, max_results: int = 5) -> list[dict]:
"""SearXNG로 코인 뉴스 검색."""
try:
resp = requests.get(
SEARXNG_URL,
params={
'q': f'{coin_name} crypto news',
'format': 'json',
'categories': 'news',
'language': 'ko-KR',
'time_range': 'week',
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
results = []
for r in data.get('results', [])[:max_results]:
results.append({
'title': r.get('title', ''),
'url': r.get('url', ''),
'content': r.get('content', '')[:200],
'date': r.get('publishedDate', ''),
})
return results
except Exception as e:
log.warning(f"[news] {coin_name} 검색 오류: {e}")
return []
def collect_news(conn):
"""각 종목의 최근 뉴스를 수집."""
log.info("[news] 수집 시작")
count = 0
for ticker in TICKERS:
coin_name = COIN_NAMES.get(ticker, ticker.split('-')[1])
try:
articles = search_news(coin_name)
if not articles:
continue
news_data = {
'coin': coin_name,
'articles': articles,
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
}
upsert_context(conn, ticker, 'news', json.dumps(news_data, ensure_ascii=False))
count += 1
time.sleep(1) # 검색 API rate limit 배려
except Exception as e:
log.warning(f"[news] {ticker} 오류: {e}")
conn.commit()
log.info(f"[news] 완료 {count}/{len(TICKERS)} 종목")
def main():
log.info("=== context_collector 시작 (1시간 간격) ===")
log.info(f"대상: {len(TICKERS)}개 종목")
conn = get_conn()
while True:
t0 = time.time()
try:
collect_price_stats(conn)
collect_news(conn)
except oracledb.DatabaseError as e:
log.error(f"DB 오류: {e} — 재연결")
try:
conn.close()
except:
pass
conn = get_conn()
except Exception as e:
log.error(f"오류: {e}")
elapsed = time.time() - t0
log.info(f"[완료] {elapsed:.0f}초 소요, {COLLECT_INTERVAL}초 후 다음 수집")
time.sleep(max(60, COLLECT_INTERVAL - elapsed))
if __name__ == '__main__':
main()

184
daemons/state_sync.py Normal file
View File

@@ -0,0 +1,184 @@
"""10초 주기로 Upbit 잔고/미체결 주문을 조회하여 position_sync 테이블 동기화.
상태:
PENDING_BUY — 매수 주문 제출됨 (미체결)
HOLDING — 보유 중 (매도 주문 없음)
PENDING_SELL — 매도 주문 제출됨 (미체결)
IDLE — 아무 것도 없음 (행 삭제)
tick_trader는 이 테이블을 읽어서 positions/pending_buys를 복구한다.
실행:
.venv/bin/python3 daemons/state_sync.py
로그:
/tmp/state_sync.log
"""
import sys, os, time, logging
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
]
INTERVAL = 10 # 초
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/state_sync.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
def sync_once(conn):
"""Upbit 실제 상태를 조회하여 position_sync 테이블 갱신."""
cur = conn.cursor()
now = datetime.now()
# 1. 잔고 조회 → 보유 종목 파악
balances = upbit.get_balances() or []
held = {} # ticker → {qty, avg_price}
for b in balances:
currency = b.get('currency', '')
if currency == 'KRW':
continue
ticker = f'KRW-{currency}'
if ticker not in TICKERS:
continue
bal = float(b.get('balance', 0))
locked = float(b.get('locked', 0))
total = bal + locked
avg = float(b.get('avg_buy_price', 0))
if total > 0 and avg > 0:
held[ticker] = {'qty': total, 'avg_price': avg, 'invested': int(total * avg)}
# 2. 미체결 주문 조회 → 매수/매도 대기 파악
pending_buys = {} # ticker → {uuid, price, qty}
pending_sells = {} # ticker → {uuid, price, qty}
for ticker in TICKERS:
try:
orders = upbit.get_order(ticker, state='wait') or []
if not isinstance(orders, list):
continue
for o in orders:
side = o.get('side')
uuid = o.get('uuid')
price = float(o.get('price', 0))
rem = float(o.get('remaining_volume', 0))
if price <= 0 or rem <= 0:
continue
if side == 'bid':
pending_buys[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
elif side == 'ask':
pending_sells[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
except Exception:
pass
# 3. 상태 결정 및 DB 반영
active_tickers = set(held.keys()) | set(pending_buys.keys()) | set(pending_sells.keys())
for ticker in active_tickers:
if ticker in pending_buys and ticker not in held:
state = 'PENDING_BUY'
pb = pending_buys[ticker]
buy_price = pb['price']
sell_price = None
qty = pb['qty']
order_uuid = pb['uuid']
invested = int(qty * buy_price)
elif ticker in held and ticker in pending_sells:
state = 'PENDING_SELL'
h = held[ticker]
ps = pending_sells[ticker]
buy_price = h['avg_price']
sell_price = ps['price']
qty = h['qty']
order_uuid = ps['uuid']
invested = h['invested']
elif ticker in held:
state = 'HOLDING'
h = held[ticker]
buy_price = h['avg_price']
sell_price = None
qty = h['qty']
order_uuid = None
invested = h['invested']
else:
continue
cur.execute(
"""MERGE INTO position_sync ps
USING (SELECT :1 AS ticker FROM dual) src
ON (ps.ticker = src.ticker)
WHEN MATCHED THEN UPDATE SET
state = :2, buy_price = :3, sell_price = :4,
qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
WHEN NOT MATCHED THEN INSERT
(ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
[ticker, state, buy_price, sell_price, qty, order_uuid, invested, now,
ticker, state, buy_price, sell_price, qty, order_uuid, invested, now]
)
# 4. 이제 없는 종목은 삭제
if active_tickers:
placeholders = ','.join(f"'{t}'" for t in active_tickers)
cur.execute(f"DELETE FROM position_sync WHERE ticker NOT IN ({placeholders})")
else:
cur.execute("DELETE FROM position_sync")
conn.commit()
if active_tickers:
summary = ', '.join(f"{t.split('-')[1]}={cur.execute('SELECT state FROM position_sync WHERE ticker=:1',[t]).fetchone()[0]}" for t in sorted(active_tickers))
log.info(f"[동기화] {summary}")
def main():
log.info(f"=== state_sync 시작 (주기 {INTERVAL}초) ===")
conn = get_conn()
fail_count = 0
while True:
try:
sync_once(conn)
fail_count = 0
except Exception as e:
fail_count += 1
log.error(f"[동기화 오류] {e}", exc_info=(fail_count <= 3))
try:
conn.close()
except Exception:
pass
try:
conn = get_conn()
except Exception:
pass
time.sleep(INTERVAL)
if __name__ == '__main__':
main()

118
daemons/tick_collector.py Normal file
View File

@@ -0,0 +1,118 @@
"""30초마다 전 종목 현재가를 Oracle price_tick 테이블에 적재.
+ 60초마다 backtest_ohlcv 1분봉 최신 데이터 갱신.
"""
import sys, os, time, logging
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
import pyupbit
import oracledb
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
]
INTERVAL = 30 # 초
OHLCV_INTERVAL = 60 # 1분봉 갱신 주기 (초)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/tick_collector.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
def get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
return oracledb.connect(**kwargs)
def collect(conn):
prices = pyupbit.get_current_price(TICKERS)
if not prices:
log.warning("현재가 조회 실패")
return
ts = datetime.now().replace(microsecond=0)
rows = [(t, ts, p) for t, p in prices.items() if p]
cur = conn.cursor()
cur.executemany(
"INSERT INTO price_tick (ticker, ts, price) VALUES (:1, :2, :3)",
rows
)
conn.commit()
log.info(f"적재 {len(rows)}건 ts={ts}")
def refresh_ohlcv(conn):
"""backtest_ohlcv 1분봉을 최근 5개씩 갱신 (중복 무시)."""
total = 0
for ticker in TICKERS:
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=5)
if df is None or df.empty:
continue
rows = [
(ticker, 'minute1', ts.to_pydatetime(),
float(r['open']), float(r['high']), float(r['low']),
float(r['close']), float(r['volume']))
for ts, r in df.iterrows()
]
cur = conn.cursor()
cur.executemany(
"INSERT INTO backtest_ohlcv "
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,:2,:3,:4,:5,:6,:7,:8)",
rows, batcherrors=True,
)
inserted = len(rows) - len(cur.getbatcherrors())
total += inserted
time.sleep(0.15)
except Exception as e:
log.warning(f"[ohlcv] {ticker} 오류: {e}")
conn.commit()
if total > 0:
log.info(f"[ohlcv] 1분봉 갱신 {total}")
def main():
log.info("=== tick_collector 시작 (30초 간격 + 1분봉 갱신) ===")
conn = get_conn()
last_ohlcv = 0
while True:
t0 = time.time()
try:
collect(conn)
# 1분봉 갱신 (OHLCV_INTERVAL마다)
if t0 - last_ohlcv >= OHLCV_INTERVAL:
refresh_ohlcv(conn)
last_ohlcv = t0
except oracledb.DatabaseError as e:
log.error(f"DB 오류: {e} — 재연결")
try: conn.close()
except: pass
conn = get_conn()
except Exception as e:
log.error(f"오류: {e}")
elapsed = time.time() - t0
time.sleep(max(1.0, INTERVAL - elapsed))
if __name__ == '__main__':
main()

489
daemons/tick_trader.py Normal file
View File

@@ -0,0 +1,489 @@
"""WebSocket 기반 20초봉 트레이더 (Controller).
구조:
WebSocket -> trade tick 수신 -> 20초봉 집계
-> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수
-> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
실행:
.venv/bin/python3 daemons/tick_trader.py
로그:
/tmp/tick_trader.log
"""
import sys
import os
import time
import logging
import threading
import requests
from datetime import datetime
from collections import deque, defaultdict
from typing import Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
from core.llm_advisor import get_entry_price
from core.signal import detect_signal, calc_vr
from core.order import (
round_price, submit_limit_buy, cancel_order,
check_order_state, sell_market,
)
from core.position_manager import (
sync_position, calc_remaining_budget,
check_exit_conditions, restore_from_upbit,
)
import pyupbit
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
TICKERS = [
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
]
BAR_SEC = 20
VOL_LOOKBACK = 61
VOL_MIN = 5.0
VOL_KRW_MIN = 5_000_000
BUY_TIMEOUT = 180
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
PER_POS = MAX_BUDGET // MAX_POS
FEE = 0.0005
TRAIL_PCT = 0.015
MIN_PROFIT_PCT = 0.005
STOP_LOSS_PCT = 0.02
TIMEOUT_SECS = 14400
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
upbit_client = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
# ── 로깅 ──────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[logging.FileHandler('/tmp/tick_trader.log')],
)
log = logging.getLogger(__name__)
# ── 상태 ──────────────────────────────────────────────────────────────────────
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
cur_bar: dict = {}
bar_lock = threading.Lock()
positions: dict = {}
pending_buys: dict = {}
# ── 유틸리티 ──────────────────────────────────────────────────────────────────
def fp(price: float) -> str:
"""가격 포맷. 100원 미만은 소수점 표시."""
if price >= 100:
return f"{price:,.0f}"
elif price >= 10:
return f"{price:,.1f}"
else:
return f"{price:,.2f}"
def tg(msg: str) -> None:
"""텔레그램 알림 전송."""
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
requests.post(
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
timeout=5,
)
except (ConnectionError, TimeoutError) as e:
log.warning(f'Telegram 전송 실패: {e}')
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
"""새 봉 초기화."""
return {'open': price, 'high': price, 'low': price,
'close': price, 'volume': volume, 'ts': ts}
def on_tick(ticker: str, price: float, volume: float) -> None:
"""WebSocket tick -> 현재 봉에 반영."""
with bar_lock:
if ticker not in cur_bar:
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
return
b = cur_bar[ticker]
b['high'] = max(b['high'], price)
b['low'] = min(b['low'], price)
b['close'] = price
b['volume'] += volume
def finalize_bars() -> None:
"""BAR_SEC마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리."""
while True:
time.sleep(BAR_SEC)
now = datetime.now()
signals = []
with bar_lock:
for ticker in list(cur_bar.keys()):
b = cur_bar[ticker]
if b['volume'] == 0:
continue
bars[ticker].append(b)
cur_bar[ticker] = _new_bar(b['close'], 0, now)
if ticker in positions or ticker in pending_buys:
continue
if len(positions) + len(pending_buys) >= MAX_POS:
continue
sig = detect_signal(
ticker, list(bars[ticker]),
vol_min=VOL_MIN, vol_lookback=VOL_LOOKBACK,
vol_krw_min=VOL_KRW_MIN,
)
if sig:
signals.append(sig)
for sig in signals:
process_signal(sig)
check_pending_buys()
check_filled_positions()
# ── 매수 처리 ─────────────────────────────────────────────────────────────────
def process_signal(sig: dict) -> None:
"""시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출."""
ticker = sig['ticker']
cur_price = sig['price']
vol_ratio = sig['vol_ratio']
if ticker in positions or ticker in pending_buys:
return
if len(positions) + len(pending_buys) >= MAX_POS:
return
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x -> LLM 판단 요청")
llm_result = get_entry_price(
ticker=ticker, signal=sig, bar_list=sig['bar_list'],
current_price=cur_price,
num_positions=len(positions), max_positions=MAX_POS,
)
if llm_result is None or llm_result.get('action') != 'buy':
_handle_skip(ticker, cur_price, vol_ratio, llm_result)
return
if ticker in positions or ticker in pending_buys:
return
if len(positions) + len(pending_buys) >= MAX_POS:
log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵")
return
_submit_buy(ticker, cur_price, vol_ratio, llm_result)
def _handle_skip(
ticker: str, price: float, vol_ratio: float,
llm_result: Optional[dict],
) -> None:
"""LLM skip 결과 로깅 + 텔레그램 알림."""
reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답'
status = llm_result.get('market_status', '') if llm_result else ''
log.info(f"[매수/LLM] {ticker} -> 스킵 | {reason}")
tg(
f"⏭️ <b>매수 스킵</b> {ticker}\n"
f"현재가: {fp(price)}원 볼륨: {vol_ratio:.1f}x\n"
f"시장: {status}\n"
f"사유: {reason}"
)
def _submit_buy(
ticker: str, cur_price: float, vol_ratio: float,
llm_result: dict,
) -> None:
"""LLM 승인 후 예산 체크 -> 지정가 매수 제출."""
buy_price = round_price(cur_price)
confidence = llm_result.get('confidence', '?')
reason = llm_result.get('reason', '')
status = llm_result.get('market_status', '')
remaining = calc_remaining_budget(positions, pending_buys, MAX_BUDGET)
invest_amt = min(PER_POS, remaining)
if invest_amt < 5000:
log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}원 -> 스킵")
return
qty = invest_amt * (1 - FEE) / buy_price
log.info(f"[매수/LLM] {ticker} -> 승인 {fp(buy_price)}원 (현재가 매수)")
uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE)
if uuid is None:
return
pending_buys[ticker] = {
'uuid': uuid, 'price': buy_price, 'qty': qty,
'ts': datetime.now(), 'vol_ratio': vol_ratio,
}
invested = int(qty * buy_price)
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
order_uuid=uuid, invested_krw=invested)
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
tg(
f"📥 <b>지정가 매수</b> {ticker}\n"
f"지정가: {fp(buy_price)}원 투자: {invested:,}\n"
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
f"확신: {confidence} 시장: {status}\n"
f"LLM: {reason}\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
# ── 체결 확인 ─────────────────────────────────────────────────────────────────
def check_pending_buys() -> None:
"""미체결 매수 주문 체결 확인. 타임아웃/한도 초과 시 취소."""
for ticker in list(pending_buys.keys()):
pb = pending_buys[ticker]
elapsed = (datetime.now() - pb['ts']).total_seconds()
if len(positions) >= MAX_POS:
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 -> 취소")
sync_position(ticker, 'IDLE')
del pending_buys[ticker]
continue
if SIM_MODE:
bar_list = list(bars.get(ticker, []))
if bar_list and bar_list[-1]['low'] <= pb['price']:
log.info(f"[SIM 매수체결] {ticker} {fp(pb['price'])}")
_activate_position(ticker, pb['price'], pb['qty'], pb['vol_ratio'])
del pending_buys[ticker]
continue
else:
state, avg_price = check_order_state(upbit_client, pb['uuid'])
if state == 'done':
actual_price = avg_price or pb['price']
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
del pending_buys[ticker]
continue
if elapsed >= BUY_TIMEOUT:
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 -> 취소")
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}{elapsed:.0f}초 미체결")
sync_position(ticker, 'IDLE')
del pending_buys[ticker]
def _activate_position(
ticker: str, entry_price: float, qty: float, vol_ratio: float,
) -> None:
"""매수 체결 후 포지션 등록."""
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
}
invested = int(qty * entry_price)
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
qty=qty, invested_krw=invested)
log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 트레일 -{TRAIL_PCT*100:.1f}%")
tg(
f"🟢 <b>매수 체결</b> {ticker}\n"
f"체결가: {fp(entry_price)}원 투자: {invested:,}\n"
f"트레일: 고점 대비 -{TRAIL_PCT*100:.1f}% / 손절: -{STOP_LOSS_PCT*100:.1f}%\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
"""포지션 청산 기록 + 텔레그램 알림."""
pos = positions[ticker]
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
held = int((datetime.now() - pos['entry_ts']).total_seconds())
reason_tag = {
'trail': '트레일스탑', 'timeout': '타임아웃',
'stoploss': '손절', 'llm': 'LLM 매도',
}.get(tag, tag)
icon = "" if pnl > 0 else "🔴"
invested = int(pos['qty'] * pos['entry_price'])
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}{held}초 보유")
tg(
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
f"투자: {invested:,}\n"
f"진입: {fp(pos['entry_price'])}원 -> 청산: {fp(exit_price)}\n"
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
sync_position(ticker, 'IDLE')
del positions[ticker]
def _try_exit(ticker: str, price: float) -> None:
"""청산 조건 체크 후 시장가 매도 실행."""
pos = positions[ticker]
pos['running_peak'] = max(pos['running_peak'], price)
tag = check_exit_conditions(
pos, price,
trail_pct=TRAIL_PCT, min_profit_pct=MIN_PROFIT_PCT,
stop_loss_pct=STOP_LOSS_PCT, timeout_secs=TIMEOUT_SECS,
)
if tag is None:
return
exit_price = sell_market(upbit_client, ticker, pos['qty'], sim_mode=SIM_MODE) or price
if tag == 'trail':
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
drop = (pos['running_peak'] - price) / pos['running_peak'] * 100
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) -> {fp(price)}원 drop {drop:.2f}%")
elif tag == 'stoploss':
profit = (price - pos['entry_price']) / pos['entry_price'] * 100
log.info(f"[손절] {ticker} {fp(price)}원 (진입 대비 {profit:+.2f}%)")
elif tag == 'timeout':
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
_record_exit(ticker, exit_price, tag)
def check_filled_positions() -> None:
"""20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃."""
for ticker in list(positions.keys()):
if ticker not in positions:
continue
bar_list = list(bars.get(ticker, []))
if not bar_list:
continue
_try_exit(ticker, bar_list[-1]['close'])
def update_positions(current_prices: dict) -> None:
"""tick마다 실시간 peak 갱신 + 손절/트레일 체크."""
for ticker in list(positions.keys()):
if ticker not in current_prices:
continue
_try_exit(ticker, current_prices[ticker])
# ── 초기화 ────────────────────────────────────────────────────────────────────
def preload_bars() -> None:
"""REST API 1분봉으로 bars[] 사전 적재."""
need_min = (VOL_LOOKBACK + 10) // 3 + 1
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
loaded = 0
for ticker in TICKERS:
for attempt in range(3):
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=need_min)
if df is None or df.empty:
time.sleep(0.5)
continue
with bar_lock:
for _, row in df.iterrows():
o, h, l, c = float(row['open']), float(row['high']), float(row['low']), float(row['close'])
v3 = float(row['volume']) / 3
ts = row.name.to_pydatetime()
for _ in range(3):
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
loaded += 1
break
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
time.sleep(1)
time.sleep(0.2)
log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커")
def restore_positions() -> None:
"""Upbit 잔고에서 포지션 + 미체결 매수 복구."""
if SIM_MODE:
return
try:
restore_from_upbit(
upbit_client, TICKERS, positions, pending_buys,
cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE),
fp_fn=fp, tg_fn=tg,
)
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
# ── 메인 ──────────────────────────────────────────────────────────────────────
def main() -> None:
"""tick_trader 메인 루프."""
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
log.info(f"=== tick_trader 시작 ({mode}) ===")
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}")
log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h")
tg(
f"🚀 <b>tick_trader 시작</b> ({mode})\n"
f"예산: {MAX_BUDGET:,}원 | 최대 {MAX_POS}포지션 | 종목당 {PER_POS:,}\n"
f"VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M | 연속양봉 >= 2\n"
f"트레일: 고점 -{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%)\n"
f"손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_SECS//3600}h"
)
preload_bars()
restore_positions()
t = threading.Thread(target=finalize_bars, daemon=True)
t.start()
ws = pyupbit.WebSocketManager("trade", TICKERS)
log.info("WebSocket 연결됨")
last_pos_log = time.time()
while True:
try:
data = ws.get()
if data is None:
continue
ticker = data.get('code')
price = data.get('trade_price')
volume = data.get('trade_volume')
if not ticker or price is None or volume is None:
continue
on_tick(ticker, float(price), float(volume))
if positions:
update_positions({ticker: float(price)})
if time.time() - last_pos_log > 60:
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
if positions:
pos_lines = ' '.join(
f"{t.split('-')[1]} {p['entry_price']:,.0f}->{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)"
for t, p in positions.items()
)
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
else:
log.info(f"[상태] 포지션 없음 ({warmed}/{len(TICKERS)} 준비완료)")
last_pos_log = time.time()
except Exception as e:
log.error(f"루프 오류: {e}")
time.sleep(1)
if __name__ == '__main__':
main()

59
ecosystem.config.js Normal file
View File

@@ -0,0 +1,59 @@
module.exports = {
apps: [
{
name: "upbit-trader",
script: "main.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/pm2.log",
error_file: "logs/pm2-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "tick-collector",
script: "daemons/tick_collector.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/tick-collector.log",
error_file: "logs/tick-collector-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "tick-trader",
script: "daemons/tick_trader.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/tick-trader.log",
error_file: "logs/tick-trader-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "state-sync",
script: "daemons/state_sync.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/state-sync.log",
error_file: "logs/state-sync-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
{
name: "context-collector",
script: "daemons/context_collector.py",
interpreter: ".venv/bin/python3",
cwd: "/Users/joungmin/workspaces/upbit-trader",
out_file: "logs/context-collector.log",
error_file: "logs/context-collector-error.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
},
],
};

View File

@@ -5,4 +5,6 @@ requires-python = ">=3.9"
dependencies = [
"pyupbit>=0.3.0",
"python-dotenv>=1.0",
"anthropic>=0.40",
"oracledb>=2.0",
]