Compare commits
1 Commits
976c53ed66
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0c4508fa |
100
README.md
Normal file
100
README.md
Normal 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`
|
||||||
360
archive/tests/check_recent_signals.py
Normal file
360
archive/tests/check_recent_signals.py
Normal 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()
|
||||||
323
archive/tests/compare_tp_vs_trail.py
Normal file
323
archive/tests/compare_tp_vs_trail.py
Normal 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()
|
||||||
131
archive/tests/fetch_1y_minute1.py
Normal file
131
archive/tests/fetch_1y_minute1.py
Normal 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}분 소요)")
|
||||||
314
archive/tests/sim_3bar.py
Normal file
314
archive/tests/sim_3bar.py
Normal 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()
|
||||||
281
archive/tests/sim_cascade.py
Normal file
281
archive/tests/sim_cascade.py
Normal 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()
|
||||||
265
archive/tests/sim_limit_exit.py
Normal file
265
archive/tests/sim_limit_exit.py
Normal 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()
|
||||||
253
archive/tests/sim_peak_exit.py
Normal file
253
archive/tests/sim_peak_exit.py
Normal 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()
|
||||||
363
archive/tests/sim_recent_db.py
Normal file
363
archive/tests/sim_recent_db.py
Normal 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)
|
||||||
244
archive/tests/sim_tp_sl.py
Normal file
244
archive/tests/sim_tp_sl.py
Normal 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()
|
||||||
310
archive/tests/sweep_1min.py
Normal file
310
archive/tests/sweep_1min.py
Normal 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
357
archive/tests/sweep_nbar.py
Normal 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()
|
||||||
355
archive/tests/sweep_volaccel.py
Normal file
355
archive/tests/sweep_volaccel.py
Normal 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()
|
||||||
187
archive/tests/test_llm_advisor.py
Normal file
187
archive/tests/test_llm_advisor.py
Normal 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)
|
||||||
216
archive/tests/test_tick_trader.py
Normal file
216
archive/tests/test_tick_trader.py
Normal 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()
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
"""OpenRouter LLM 기반 매매 어드바이저.
|
"""OpenRouter LLM 기반 매수 어드바이저.
|
||||||
|
|
||||||
매수: 시그널 감지 후 LLM이 매수 여부 + 지정가 결정
|
시그널 감지 후 LLM이 매수 여부를 판단한다.
|
||||||
매도: 1분 주기로 LLM이 매도 목표가 결정 (cascade fallback)
|
매도는 트레일링 스탑으로 대체되어 LLM을 사용하지 않는다.
|
||||||
|
|
||||||
LLM에게 제공하는 DB Tool (OpenAI function calling):
|
DB Tool (OpenAI function calling):
|
||||||
- get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱)
|
- get_price_ticks: Oracle price_tick (최근 N분 가격 틱)
|
||||||
- get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용)
|
- get_ohlcv: Oracle backtest_ohlcv 1분봉
|
||||||
- get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스)
|
- get_ticker_context: 종목 평판 (가격 변동, 뉴스)
|
||||||
- get_trade_history(ticker): 최근 거래 이력 (승패, 손익)
|
- get_btc_trend: BTC 최근 동향
|
||||||
- get_btc_trend(): BTC 최근 동향 (알트 매수 판단용)
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
178
core/order.py
Normal file
178
core/order.py
Normal 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
264
core/position_manager.py
Normal 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
139
core/signal.py
Normal 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,
|
||||||
|
}
|
||||||
184
daemons/state_sync.py
Normal file
184
daemons/state_sync.py
Normal 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()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""WebSocket 기반 20초봉 트레이더.
|
"""WebSocket 기반 20초봉 트레이더 (Controller).
|
||||||
|
|
||||||
구조:
|
구조:
|
||||||
WebSocket -> trade tick 수신 -> 20초봉 집계
|
WebSocket -> trade tick 수신 -> 20초봉 집계
|
||||||
@@ -10,8 +10,13 @@
|
|||||||
로그:
|
로그:
|
||||||
/tmp/tick_trader.log
|
/tmp/tick_trader.log
|
||||||
"""
|
"""
|
||||||
import sys, os, time, logging, threading, requests, math
|
import sys
|
||||||
from datetime import datetime, timedelta
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
from collections import deque, defaultdict
|
from collections import deque, defaultdict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -19,10 +24,18 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
from core.llm_advisor import get_exit_price, get_entry_price
|
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
|
import pyupbit
|
||||||
import oracledb
|
|
||||||
|
|
||||||
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
|
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
|
||||||
TICKERS = [
|
TICKERS = [
|
||||||
@@ -30,23 +43,21 @@ TICKERS = [
|
|||||||
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
|
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
|
||||||
]
|
]
|
||||||
|
|
||||||
BAR_SEC = 20 # 봉 주기 (초)
|
BAR_SEC = 20
|
||||||
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
VOL_LOOKBACK = 61
|
||||||
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
VOL_MIN = 5.0
|
||||||
VOL_MIN = 5.0 # 거래량 배수 임계값
|
VOL_KRW_MIN = 5_000_000
|
||||||
VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터
|
BUY_TIMEOUT = 180
|
||||||
BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초)
|
|
||||||
|
|
||||||
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
|
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
|
||||||
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
|
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
|
||||||
PER_POS = MAX_BUDGET // MAX_POS
|
PER_POS = MAX_BUDGET // MAX_POS
|
||||||
FEE = 0.0005
|
FEE = 0.0005
|
||||||
|
|
||||||
# 트레일링 스탑 청산
|
TRAIL_PCT = 0.015
|
||||||
TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도
|
MIN_PROFIT_PCT = 0.005
|
||||||
MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5%
|
STOP_LOSS_PCT = 0.02
|
||||||
STOP_LOSS_PCT = 0.02 # -2% 손절
|
TIMEOUT_SECS = 14400
|
||||||
TIMEOUT_SECS = 14400 # 4시간
|
|
||||||
|
|
||||||
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
||||||
|
|
||||||
@@ -59,58 +70,21 @@ TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
|
|||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s %(levelname)s %(message)s',
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
handlers=[
|
handlers=[logging.FileHandler('/tmp/tick_trader.log')],
|
||||||
logging.FileHandler('/tmp/tick_trader.log'),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── 상태 ──────────────────────────────────────────────────────────────────────
|
||||||
# ── position_sync DB ─────────────────────────────────────────────────────────
|
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
|
||||||
_db_conn = None
|
cur_bar: dict = {}
|
||||||
|
bar_lock = threading.Lock()
|
||||||
def _get_db():
|
positions: dict = {}
|
||||||
global _db_conn
|
pending_buys: dict = {}
|
||||||
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=None, sell_price=None,
|
|
||||||
qty=None, order_uuid=None, invested_krw=None):
|
|
||||||
"""position_sync 테이블에 상태 기록. state_sync 데몬과 tick_trader 양쪽에서 갱신."""
|
|
||||||
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 Exception as e:
|
|
||||||
log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
|
|
||||||
global _db_conn
|
|
||||||
_db_conn = None
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── 유틸리티 ──────────────────────────────────────────────────────────────────
|
||||||
def fp(price: float) -> str:
|
def fp(price: float) -> str:
|
||||||
"""가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시."""
|
"""가격 포맷. 100원 미만은 소수점 표시."""
|
||||||
if price >= 100:
|
if price >= 100:
|
||||||
return f"{price:,.0f}"
|
return f"{price:,.0f}"
|
||||||
elif price >= 10:
|
elif price >= 10:
|
||||||
@@ -120,6 +94,7 @@ def fp(price: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def tg(msg: str) -> None:
|
def tg(msg: str) -> None:
|
||||||
|
"""텔레그램 알림 전송."""
|
||||||
if not TG_TOKEN or not TG_CHAT_ID:
|
if not TG_TOKEN or not TG_CHAT_ID:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -128,35 +103,32 @@ def tg(msg: str) -> None:
|
|||||||
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
|
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except (ConnectionError, TimeoutError) as e:
|
||||||
log.warning(f'Telegram 전송 실패: {e}')
|
log.warning(f'Telegram 전송 실패: {e}')
|
||||||
|
|
||||||
|
|
||||||
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
|
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
|
||||||
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
|
|
||||||
cur_bar: dict = {}
|
|
||||||
bar_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
|
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
|
||||||
|
"""새 봉 초기화."""
|
||||||
return {'open': price, 'high': price, 'low': price,
|
return {'open': price, 'high': price, 'low': price,
|
||||||
'close': price, 'volume': volume, 'ts': ts}
|
'close': price, 'volume': volume, 'ts': ts}
|
||||||
|
|
||||||
|
|
||||||
def on_tick(ticker: str, price: float, volume: float) -> None:
|
def on_tick(ticker: str, price: float, volume: float) -> None:
|
||||||
|
"""WebSocket tick -> 현재 봉에 반영."""
|
||||||
with bar_lock:
|
with bar_lock:
|
||||||
if ticker not in cur_bar:
|
if ticker not in cur_bar:
|
||||||
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
|
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
|
||||||
return
|
return
|
||||||
b = cur_bar[ticker]
|
b = cur_bar[ticker]
|
||||||
b['high'] = max(b['high'], price)
|
b['high'] = max(b['high'], price)
|
||||||
b['low'] = min(b['low'], price)
|
b['low'] = min(b['low'], price)
|
||||||
b['close'] = price
|
b['close'] = price
|
||||||
b['volume'] += volume
|
b['volume'] += volume
|
||||||
|
|
||||||
|
|
||||||
def finalize_bars() -> None:
|
def finalize_bars() -> None:
|
||||||
"""BAR_SEC마다 봉 확정 → 시그널 감지 → LLM 매수 판단 → 체결 확인."""
|
"""BAR_SEC마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리."""
|
||||||
while True:
|
while True:
|
||||||
time.sleep(BAR_SEC)
|
time.sleep(BAR_SEC)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -168,296 +140,103 @@ def finalize_bars() -> None:
|
|||||||
continue
|
continue
|
||||||
bars[ticker].append(b)
|
bars[ticker].append(b)
|
||||||
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
||||||
sig = detect_signal(ticker)
|
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:
|
if sig:
|
||||||
signals.append(sig)
|
signals.append(sig)
|
||||||
# bar_lock 밖에서 LLM 호출 + 체결 확인
|
|
||||||
for sig in signals:
|
for sig in signals:
|
||||||
process_signal(sig)
|
process_signal(sig)
|
||||||
check_pending_buys()
|
check_pending_buys()
|
||||||
check_filled_positions()
|
check_filled_positions()
|
||||||
|
|
||||||
|
|
||||||
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
# ── 매수 처리 ─────────────────────────────────────────────────────────────────
|
||||||
def calc_vr(bar_list: list, idx: int) -> float:
|
|
||||||
start = max(0, idx - VOL_LOOKBACK)
|
|
||||||
end = max(0, idx - 2)
|
|
||||||
baseline = sorted(bar_list[i]['volume'] for i in range(start, end))
|
|
||||||
if not baseline:
|
|
||||||
return 0.0
|
|
||||||
# 상위 10% 스파이크 제거 (trimmed mean) — 볼륨 평균 오염 방지
|
|
||||||
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) -> float:
|
|
||||||
if len(bar_list) < ATR_LOOKBACK + 2:
|
|
||||||
return 0.0
|
|
||||||
trs = []
|
|
||||||
for i in range(-ATR_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
|
|
||||||
|
|
||||||
|
|
||||||
# ── 시그널 감지 (완화 — LLM이 최종 판단) ────────────────────────────────────
|
|
||||||
def detect_signal(ticker: str) -> Optional[dict]:
|
|
||||||
"""양봉 + 거래량 VOL_MIN 이상이면 시그널 후보 반환. bar_lock 안에서 호출."""
|
|
||||||
bar_list = list(bars[ticker])
|
|
||||||
n = len(bar_list)
|
|
||||||
|
|
||||||
if n < VOL_LOOKBACK + 5:
|
|
||||||
return None
|
|
||||||
if ticker in positions or ticker in pending_buys:
|
|
||||||
return None
|
|
||||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
b = bar_list[-1]
|
|
||||||
if b['close'] <= b['open']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
vr = calc_vr(bar_list, n - 1)
|
|
||||||
if vr < VOL_MIN:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 20초봉 거래대금 하드캡: 소량 조작 방지
|
|
||||||
bar_krw = b['close'] * b['volume']
|
|
||||||
if bar_krw < VOL_KRW_MIN:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── LLM 호출 절감: skip 패턴 사전 필터 ──
|
|
||||||
|
|
||||||
# 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 < 0.3:
|
|
||||||
log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% → 스킵")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 2) 상승 추세 이미 진행 (현재가가 구간 고점 대비 90% 이상 도달)
|
|
||||||
long_bars = bar_list[-90:] # ~30분
|
|
||||||
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)
|
|
||||||
if pos_in_range > 0.9 and (long_high - long_low) / long_low * 100 > 1.0:
|
|
||||||
log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {(long_high-long_low)/long_low*100:.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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 주문 ──────────────────────────────────────────────────────────────────────
|
|
||||||
def _round_price(price: float) -> float:
|
|
||||||
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
|
|
||||||
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_sell(ticker: str, qty: float, price: float) -> Optional[str]:
|
|
||||||
"""지정가 매도 주문. Returns UUID."""
|
|
||||||
price = _round_price(price)
|
|
||||||
log.debug(f"[매도주문] {ticker} price={price} qty={qty}")
|
|
||||||
if SIM_MODE:
|
|
||||||
return f"sim-{ticker}"
|
|
||||||
try:
|
|
||||||
order = upbit_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 Exception as e:
|
|
||||||
log.error(f"지정가 매도 오류 {ticker}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_order_safe(uuid: Optional[str]) -> None:
|
|
||||||
if SIM_MODE or not uuid or uuid.startswith('sim-'):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
upbit_client.cancel_order(uuid)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"주문 취소 실패 {uuid}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_order_state(uuid: str) -> tuple:
|
|
||||||
"""Returns (state, avg_price). state: 'done'|'wait'|'cancel'|None"""
|
|
||||||
try:
|
|
||||||
detail = upbit_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 Exception as e:
|
|
||||||
log.warning(f"주문 조회 실패 {uuid}: {e}")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _avg_price_from_order(uuid: str) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
detail = upbit_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 Exception as e:
|
|
||||||
log.warning(f"체결가 조회 실패 {uuid}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def do_sell_market(ticker: str, qty: float) -> Optional[float]:
|
|
||||||
"""Trail Stop / Timeout용 시장가 매도."""
|
|
||||||
if SIM_MODE:
|
|
||||||
price = pyupbit.get_current_price(ticker)
|
|
||||||
log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}")
|
|
||||||
return price
|
|
||||||
try:
|
|
||||||
order = upbit_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(uuid) if uuid else None
|
|
||||||
return avg_price or pyupbit.get_current_price(ticker)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"시장가 매도 오류 {ticker}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── 지정가 매수 (LLM 판단) ───────────────────────────────────────────────────
|
|
||||||
pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio}
|
|
||||||
|
|
||||||
|
|
||||||
def process_signal(sig: dict) -> None:
|
def process_signal(sig: dict) -> None:
|
||||||
"""시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출."""
|
"""시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출."""
|
||||||
ticker = sig['ticker']
|
ticker = sig['ticker']
|
||||||
bar_list = sig['bar_list']
|
|
||||||
cur_price = sig['price']
|
cur_price = sig['price']
|
||||||
vol_ratio = sig['vol_ratio']
|
vol_ratio = sig['vol_ratio']
|
||||||
|
|
||||||
# 이미 보유/매수대기 중인 종목 중복 방지
|
|
||||||
if ticker in positions or ticker in pending_buys:
|
if ticker in positions or ticker in pending_buys:
|
||||||
return
|
return
|
||||||
|
|
||||||
# LLM 호출 전 포지션 수 재확인 (동시 진행 방지)
|
|
||||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x → LLM 판단 요청")
|
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x -> LLM 판단 요청")
|
||||||
|
|
||||||
llm_result = get_entry_price(
|
llm_result = get_entry_price(
|
||||||
ticker=ticker,
|
ticker=ticker, signal=sig, bar_list=sig['bar_list'],
|
||||||
signal=sig,
|
|
||||||
bar_list=bar_list,
|
|
||||||
current_price=cur_price,
|
current_price=cur_price,
|
||||||
num_positions=len(positions),
|
num_positions=len(positions), max_positions=MAX_POS,
|
||||||
max_positions=MAX_POS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if llm_result is None or llm_result.get('action') != 'buy':
|
if llm_result is None or llm_result.get('action') != 'buy':
|
||||||
reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답'
|
_handle_skip(ticker, cur_price, vol_ratio, llm_result)
|
||||||
status = llm_result.get('market_status', '') if llm_result else ''
|
|
||||||
log.info(f"[매수/LLM] {ticker} → 스킵 | {reason}")
|
|
||||||
tg(
|
|
||||||
f"⏭️ <b>매수 스킵</b> {ticker}\n"
|
|
||||||
f"현재가: {fp(cur_price)}원 볼륨: {vol_ratio:.1f}x\n"
|
|
||||||
f"시장: {status}\n"
|
|
||||||
f"사유: {reason}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# LLM 호출 후 포지션 수/중복 재확인
|
|
||||||
if ticker in positions or ticker in pending_buys:
|
if ticker in positions or ticker in pending_buys:
|
||||||
return
|
return
|
||||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵")
|
log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵")
|
||||||
return
|
return
|
||||||
|
|
||||||
buy_price = _round_price(cur_price) # 현재가로 즉시 매수
|
_submit_buy(ticker, cur_price, vol_ratio, llm_result)
|
||||||
confidence = llm_result.get('confidence', '?')
|
|
||||||
reason = llm_result.get('reason', '')
|
|
||||||
status = llm_result.get('market_status', '')
|
|
||||||
|
|
||||||
# 예산 체크: MAX_BUDGET - 현재 투자금 합계
|
|
||||||
invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
|
def _handle_skip(
|
||||||
invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
|
ticker: str, price: float, vol_ratio: float,
|
||||||
remaining = MAX_BUDGET - invested
|
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)
|
invest_amt = min(PER_POS, remaining)
|
||||||
if invest_amt < 5000:
|
if invest_amt < 5000:
|
||||||
log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f}원 → 스킵")
|
log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}원 -> 스킵")
|
||||||
return
|
return
|
||||||
|
|
||||||
qty = invest_amt * (1 - FEE) / buy_price
|
qty = invest_amt * (1 - FEE) / buy_price
|
||||||
log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 매수)")
|
log.info(f"[매수/LLM] {ticker} -> 승인 {fp(buy_price)}원 (현재가 매수)")
|
||||||
|
|
||||||
if SIM_MODE:
|
uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE)
|
||||||
uuid = f"sim-buy-{ticker}"
|
if uuid is None:
|
||||||
else:
|
return
|
||||||
try:
|
|
||||||
order = upbit_client.buy_limit_order(ticker, buy_price, qty)
|
|
||||||
if not order or 'error' in str(order):
|
|
||||||
log.error(f"지정가 매수 제출 실패: {order}")
|
|
||||||
return
|
|
||||||
uuid = order.get('uuid')
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"지정가 매수 오류 {ticker}: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
pending_buys[ticker] = {
|
pending_buys[ticker] = {
|
||||||
'uuid': uuid,
|
'uuid': uuid, 'price': buy_price, 'qty': qty,
|
||||||
'price': buy_price,
|
'ts': datetime.now(), 'vol_ratio': vol_ratio,
|
||||||
'qty': qty,
|
|
||||||
'ts': datetime.now(),
|
|
||||||
'vol_ratio': vol_ratio,
|
|
||||||
}
|
}
|
||||||
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
|
|
||||||
order_uuid=uuid, invested_krw=int(qty * buy_price))
|
|
||||||
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
|
|
||||||
invested = int(qty * buy_price)
|
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(
|
tg(
|
||||||
f"📥 <b>지정가 매수</b> {ticker}\n"
|
f"📥 <b>지정가 매수</b> {ticker}\n"
|
||||||
f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n"
|
f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n"
|
||||||
@@ -468,16 +247,16 @@ def process_signal(sig: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 체결 확인 ─────────────────────────────────────────────────────────────────
|
||||||
def check_pending_buys() -> None:
|
def check_pending_buys() -> None:
|
||||||
"""지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃/한도초과 시 취소."""
|
"""미체결 매수 주문 체결 확인. 타임아웃/한도 초과 시 취소."""
|
||||||
for ticker in list(pending_buys.keys()):
|
for ticker in list(pending_buys.keys()):
|
||||||
pb = pending_buys[ticker]
|
pb = pending_buys[ticker]
|
||||||
elapsed = (datetime.now() - pb['ts']).total_seconds()
|
elapsed = (datetime.now() - pb['ts']).total_seconds()
|
||||||
|
|
||||||
# 포지션 한도 초과 시 미체결 주문 즉시 취소
|
|
||||||
if len(positions) >= MAX_POS:
|
if len(positions) >= MAX_POS:
|
||||||
cancel_order_safe(pb['uuid'])
|
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
|
||||||
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소")
|
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 -> 취소")
|
||||||
sync_position(ticker, 'IDLE')
|
sync_position(ticker, 'IDLE')
|
||||||
del pending_buys[ticker]
|
del pending_buys[ticker]
|
||||||
continue
|
continue
|
||||||
@@ -490,30 +269,31 @@ def check_pending_buys() -> None:
|
|||||||
del pending_buys[ticker]
|
del pending_buys[ticker]
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
state, avg_price = check_order_state(pb['uuid'])
|
state, avg_price = check_order_state(upbit_client, pb['uuid'])
|
||||||
if state == 'done':
|
if state == 'done':
|
||||||
actual_price = avg_price or pb['price']
|
actual_price = avg_price or pb['price']
|
||||||
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
|
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
|
||||||
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
|
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
|
||||||
del pending_buys[ticker]
|
del pending_buys[ticker]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 타임아웃
|
|
||||||
if elapsed >= BUY_TIMEOUT:
|
if elapsed >= BUY_TIMEOUT:
|
||||||
cancel_order_safe(pb['uuid'])
|
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
|
||||||
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 → 취소")
|
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 -> 취소")
|
||||||
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}원 {elapsed:.0f}초 미체결")
|
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}원 {elapsed:.0f}초 미체결")
|
||||||
sync_position(ticker, 'IDLE')
|
sync_position(ticker, 'IDLE')
|
||||||
del pending_buys[ticker]
|
del pending_buys[ticker]
|
||||||
|
|
||||||
|
|
||||||
def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: float) -> None:
|
def _activate_position(
|
||||||
"""매수 체결 후 포지션 등록 (트레일링 스탑)."""
|
ticker: str, entry_price: float, qty: float, vol_ratio: float,
|
||||||
|
) -> None:
|
||||||
|
"""매수 체결 후 포지션 등록."""
|
||||||
positions[ticker] = {
|
positions[ticker] = {
|
||||||
'entry_price': entry_price,
|
'entry_price': entry_price,
|
||||||
'entry_ts': datetime.now(),
|
'entry_ts': datetime.now(),
|
||||||
'running_peak': entry_price,
|
'running_peak': entry_price,
|
||||||
'qty': qty,
|
'qty': qty,
|
||||||
}
|
}
|
||||||
invested = int(qty * entry_price)
|
invested = int(qty * entry_price)
|
||||||
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
|
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
|
||||||
@@ -528,15 +308,11 @@ def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: f
|
|||||||
|
|
||||||
|
|
||||||
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
||||||
positions: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
||||||
"""체결 완료 후 포지션 종료 처리."""
|
"""포지션 청산 기록 + 텔레그램 알림."""
|
||||||
pos = positions[ticker]
|
pos = positions[ticker]
|
||||||
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
|
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
|
||||||
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
|
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
|
||||||
held = int((datetime.now() - pos['entry_ts']).total_seconds())
|
held = int((datetime.now() - pos['entry_ts']).total_seconds())
|
||||||
|
|
||||||
reason_tag = {
|
reason_tag = {
|
||||||
@@ -545,12 +321,12 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
|||||||
}.get(tag, tag)
|
}.get(tag, tag)
|
||||||
|
|
||||||
icon = "✅" if pnl > 0 else "🔴"
|
icon = "✅" if pnl > 0 else "🔴"
|
||||||
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
|
||||||
invested = int(pos['qty'] * pos['entry_price'])
|
invested = int(pos['qty'] * pos['entry_price'])
|
||||||
|
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
||||||
tg(
|
tg(
|
||||||
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
|
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
|
||||||
f"투자: {invested:,}원\n"
|
f"투자: {invested:,}원\n"
|
||||||
f"진입: {fp(pos['entry_price'])}원 → 청산: {fp(exit_price)}원\n"
|
f"진입: {fp(pos['entry_price'])}원 -> 청산: {fp(exit_price)}원\n"
|
||||||
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
|
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
|
||||||
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
)
|
)
|
||||||
@@ -558,76 +334,56 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
|||||||
del positions[ticker]
|
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:
|
def check_filled_positions() -> None:
|
||||||
"""20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃."""
|
"""20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃."""
|
||||||
for ticker in list(positions.keys()):
|
for ticker in list(positions.keys()):
|
||||||
if ticker not in positions:
|
if ticker not in positions:
|
||||||
continue
|
continue
|
||||||
pos = positions[ticker]
|
|
||||||
bar_list = list(bars.get(ticker, []))
|
bar_list = list(bars.get(ticker, []))
|
||||||
if not bar_list:
|
if not bar_list:
|
||||||
continue
|
continue
|
||||||
|
_try_exit(ticker, bar_list[-1]['close'])
|
||||||
current_price = bar_list[-1]['close']
|
|
||||||
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
|
||||||
|
|
||||||
# peak 갱신
|
|
||||||
pos['running_peak'] = max(pos['running_peak'], current_price)
|
|
||||||
|
|
||||||
profit_pct = (current_price - pos['entry_price']) / pos['entry_price']
|
|
||||||
drop_from_peak = (pos['running_peak'] - current_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
|
|
||||||
|
|
||||||
# 1. 손절: -2%
|
|
||||||
if profit_pct <= -STOP_LOSS_PCT:
|
|
||||||
exit_price = do_sell_market(ticker, pos['qty']) or current_price
|
|
||||||
log.info(f"[손절] {ticker} {fp(current_price)}원 (진입 대비 {profit_pct*100:+.2f}%)")
|
|
||||||
_record_exit(ticker, exit_price, 'stoploss')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 2. 트레일링 스탑: 수익 +0.5% 이상 AND 고점 대비 -1.5%
|
|
||||||
if profit_pct >= MIN_PROFIT_PCT and drop_from_peak >= TRAIL_PCT:
|
|
||||||
exit_price = do_sell_market(ticker, pos['qty']) or current_price
|
|
||||||
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
|
|
||||||
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) → {fp(current_price)}원 drop {drop_from_peak*100:.2f}%")
|
|
||||||
_record_exit(ticker, exit_price, 'trail')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 3. 타임아웃: 4시간
|
|
||||||
if elapsed >= TIMEOUT_SECS:
|
|
||||||
exit_price = do_sell_market(ticker, pos['qty']) or current_price
|
|
||||||
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
|
|
||||||
_record_exit(ticker, exit_price, 'timeout')
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def update_positions(current_prices: dict) -> None:
|
def update_positions(current_prices: dict) -> None:
|
||||||
"""tick마다 peak 갱신 (실시간 트레일링)."""
|
"""tick마다 실시간 peak 갱신 + 손절/트레일 체크."""
|
||||||
for ticker in list(positions.keys()):
|
for ticker in list(positions.keys()):
|
||||||
if ticker not in current_prices:
|
if ticker not in current_prices:
|
||||||
continue
|
continue
|
||||||
pos = positions[ticker]
|
_try_exit(ticker, current_prices[ticker])
|
||||||
price = current_prices[ticker]
|
|
||||||
|
|
||||||
pos['running_peak'] = max(pos['running_peak'], price)
|
|
||||||
|
|
||||||
# 실시간 손절 체크
|
|
||||||
profit_pct = (price - pos['entry_price']) / pos['entry_price']
|
|
||||||
if profit_pct <= -STOP_LOSS_PCT:
|
|
||||||
exit_price = do_sell_market(ticker, pos['qty']) or price
|
|
||||||
log.info(f"[손절/실시간] {ticker} {fp(price)}원 ({profit_pct*100:+.2f}%)")
|
|
||||||
_record_exit(ticker, exit_price, 'stoploss')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 실시간 트레일링 체크
|
|
||||||
drop = (pos['running_peak'] - price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
|
|
||||||
if profit_pct >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
|
|
||||||
exit_price = do_sell_market(ticker, pos['qty']) or price
|
|
||||||
log.info(f"[트레일/실시간] {ticker} 고점 {fp(pos['running_peak'])}원 → {fp(price)}원")
|
|
||||||
_record_exit(ticker, exit_price, 'trail')
|
|
||||||
|
|
||||||
|
|
||||||
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
# ── 초기화 ────────────────────────────────────────────────────────────────────
|
||||||
def preload_bars() -> None:
|
def preload_bars() -> None:
|
||||||
|
"""REST API 1분봉으로 bars[] 사전 적재."""
|
||||||
need_min = (VOL_LOOKBACK + 10) // 3 + 1
|
need_min = (VOL_LOOKBACK + 10) // 3 + 1
|
||||||
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
|
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
|
||||||
loaded = 0
|
loaded = 0
|
||||||
@@ -647,7 +403,7 @@ def preload_bars() -> None:
|
|||||||
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
|
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
|
||||||
loaded += 1
|
loaded += 1
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||||
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
|
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
@@ -655,91 +411,22 @@ def preload_bars() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def restore_positions() -> None:
|
def restore_positions() -> None:
|
||||||
"""Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응)."""
|
"""Upbit 잔고에서 포지션 + 미체결 매수 복구."""
|
||||||
if SIM_MODE:
|
if SIM_MODE:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
balances = upbit_client.get_balances()
|
restore_from_upbit(
|
||||||
log.info(f"[복구] 잔고 조회: {len(balances)}건")
|
upbit_client, TICKERS, positions, pending_buys,
|
||||||
for b in balances:
|
cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE),
|
||||||
currency = b.get('currency', '')
|
fp_fn=fp, tg_fn=tg,
|
||||||
bal = float(b.get('balance', 0))
|
)
|
||||||
locked = float(b.get('locked', 0))
|
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||||
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:
|
|
||||||
log.info(f"[복구] {ticker} TICKERS 외 → 스킵")
|
|
||||||
continue
|
|
||||||
if ticker in positions:
|
|
||||||
continue
|
|
||||||
log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp(avg)}원")
|
|
||||||
# 기존 미체결 매도 주문 전부 취소 (트레일링으로 관리)
|
|
||||||
try:
|
|
||||||
old_orders = upbit_client.get_order(ticker, state='wait') or []
|
|
||||||
for o in (old_orders if isinstance(old_orders, list) else []):
|
|
||||||
if o.get('side') == 'ask':
|
|
||||||
cancel_order_safe(o.get('uuid'))
|
|
||||||
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
|
|
||||||
# 취소 후 실제 가용 수량 재조회
|
|
||||||
time.sleep(0.5)
|
|
||||||
actual_bal = upbit_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(avg)}원 트레일링")
|
|
||||||
tg(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}")
|
|
||||||
|
|
||||||
# 미체결 매수 주문 복구 → pending_buys
|
|
||||||
for ticker in TICKERS:
|
|
||||||
if ticker in positions or ticker in pending_buys:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
orders = upbit_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(price)}원 수량:{rem:.6f}")
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
restored = len(positions) + len(pending_buys)
|
|
||||||
if restored:
|
|
||||||
log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
|
|
||||||
# 복구 결과를 position_sync에 반영
|
|
||||||
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']))
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
|
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
||||||
|
def main() -> None:
|
||||||
|
"""tick_trader 메인 루프."""
|
||||||
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
||||||
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
||||||
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원")
|
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원")
|
||||||
@@ -770,7 +457,7 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
ticker = data.get('code')
|
ticker = data.get('code')
|
||||||
price = data.get('trade_price')
|
price = data.get('trade_price')
|
||||||
volume = data.get('trade_volume')
|
volume = data.get('trade_volume')
|
||||||
|
|
||||||
if not ticker or price is None or volume is None:
|
if not ticker or price is None or volume is None:
|
||||||
@@ -785,7 +472,7 @@ def main():
|
|||||||
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
|
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
|
||||||
if positions:
|
if positions:
|
||||||
pos_lines = ' '.join(
|
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}%)"
|
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()
|
for t, p in positions.items()
|
||||||
)
|
)
|
||||||
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
|
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ module.exports = {
|
|||||||
autorestart: true,
|
autorestart: true,
|
||||||
watch: false,
|
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",
|
name: "context-collector",
|
||||||
script: "daemons/context_collector.py",
|
script: "daemons/context_collector.py",
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ requires-python = ">=3.9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"pyupbit>=0.3.0",
|
"pyupbit>=0.3.0",
|
||||||
"python-dotenv>=1.0",
|
"python-dotenv>=1.0",
|
||||||
|
"anthropic>=0.40",
|
||||||
|
"oracledb>=2.0",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user