- 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>
324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""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()
|