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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:46:47 +09:00

324 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()