refactor: reorganize project structure into tests/, data/, logs/
- Move all backtest/simulation scripts to tests/ - Add sys.path.insert to each script for correct import resolution - Move pkl cache files to data/ (git-ignored) - Move log files to logs/ (git-ignored) - Update main.py: trading.log path → logs/trading.log - Add ecosystem.config.js: pm2 log paths → logs/pm2*.log - Update .gitignore: ignore data/ and logs/ instead of *.pkl/*.log - core/fng.py: increase cache TTL 3600→86400s (API updates daily at KST 09:00) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,5 +2,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
*.log
|
logs/
|
||||||
*.pkl
|
data/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""공포탐욕지수(F&G) 조회 모듈.
|
"""공포탐욕지수(F&G) 조회 모듈.
|
||||||
|
|
||||||
alternative.me API로 일일 F&G 값을 가져와 메모리에 캐시한다.
|
alternative.me API로 일일 F&G 값을 가져와 메모리에 캐시한다.
|
||||||
캐시 TTL은 1시간 (F&G는 하루 1회 업데이트).
|
캐시 TTL은 24시간 (F&G는 하루 1회 KST 09:00 업데이트).
|
||||||
|
|
||||||
환경변수:
|
환경변수:
|
||||||
FNG_MIN_ENTRY (기본값 41): 이 값 미만이면 진입 차단
|
FNG_MIN_ENTRY (기본값 41): 이 값 미만이면 진입 차단
|
||||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41")) # 진입 허용 최소 F&G 값
|
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41")) # 진입 허용 최소 F&G 값
|
||||||
_FNG_API_URL = "https://api.alternative.me/fng/?limit=1&format=json"
|
_FNG_API_URL = "https://api.alternative.me/fng/?limit=1&format=json"
|
||||||
_CACHE_TTL = 3600 # 1시간
|
_CACHE_TTL = 86400 # 24시간 (API는 하루 1회 KST 09:00 업데이트)
|
||||||
|
|
||||||
_fng_value: int | None = None
|
_fng_value: int | None = None
|
||||||
_fng_cached_at: float = 0.0
|
_fng_cached_at: float = 0.0
|
||||||
|
|||||||
15
ecosystem.config.js
Normal file
15
ecosystem.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "upbit-trader",
|
||||||
|
script: "main.py",
|
||||||
|
interpreter: ".venv/bin/python3",
|
||||||
|
cwd: "/Users/joungmin/workspaces/upbit-trader",
|
||||||
|
out_file: "logs/pm2.log",
|
||||||
|
error_file: "logs/pm2-error.log",
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
2
main.py
2
main.py
@@ -13,7 +13,7 @@ logging.basicConfig(
|
|||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.StreamHandler(),
|
logging.StreamHandler(),
|
||||||
logging.FileHandler("trading.log", encoding="utf-8"),
|
logging.FileHandler("logs/trading.log", encoding="utf-8"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
캐시: Oracle ADB backtest_ohlcv / backtest_daily / backtest_trades 테이블
|
캐시: Oracle ADB backtest_ohlcv / backtest_daily / backtest_trades 테이블
|
||||||
시뮬레이션 결과는 backtest_trades에 파라미터 해시로 저장 — 동일 파라미터 재실행 시 즉시 반환
|
시뮬레이션 결과는 backtest_trades에 파라미터 해시로 저장 — 동일 파라미터 재실행 시 즉시 반환
|
||||||
"""
|
"""
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
backtest_results - 조건별 집계 (run_id + label)
|
backtest_results - 조건별 집계 (run_id + label)
|
||||||
backtest_trade_log - 개별 거래 (run_id + label + 종목 + pnl + fng + ...)
|
backtest_trade_log - 개별 거래 (run_id + label + 종목 + pnl + fng + ...)
|
||||||
"""
|
"""
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
60일 극공포 편향을 제거하고 Bull/Neutral/Bear 다양한 구간 포함.
|
60일 극공포 편향을 제거하고 Bull/Neutral/Bear 다양한 구간 포함.
|
||||||
데이터: 1h 캔들 배치 수집 → 약 365일치
|
데이터: 1h 캔들 배치 수집 → 약 365일치
|
||||||
"""
|
"""
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime, json, time, sys, urllib.request
|
import datetime, json, time, sys, urllib.request
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
결과를 Oracle DB에 저장.
|
결과를 Oracle DB에 저장.
|
||||||
데이터: 1년치 1h 캔들 (배치 수집)
|
데이터: 1년치 1h 캔들 (배치 수집)
|
||||||
"""
|
"""
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
258
tests/fng_backtest.py
Normal file
258
tests/fng_backtest.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""공포탐욕지수(F&G) 조건별 백테스트
|
||||||
|
|
||||||
|
질문: "F&G 수치에 따라 진입 조건을 제한하면 성과가 나아지는가?"
|
||||||
|
|
||||||
|
테스트 구간:
|
||||||
|
A. 필터 없음 (baseline)
|
||||||
|
B. F&G > 25 (Extreme Fear 구간 제외)
|
||||||
|
C. F&G > 45 (Fear 이하 구간 제외, Neutral 이상)
|
||||||
|
D. F&G > 55 (Greed 이상만 진입)
|
||||||
|
E. F&G 20~45 (Fear 구간만 진입 – 역발상 매수)
|
||||||
|
|
||||||
|
진입 전략: 모멘텀 (거래량 급증 + 횡보 → +3% 상승 시 매수)
|
||||||
|
청산: 트레일링 스탑 1.5% + 타임 스탑 24h/+3%
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json, time, sys, datetime, urllib.request
|
||||||
|
import pyupbit
|
||||||
|
import pandas as pd
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# ── 종목 ──────────────────────────────────────────────────────
|
||||||
|
TICKERS = [
|
||||||
|
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
|
||||||
|
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
|
||||||
|
"KRW-SUI", "KRW-HBAR", "KRW-TRX",
|
||||||
|
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
|
||||||
|
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 공통 파라미터 ─────────────────────────────────────────────
|
||||||
|
VOL_MULT = 2.0
|
||||||
|
QUIET_2H = 2.0
|
||||||
|
SIG_TO_H = 8
|
||||||
|
MOM_THR = 3.0
|
||||||
|
SIG_CANCEL = 3.0
|
||||||
|
TRAIL_STOP = 0.015 # 1.5% (앞 테스트에서 최적)
|
||||||
|
TIME_H = 24
|
||||||
|
TIME_MIN = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── F&G 데이터 로드 ───────────────────────────────────────────
|
||||||
|
def load_fng(days: int = 365) -> dict[str, int]:
|
||||||
|
"""날짜(YYYY-MM-DD) → F&G 지수 딕셔너리 반환."""
|
||||||
|
url = f"https://api.alternative.me/fng/?limit={days}&format=json"
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
result = {}
|
||||||
|
for d in data["data"]:
|
||||||
|
dt_str = datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d")
|
||||||
|
result[dt_str] = int(d["value"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fng_at(fng_map: dict, ts: pd.Timestamp) -> int:
|
||||||
|
"""타임스탬프에 해당하는 F&G 값 반환. 없으면 50(Neutral) 반환."""
|
||||||
|
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
|
||||||
|
|
||||||
|
|
||||||
|
# ── F&G 필터 정의 ─────────────────────────────────────────────
|
||||||
|
CONFIGS = [
|
||||||
|
(None, None, "필터 없음 (baseline)"),
|
||||||
|
(26, None, "F&G > 25 (Extreme Fear 제외)"),
|
||||||
|
(46, None, "F&G > 45 (Neutral 이상)"),
|
||||||
|
(56, None, "F&G > 55 (Greed 이상)"),
|
||||||
|
(20, 45, "F&G 20~45 (Fear 역발상 매수)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시뮬레이션 ────────────────────────────────────────────────
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
pnl: float
|
||||||
|
h: int
|
||||||
|
fng: int
|
||||||
|
exit: str
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(df, fng_map: dict, fng_lo=None, fng_hi=None) -> list[Trade]:
|
||||||
|
closes = df["close"].values
|
||||||
|
vols = df["volume"].values
|
||||||
|
idx = df.index
|
||||||
|
trades: list[Trade] = []
|
||||||
|
sig_px = sig_i = None
|
||||||
|
pos_buy = pos_peak = pos_i = pos_fng = None
|
||||||
|
|
||||||
|
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
|
||||||
|
|
||||||
|
# ── 포지션 관리 ──────────────────────────────────────
|
||||||
|
if pos_buy is not None:
|
||||||
|
cur = closes[i]
|
||||||
|
if cur > pos_peak:
|
||||||
|
pos_peak = cur
|
||||||
|
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
|
||||||
|
pnl = (cur - pos_buy) / pos_buy * 100
|
||||||
|
trades.append(Trade(pnl, i - pos_i, pos_fng, "trail"))
|
||||||
|
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
|
||||||
|
continue
|
||||||
|
if i - pos_i >= TIME_H:
|
||||||
|
pnl = (cur - pos_buy) / pos_buy * 100
|
||||||
|
if pnl < TIME_MIN:
|
||||||
|
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
|
||||||
|
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 신호 유효성 ──────────────────────────────────────
|
||||||
|
if sig_px is not None:
|
||||||
|
if i - sig_i > SIG_TO_H:
|
||||||
|
sig_px = sig_i = None
|
||||||
|
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
|
||||||
|
sig_px = sig_i = None
|
||||||
|
|
||||||
|
# ── 축적 감지 ─────────────────────────────────────────
|
||||||
|
if sig_px is None:
|
||||||
|
vol_avg = vols[i - 6:i - 1].mean()
|
||||||
|
if vol_avg <= 0:
|
||||||
|
continue
|
||||||
|
if vols[i - 1] / vol_avg >= VOL_MULT:
|
||||||
|
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
|
||||||
|
sig_px = closes[i]
|
||||||
|
sig_i = i
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── F&G 필터 체크 ─────────────────────────────────────
|
||||||
|
fng_val = fng_at(fng_map, idx[i])
|
||||||
|
if fng_lo is not None and fng_val < fng_lo:
|
||||||
|
continue
|
||||||
|
if fng_hi is not None and fng_val > fng_hi:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 모멘텀 진입 ───────────────────────────────────────
|
||||||
|
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
|
||||||
|
pos_buy = pos_peak = closes[i]
|
||||||
|
pos_i = i
|
||||||
|
pos_fng = fng_val
|
||||||
|
sig_px = sig_i = None
|
||||||
|
|
||||||
|
return trades
|
||||||
|
|
||||||
|
|
||||||
|
def stats(trades: list[Trade]) -> dict:
|
||||||
|
if not trades:
|
||||||
|
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
|
||||||
|
avg_win=0, avg_loss=0, avg_fng=0, max_dd=0)
|
||||||
|
wins = [t for t in trades if t.pnl > 0]
|
||||||
|
losses = [t for t in trades if t.pnl <= 0]
|
||||||
|
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
|
||||||
|
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
|
||||||
|
rr = abs(aw / al) if al else 0
|
||||||
|
|
||||||
|
cum = 0.0; pk = 0.0; max_dd = 0.0
|
||||||
|
for t in trades:
|
||||||
|
cum += t.pnl
|
||||||
|
if cum > pk: pk = cum
|
||||||
|
if pk - cum > max_dd: max_dd = pk - cum
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
n = len(trades),
|
||||||
|
wr = len(wins) / len(trades) * 100,
|
||||||
|
avg_pnl = sum(t.pnl for t in trades) / len(trades),
|
||||||
|
total_pnl= sum(t.pnl for t in trades),
|
||||||
|
rr = rr,
|
||||||
|
avg_win = aw,
|
||||||
|
avg_loss = al,
|
||||||
|
avg_fng = sum(t.fng for t in trades) / len(trades),
|
||||||
|
max_dd = max_dd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("F&G 데이터 로드 중...")
|
||||||
|
fng_map = load_fng(365)
|
||||||
|
print(f" {len(fng_map)}일치 F&G 데이터 로드 완료\n")
|
||||||
|
|
||||||
|
print(f"종목 데이터 수집 중 ({len(TICKERS)}개, 60일 1h 캔들)...")
|
||||||
|
datasets: dict = {}
|
||||||
|
for i, tk in enumerate(TICKERS):
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(tk, interval="minute60", count=1440)
|
||||||
|
if df is not None and len(df) > 50:
|
||||||
|
datasets[tk] = df
|
||||||
|
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ")
|
||||||
|
time.sleep(0.08)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
print(f" 완료: {len(datasets)}개 종목\n")
|
||||||
|
|
||||||
|
# ── 결과 집계 ──────────────────────────────────────────────
|
||||||
|
print("=" * 78)
|
||||||
|
print(" F&G 조건별 백테스트 결과 (60일 / 모멘텀 진입 / 스탑 1.5%)")
|
||||||
|
print("=" * 78)
|
||||||
|
print(f" {'조건':<30} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
|
||||||
|
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} {'진입F&G':>7}")
|
||||||
|
print(" " + "-" * 74)
|
||||||
|
|
||||||
|
all_results = {}
|
||||||
|
for fng_lo, fng_hi, label in CONFIGS:
|
||||||
|
all_trades: list[Trade] = []
|
||||||
|
for df in datasets.values():
|
||||||
|
all_trades.extend(simulate(df, fng_map, fng_lo, fng_hi))
|
||||||
|
s = stats(all_trades)
|
||||||
|
all_results[label] = (s, all_trades)
|
||||||
|
marker = " ◀ 현행" if "없음" in label else ""
|
||||||
|
if s["n"] == 0:
|
||||||
|
print(f" {label:<30} 거래 없음")
|
||||||
|
continue
|
||||||
|
print(
|
||||||
|
f" {label:<30} {s['n']:>5}건 {s['wr']:>5.1f}% "
|
||||||
|
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
|
||||||
|
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
|
||||||
|
f"{s['avg_fng']:>6.1f}{marker}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── F&G 구간별 세부 성과 ──────────────────────────────────
|
||||||
|
print()
|
||||||
|
print(" F&G 수치 구간별 실제 거래 성과 (필터 없음 전체 거래 기준):")
|
||||||
|
base_trades = all_results["필터 없음 (baseline)"][1]
|
||||||
|
|
||||||
|
zones = [
|
||||||
|
(0, 25, "Extreme Fear (0~25) "),
|
||||||
|
(26, 45, "Fear (26~45)"),
|
||||||
|
(46, 55, "Neutral (46~55)"),
|
||||||
|
(56, 75, "Greed (56~75)"),
|
||||||
|
(76, 100, "Extreme Greed(76~100)"),
|
||||||
|
]
|
||||||
|
print(f" {'F&G 구간':<28} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6}")
|
||||||
|
print(" " + "-" * 60)
|
||||||
|
for lo, hi, name in zones:
|
||||||
|
subset = [t for t in base_trades if lo <= t.fng <= hi]
|
||||||
|
if not subset:
|
||||||
|
continue
|
||||||
|
s = stats(subset)
|
||||||
|
print(
|
||||||
|
f" {name:<28} {s['n']:>5}건 {s['wr']:>5.1f}% "
|
||||||
|
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 월별 F&G 흐름과 성과 ─────────────────────────────────
|
||||||
|
print()
|
||||||
|
print(" F&G 추이와 진입 시점 분포:")
|
||||||
|
sorted_fng = sorted(fng_map.items())[-70:] # 최근 70일
|
||||||
|
for dt_str, val in sorted_fng[::7]: # 1주 간격
|
||||||
|
bar_len = val // 5
|
||||||
|
bar = "█" * bar_len
|
||||||
|
zone = ("극공포" if val <= 25 else "공포" if val <= 45
|
||||||
|
else "중립" if val <= 55 else "탐욕")
|
||||||
|
print(f" {dt_str} {val:>3} {bar:<20} {zone}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
결과는 Oracle DB(backtest_results)에 저장.
|
결과는 Oracle DB(backtest_results)에 저장.
|
||||||
데이터: 1년치 1h 캔들 (배치 수집)
|
데이터: 1년치 1h 캔들 (배치 수집)
|
||||||
"""
|
"""
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -4,6 +4,9 @@ A안: 추세(2h +5%) + 15분 워치리스트 (모멘텀 없음)
|
|||||||
B안: 추세(2h +5%) + 모멘텀 + 15분 워치리스트 (현행)
|
B안: 추세(2h +5%) + 모멘텀 + 15분 워치리스트 (현행)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os, time
|
import os, time
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
- 증분 업데이트 (신규 봉만 API 페치)
|
- 증분 업데이트 (신규 봉만 API 페치)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
248
tests/pullback_backtest.py
Normal file
248
tests/pullback_backtest.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""눌림목 진입 vs 모멘텀 진입 백테스트 비교
|
||||||
|
|
||||||
|
전략 A — 모멘텀 (현행):
|
||||||
|
거래량 급증 + 횡보 감지 → 신호가 기록
|
||||||
|
→ 신호가 대비 +3% 상승 확인 시 즉시 매수
|
||||||
|
|
||||||
|
전략 B — 눌림목:
|
||||||
|
거래량 급증 + 횡보 감지 → 신호가 기록
|
||||||
|
→ 신호가 대비 -1.5% 이하로 눌림 확인
|
||||||
|
→ 눌림 저점에서 +0.5% 회복 시 매수
|
||||||
|
|
||||||
|
공통 청산:
|
||||||
|
트레일링 스탑 (최고가 대비 -2%) + 타임 스탑 (24h / +3% 미달 시)
|
||||||
|
|
||||||
|
데이터: 1h 캔들 60일 (최근)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import pyupbit, time, sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# ─── 종목 (현재 전략이 스캔하는 상위권 종목) ───────────────────
|
||||||
|
TICKERS = [
|
||||||
|
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
|
||||||
|
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
|
||||||
|
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM", "KRW-SAND",
|
||||||
|
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
|
||||||
|
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── 공통 파라미터 ──────────────────────────────────────────────
|
||||||
|
VOL_MULT = 2.0 # 거래량 급증 배수 (직전 캔들 / 5h 평균)
|
||||||
|
QUIET_2H = 2.0 # 2h 횡보 기준 (%)
|
||||||
|
SIGNAL_TO_H = 8 # 신호 유효 시간 (h)
|
||||||
|
TRAIL_STOP = 0.020 # 트레일링 스탑 2%
|
||||||
|
TIME_STOP_H = 24 # 타임 스탑 기준 시간
|
||||||
|
TIME_STOP_MIN = 3.0 # 타임 스탑 최소 수익 (%)
|
||||||
|
SIGNAL_CANCEL = 3.0 # 신호가 대비 -3% 하락 시 신호 취소 (%)
|
||||||
|
|
||||||
|
# ─── 전략 A: 모멘텀 진입 ─────────────────────────────────────
|
||||||
|
MOMENTUM_THR = 3.0 # 신호가 대비 +3% 이상 상승 시 매수
|
||||||
|
|
||||||
|
# ─── 전략 B: 눌림목 진입 ─────────────────────────────────────
|
||||||
|
# 다양한 깊이 비교 (PULLBACK_DEPTH: 신호가 대비 몇 % 눌림 대기)
|
||||||
|
PULLBACK_CONFIGS = [
|
||||||
|
(0.5, 0.3, "눌림0.5%+회복0.3%"),
|
||||||
|
(1.0, 0.5, "눌림1.0%+회복0.5%"),
|
||||||
|
(1.5, 0.5, "눌림1.5%+회복0.5%"),
|
||||||
|
(2.0, 0.5, "눌림2.0%+회복0.5%"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(df, strategy: str, pb_depth: float = 1.5, pb_recovery: float = 0.5) -> dict:
|
||||||
|
closes = df["close"].values
|
||||||
|
vols = df["volume"].values
|
||||||
|
|
||||||
|
trades = []
|
||||||
|
sig_px = sig_i = dip_px = None
|
||||||
|
pos_buy = pos_peak = pos_i = None
|
||||||
|
|
||||||
|
vol_window = 5 # 5h 평균 (1h 캔들 기준)
|
||||||
|
|
||||||
|
for i in range(vol_window + 2, len(closes) - max(TIME_STOP_H + 4, 10)):
|
||||||
|
|
||||||
|
# ── 포지션 관리 ────────────────────────────────────────
|
||||||
|
if pos_buy is not None:
|
||||||
|
cur = closes[i]
|
||||||
|
if cur > pos_peak:
|
||||||
|
pos_peak = cur
|
||||||
|
|
||||||
|
# 트레일링 스탑
|
||||||
|
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
|
||||||
|
pnl = (cur - pos_buy) / pos_buy * 100
|
||||||
|
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "trail"})
|
||||||
|
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 타임 스탑
|
||||||
|
if i - pos_i >= TIME_STOP_H:
|
||||||
|
pnl = (cur - pos_buy) / pos_buy * 100
|
||||||
|
if pnl < TIME_STOP_MIN:
|
||||||
|
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "time"})
|
||||||
|
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 신호 유효성 체크 ──────────────────────────────────
|
||||||
|
if sig_px is not None:
|
||||||
|
# 시간 초과
|
||||||
|
if i - sig_i > SIGNAL_TO_H:
|
||||||
|
sig_px = sig_i = dip_px = None
|
||||||
|
# 신호가 대비 큰 하락 → 축적 실패
|
||||||
|
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
|
||||||
|
sig_px = sig_i = dip_px = None
|
||||||
|
|
||||||
|
# ── 신호 없으면 축적 조건 탐색 ────────────────────────
|
||||||
|
if sig_px is None:
|
||||||
|
vol_avg = vols[i - vol_window - 1 : i - 1].mean()
|
||||||
|
if vol_avg <= 0:
|
||||||
|
continue
|
||||||
|
vol_ratio = vols[i - 1] / vol_avg
|
||||||
|
quiet = abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H
|
||||||
|
if vol_ratio >= VOL_MULT and quiet:
|
||||||
|
sig_px = closes[i]
|
||||||
|
sig_i = i
|
||||||
|
dip_px = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 진입 로직 ─────────────────────────────────────────
|
||||||
|
cur = closes[i]
|
||||||
|
move = (cur - sig_px) / sig_px * 100
|
||||||
|
|
||||||
|
if strategy == "momentum":
|
||||||
|
if move >= MOMENTUM_THR:
|
||||||
|
pos_buy = pos_peak = cur
|
||||||
|
pos_i = i
|
||||||
|
sig_px = sig_i = None
|
||||||
|
|
||||||
|
else: # pullback
|
||||||
|
if dip_px is None:
|
||||||
|
if move <= -pb_depth:
|
||||||
|
dip_px = cur
|
||||||
|
else:
|
||||||
|
if cur < dip_px:
|
||||||
|
dip_px = cur # 저점 갱신
|
||||||
|
recovery = (cur - dip_px) / dip_px * 100
|
||||||
|
if recovery >= pb_recovery:
|
||||||
|
pos_buy = pos_peak = cur
|
||||||
|
pos_i = i
|
||||||
|
sig_px = sig_i = dip_px = None
|
||||||
|
|
||||||
|
if not trades:
|
||||||
|
return {"n": 0, "wins": 0, "wr": 0.0, "avg_pnl": 0.0, "total_pnl": 0.0, "avg_h": 0.0}
|
||||||
|
|
||||||
|
wins = [t for t in trades if t["pnl"] > 0]
|
||||||
|
return {
|
||||||
|
"n": len(trades),
|
||||||
|
"wins": len(wins),
|
||||||
|
"wr": len(wins) / len(trades) * 100,
|
||||||
|
"avg_pnl": sum(t["pnl"] for t in trades) / len(trades),
|
||||||
|
"total_pnl": sum(t["pnl"] for t in trades),
|
||||||
|
"avg_h": sum(t["h"] for t in trades) / len(trades),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agg(results: list[dict]) -> dict:
|
||||||
|
"""종목별 결과 리스트를 합산."""
|
||||||
|
if not results:
|
||||||
|
return {"n": 0, "wins": 0, "wr": 0, "avg_pnl": 0, "total_pnl": 0, "avg_h": 0}
|
||||||
|
total_n = sum(r["n"] for r in results)
|
||||||
|
total_wins = sum(r["wins"] for r in results)
|
||||||
|
total_pnl = sum(r["total_pnl"] for r in results)
|
||||||
|
all_h = [r["avg_h"] for r in results if r["n"] > 0]
|
||||||
|
return {
|
||||||
|
"n": total_n,
|
||||||
|
"wins": total_wins,
|
||||||
|
"wr": total_wins / total_n * 100 if total_n else 0,
|
||||||
|
"avg_pnl": total_pnl / total_n if total_n else 0,
|
||||||
|
"total_pnl": total_pnl,
|
||||||
|
"avg_h": sum(all_h) / len(all_h) if all_h else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
|
||||||
|
datasets: dict[str, object] = {}
|
||||||
|
for i, ticker in enumerate(TICKERS):
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
|
||||||
|
if df is not None and len(df) > 50:
|
||||||
|
datasets[ticker] = df
|
||||||
|
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
|
||||||
|
time.sleep(0.08)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
print(f" 수집 완료: {len(datasets)}개 종목\n")
|
||||||
|
|
||||||
|
# 전략별 결과 수집
|
||||||
|
strat_labels = ["모멘텀(현행)", "눌림0.5%", "눌림1.0%", "눌림1.5%", "눌림2.0%"]
|
||||||
|
strat_results: dict[str, list[dict]] = {l: [] for l in strat_labels}
|
||||||
|
|
||||||
|
for ticker, df in datasets.items():
|
||||||
|
r_mom = simulate(df, "momentum")
|
||||||
|
strat_results["모멘텀(현행)"].append(r_mom)
|
||||||
|
for depth, rec, label in PULLBACK_CONFIGS:
|
||||||
|
lbl = f"눌림{depth:.1f}%"
|
||||||
|
r_pb = simulate(df, "pullback", pb_depth=depth, pb_recovery=rec)
|
||||||
|
strat_results[lbl].append(r_pb)
|
||||||
|
|
||||||
|
# ─── 결과 출력 ─────────────────────────────────────────────
|
||||||
|
print("=" * 72)
|
||||||
|
print(" 전략 비교 (60일 / 25개 종목 합산)")
|
||||||
|
print("=" * 72)
|
||||||
|
print(f" {'전략':<16} {'거래':>5} {'승률':>7} {'평균PnL':>9} {'총PnL(%)':>10} {'평균보유':>8}")
|
||||||
|
print(" " + "-" * 62)
|
||||||
|
|
||||||
|
for label in strat_labels:
|
||||||
|
r = agg(strat_results[label])
|
||||||
|
if r["n"] == 0:
|
||||||
|
print(f" {label:<16} 데이터 없음")
|
||||||
|
continue
|
||||||
|
marker = " ◀ 현행" if label == "모멘텀(현행)" else ""
|
||||||
|
print(
|
||||||
|
f" {label:<16} {r['n']:>5}건 {r['wr']:>6.1f}% "
|
||||||
|
f"{r['avg_pnl']:>+8.3f}% {r['total_pnl']:>+9.2f}% "
|
||||||
|
f"{r['avg_h']:>6.1f}h{marker}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 승률/손익 상세 비교
|
||||||
|
print()
|
||||||
|
print(" 손익비 (avg_win / |avg_loss|) 비교:")
|
||||||
|
for label in strat_labels:
|
||||||
|
all_trades = []
|
||||||
|
for r_list in [simulate(df, "momentum") if label == "모멘텀(현행)"
|
||||||
|
else simulate(df, "pullback",
|
||||||
|
pb_depth=float(label.replace("눌림","").replace("%","")),
|
||||||
|
pb_recovery=0.5)
|
||||||
|
for df in datasets.values()]:
|
||||||
|
pass # need per-trade data
|
||||||
|
|
||||||
|
# 종목별 상세 (상위/하위)
|
||||||
|
print()
|
||||||
|
print(" 종목별 모멘텀 vs 눌림1.5% 비교:")
|
||||||
|
print(f" {'종목':<14} {'모멘텀 WR':>9} {'모멘텀 PnL':>11} {'눌림1.5% WR':>11} {'눌림1.5% PnL':>12}")
|
||||||
|
print(" " + "-" * 62)
|
||||||
|
|
||||||
|
ticker_list = list(datasets.keys())
|
||||||
|
for j, ticker in enumerate(ticker_list):
|
||||||
|
df = datasets[ticker]
|
||||||
|
rm = strat_results["모멘텀(현행)"][j]
|
||||||
|
rp = strat_results["눌림1.5%"][j]
|
||||||
|
if rm["n"] == 0 and rp["n"] == 0:
|
||||||
|
continue
|
||||||
|
rm_wr = f"{rm['wr']:.0f}%" if rm["n"] else "-"
|
||||||
|
rm_pnl = f"{rm['total_pnl']:+.2f}%" if rm["n"] else "-"
|
||||||
|
rp_wr = f"{rp['wr']:.0f}%" if rp["n"] else "-"
|
||||||
|
rp_pnl = f"{rp['total_pnl']:+.2f}%" if rp["n"] else "-"
|
||||||
|
diff = (rp["total_pnl"] - rm["total_pnl"]) if (rm["n"] and rp["n"]) else 0
|
||||||
|
mark = "↑" if diff > 0.5 else ("↓" if diff < -0.5 else "=")
|
||||||
|
print(f" {ticker:<14} {rm_wr:>9} {rm_pnl:>11} {rp_wr:>11} {rp_pnl:>12} {mark}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,6 +4,9 @@ WF차단 종목들에 대해 shadow 포지션을 시뮬레이션하여
|
|||||||
몇 번의 shadow 승리 후 WF차단이 해제될 수 있었는지 분석.
|
몇 번의 shadow 승리 후 WF차단이 해제될 수 있었는지 분석.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Shadow 재활 이후 실제 진입 성과 시뮬레이션."""
|
"""Shadow 재활 이후 실제 진입 성과 시뮬레이션."""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
3. 15분 워치리스트: 첫 신호 후 15분 재확인
|
3. 15분 워치리스트: 첫 신호 후 15분 재확인
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os, time
|
import os, time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
260
tests/stop_sweep.py
Normal file
260
tests/stop_sweep.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""트레일링 스탑 크기 스윕 백테스트
|
||||||
|
|
||||||
|
현행 모멘텀 진입 전략에서
|
||||||
|
트레일링 스탑 크기(1% ~ 6%)를 바꿔가며 성과 비교.
|
||||||
|
|
||||||
|
핵심 질문: "2% 스탑이 너무 좁아서 정상 노이즈에 스탑이 걸리는 건 아닌가?"
|
||||||
|
"""
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pyupbit, time, sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
TICKERS = [
|
||||||
|
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
|
||||||
|
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
|
||||||
|
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM",
|
||||||
|
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
|
||||||
|
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 공통
|
||||||
|
VOL_MULT = 2.0
|
||||||
|
QUIET_2H = 2.0
|
||||||
|
SIGNAL_TO_H = 8
|
||||||
|
MOMENTUM_THR = 3.0
|
||||||
|
SIGNAL_CANCEL = 3.0
|
||||||
|
TIME_STOP_H = 24
|
||||||
|
TIME_STOP_MIN = 3.0 # 24h 후 +3% 미달 시 청산
|
||||||
|
|
||||||
|
# 테스트할 스탑 크기 목록
|
||||||
|
STOP_SIZES = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
pnl: float
|
||||||
|
h: int
|
||||||
|
exit: str # trail | time
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(df, trail_stop_pct: float) -> list[Trade]:
|
||||||
|
closes = df["close"].values
|
||||||
|
vols = df["volume"].values
|
||||||
|
trail = trail_stop_pct / 100.0
|
||||||
|
|
||||||
|
trades: list[Trade] = []
|
||||||
|
sig_px = sig_i = None
|
||||||
|
pos_buy = pos_peak = pos_i = None
|
||||||
|
|
||||||
|
for i in range(7, len(closes) - max(TIME_STOP_H + 4, 10)):
|
||||||
|
|
||||||
|
# ── 포지션 관리 ──────────────────────────────────────
|
||||||
|
if pos_buy is not None:
|
||||||
|
cur = closes[i]
|
||||||
|
if cur > pos_peak:
|
||||||
|
pos_peak = cur
|
||||||
|
|
||||||
|
# 트레일링 스탑
|
||||||
|
if (pos_peak - cur) / pos_peak >= trail:
|
||||||
|
pnl = (cur - pos_buy) / pos_buy * 100
|
||||||
|
trades.append(Trade(pnl, i - pos_i, "trail"))
|
||||||
|
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 타임 스탑
|
||||||
|
if i - pos_i >= TIME_STOP_H:
|
||||||
|
pnl = (cur - pos_buy) / pos_buy * 100
|
||||||
|
if pnl < TIME_STOP_MIN:
|
||||||
|
trades.append(Trade(pnl, i - pos_i, "time"))
|
||||||
|
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 신호 유효성 ──────────────────────────────────────
|
||||||
|
if sig_px is not None:
|
||||||
|
if i - sig_i > SIGNAL_TO_H:
|
||||||
|
sig_px = sig_i = None
|
||||||
|
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
|
||||||
|
sig_px = sig_i = None
|
||||||
|
|
||||||
|
# ── 축적 감지 ─────────────────────────────────────────
|
||||||
|
if sig_px is None:
|
||||||
|
vol_avg = vols[i-6:i-1].mean()
|
||||||
|
if vol_avg <= 0:
|
||||||
|
continue
|
||||||
|
if vols[i-1] / vol_avg >= VOL_MULT:
|
||||||
|
if abs(closes[i] - closes[i-2]) / closes[i-2] * 100 < QUIET_2H:
|
||||||
|
sig_px = closes[i]
|
||||||
|
sig_i = i
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 모멘텀 진입 ───────────────────────────────────────
|
||||||
|
if (closes[i] - sig_px) / sig_px * 100 >= MOMENTUM_THR:
|
||||||
|
pos_buy = pos_peak = closes[i]
|
||||||
|
pos_i = i
|
||||||
|
sig_px = sig_i = None
|
||||||
|
|
||||||
|
return trades
|
||||||
|
|
||||||
|
|
||||||
|
def stats(trades: list[Trade]) -> dict:
|
||||||
|
if not trades:
|
||||||
|
return dict(n=0, wins=0, wr=0, avg_pnl=0, avg_win=0, avg_loss=0,
|
||||||
|
rr=0, total_pnl=0, avg_h=0, max_dd=0, trail_pct=0, time_pct=0)
|
||||||
|
wins = [t for t in trades if t.pnl > 0]
|
||||||
|
losses = [t for t in trades if t.pnl <= 0]
|
||||||
|
avg_win = sum(t.pnl for t in wins) / len(wins) if wins else 0
|
||||||
|
avg_loss = sum(t.pnl for t in losses) / len(losses) if losses else 0
|
||||||
|
rr = abs(avg_win / avg_loss) if avg_loss else 0
|
||||||
|
|
||||||
|
# 누적 수익 흐름으로 Max Drawdown 계산
|
||||||
|
cum = 0.0; peak_cum = 0.0; max_dd = 0.0
|
||||||
|
for t in trades:
|
||||||
|
cum += t.pnl
|
||||||
|
if cum > peak_cum:
|
||||||
|
peak_cum = cum
|
||||||
|
dd = peak_cum - cum
|
||||||
|
if dd > max_dd:
|
||||||
|
max_dd = dd
|
||||||
|
|
||||||
|
trail_n = sum(1 for t in trades if t.exit == "trail")
|
||||||
|
time_n = sum(1 for t in trades if t.exit == "time")
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
n = len(trades),
|
||||||
|
wins = len(wins),
|
||||||
|
wr = len(wins) / len(trades) * 100,
|
||||||
|
avg_pnl = sum(t.pnl for t in trades) / len(trades),
|
||||||
|
avg_win = avg_win,
|
||||||
|
avg_loss = avg_loss,
|
||||||
|
rr = rr,
|
||||||
|
total_pnl = sum(t.pnl for t in trades),
|
||||||
|
avg_h = sum(t.h for t in trades) / len(trades),
|
||||||
|
max_dd = max_dd,
|
||||||
|
trail_pct = trail_n / len(trades) * 100,
|
||||||
|
time_pct = time_n / len(trades) * 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
|
||||||
|
datasets: dict = {}
|
||||||
|
for i, ticker in enumerate(TICKERS):
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
|
||||||
|
if df is not None and len(df) > 50:
|
||||||
|
datasets[ticker] = df
|
||||||
|
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
|
||||||
|
time.sleep(0.08)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
print(f" 완료: {len(datasets)}개 종목\n")
|
||||||
|
|
||||||
|
# ── 스탑 크기별 전체 집계 ─────────────────────────────────
|
||||||
|
print("=" * 80)
|
||||||
|
print(" 트레일링 스탑 크기별 성과 비교 (60일 / 모멘텀 진입 기준)")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f" {'스탑%':>5} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
|
||||||
|
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} "
|
||||||
|
f"{'평균보유':>7} {'Trail%':>7} {'Time%':>6}")
|
||||||
|
print(" " + "-" * 77)
|
||||||
|
|
||||||
|
best_total = -9999
|
||||||
|
best_stop = None
|
||||||
|
all_stats: dict[float, dict] = {}
|
||||||
|
|
||||||
|
for stop in STOP_SIZES:
|
||||||
|
all_trades: list[Trade] = []
|
||||||
|
for df in datasets.values():
|
||||||
|
all_trades.extend(simulate(df, stop))
|
||||||
|
s = stats(all_trades)
|
||||||
|
all_stats[stop] = s
|
||||||
|
|
||||||
|
marker = ""
|
||||||
|
if stop == 2.0:
|
||||||
|
marker = " ◀ 현행"
|
||||||
|
print(
|
||||||
|
f" {stop:>4.1f}% {s['n']:>5}건 {s['wr']:>5.1f}% "
|
||||||
|
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
|
||||||
|
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
|
||||||
|
f"{s['avg_h']:>6.1f}h {s['trail_pct']:>6.1f}% "
|
||||||
|
f"{s['time_pct']:>5.1f}%{marker}"
|
||||||
|
)
|
||||||
|
if s["total_pnl"] > best_total and s["n"] >= 30:
|
||||||
|
best_total = s["total_pnl"]
|
||||||
|
best_stop = stop
|
||||||
|
|
||||||
|
# ── 손익 분포 시각화 ─────────────────────────────────────
|
||||||
|
print()
|
||||||
|
print(" 총 손익 트렌드 (스탑 크기별):")
|
||||||
|
for stop in STOP_SIZES:
|
||||||
|
s = all_stats[stop]
|
||||||
|
if s["n"] == 0:
|
||||||
|
continue
|
||||||
|
bar_val = s["total_pnl"]
|
||||||
|
bar_len = int(abs(bar_val) / 5)
|
||||||
|
bar = ("▓" * min(bar_len, 30)) if bar_val > 0 else ("░" * min(bar_len, 30))
|
||||||
|
sign = "+" if bar_val > 0 else "-"
|
||||||
|
marker = " ◀ 현행" if stop == 2.0 else ""
|
||||||
|
print(f" {stop:>4.1f}% {sign}{bar} ({bar_val:+.1f}%){marker}")
|
||||||
|
|
||||||
|
# ── 이익/손실 평균 비교 ─────────────────────────────────
|
||||||
|
print()
|
||||||
|
print(" 이익 vs 손실 평균 비교:")
|
||||||
|
print(f" {'스탑%':>5} {'평균이익':>8} {'평균손실':>9} {'손익비':>6} {'설명'}")
|
||||||
|
print(" " + "-" * 55)
|
||||||
|
for stop in STOP_SIZES:
|
||||||
|
s = all_stats[stop]
|
||||||
|
if s["n"] == 0:
|
||||||
|
continue
|
||||||
|
marker = " ◀ 현행" if stop == 2.0 else ""
|
||||||
|
# 손익비 해석
|
||||||
|
if s["rr"] >= 1.5: interp = "양호"
|
||||||
|
elif s["rr"] >= 1.0: interp = "보통"
|
||||||
|
elif s["rr"] >= 0.7: interp = "불량"
|
||||||
|
else: interp = "매우불량"
|
||||||
|
print(
|
||||||
|
f" {stop:>4.1f}% {s['avg_win']:>+7.3f}% {s['avg_loss']:>+8.3f}% "
|
||||||
|
f"{s['rr']:>5.2f} {interp}{marker}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if best_stop:
|
||||||
|
s = all_stats[best_stop]
|
||||||
|
print(f"\n ★ 최적 스탑: {best_stop:.1f}% "
|
||||||
|
f"(거래 {s['n']}건 | 승률 {s['wr']:.1f}% | "
|
||||||
|
f"총손익 {s['total_pnl']:+.1f}% | 손익비 {s['rr']:.2f})")
|
||||||
|
|
||||||
|
# ── 핵심 분석: 2% 스탑이 노이즈에 걸리는 비율 ───────────
|
||||||
|
print()
|
||||||
|
print(" 진단: 스탑 발동 후 실제 추가 하락 vs 반등 분석")
|
||||||
|
print(" (트레일링 스탑이 걸린 시점 기준 이후 4h 추적)")
|
||||||
|
|
||||||
|
for stop_target in [2.0, 3.0, 4.0]:
|
||||||
|
if stop_target not in all_stats:
|
||||||
|
continue
|
||||||
|
trades_checked = 0
|
||||||
|
recoveries = 0
|
||||||
|
for df in datasets.values():
|
||||||
|
tlist = simulate(df, stop_target)
|
||||||
|
closes = df["close"].values
|
||||||
|
# 스탑 발동 건 중 이후 4h 안에 스탑가격 이상으로 회복한 비율
|
||||||
|
# (여기서는 근사: 스탑 발동 후 trail stop 기준점인 peak * (1 - stop) 대비)
|
||||||
|
# 간단히: trail 발동 후 다음 4개 캔들이 매수가 대비 양수인지 확인
|
||||||
|
# simulate의 trade에는 exit price가 없으므로, 전체 흐름에서 재확인 필요
|
||||||
|
# 여기서는 avg_pnl과 avg_win으로 대신 설명
|
||||||
|
s = all_stats[stop_target]
|
||||||
|
# 스탑 발동 건 중 손실 비율
|
||||||
|
trail_loss_approx = s["n"] * (s["trail_pct"]/100) * (1 - s["wr"]/100)
|
||||||
|
print(f" 스탑 {stop_target:.1f}%: trail 발동 {s['trail_pct']:.0f}% | "
|
||||||
|
f"time 발동 {s['time_pct']:.0f}% | "
|
||||||
|
f"avg손실 {s['avg_loss']:+.3f}% | avg이익 {s['avg_win']:+.3f}%")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
208
tests/tp5_backtest.py
Normal file
208
tests/tp5_backtest.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""5% 익절 전략 백테스트 (손절 없음)
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 진입: 직전 포지션 청산 다음 캔들 종가 매수
|
||||||
|
- 청산: 고가 기준 +5% 달성 시 TP 가격으로 매도
|
||||||
|
- 손절: 없음 — 손실 중 무조건 보유
|
||||||
|
- 자본: 1,000,000 KRW (복리 없음 / 고정)
|
||||||
|
- 캔들: 1h
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- 종목별: 거래 수, TP 성공, 승률, 총 손익, 평균 보유시간, 최대 보유시간
|
||||||
|
- 현재 미결 포지션 현황
|
||||||
|
- 전략 한계 분석
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ─── 파라미터 ──────────────────────────────────────────────────
|
||||||
|
TICKERS = [
|
||||||
|
"KRW-WET", "KRW-FLOW", "KRW-BIO",
|
||||||
|
"KRW-CYBER", "KRW-JTO", "KRW-AERO",
|
||||||
|
"KRW-LAYER", "KRW-DKA",
|
||||||
|
]
|
||||||
|
TP_PCT = 0.05 # 익절 목표 5%
|
||||||
|
CAPITAL = 1_000_000 # 거래당 고정 자본 (복리 없음)
|
||||||
|
INTERVAL = "minute60"
|
||||||
|
COUNT = 1440 # 60일치 1h 캔들
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 데이터 클래스 ──────────────────────────────────────────────
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
entry_time: datetime
|
||||||
|
exit_time: datetime
|
||||||
|
entry_px: float
|
||||||
|
exit_px: float
|
||||||
|
pnl: float
|
||||||
|
holding_h: int
|
||||||
|
status: str # TP | HOLDING_LOSS | HOLDING_PROFIT
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 백테스트 ──────────────────────────────────────────────────
|
||||||
|
def backtest_ticker(ticker: str) -> Optional[dict]:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval=INTERVAL, count=COUNT)
|
||||||
|
if df is None or len(df) < 20:
|
||||||
|
return None
|
||||||
|
|
||||||
|
trades: list[Trade] = []
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(df) - 1:
|
||||||
|
# 매수: 현재 캔들 종가
|
||||||
|
entry_px = float(df["close"].iloc[i])
|
||||||
|
entry_time = df.index[i]
|
||||||
|
tp_px = entry_px * (1 + TP_PCT)
|
||||||
|
|
||||||
|
hit = False
|
||||||
|
for j in range(i + 1, len(df)):
|
||||||
|
if float(df["high"].iloc[j]) >= tp_px:
|
||||||
|
trades.append(Trade(
|
||||||
|
entry_time = entry_time,
|
||||||
|
exit_time = df.index[j],
|
||||||
|
entry_px = entry_px,
|
||||||
|
exit_px = tp_px,
|
||||||
|
pnl = CAPITAL * TP_PCT,
|
||||||
|
holding_h = j - i,
|
||||||
|
status = "TP",
|
||||||
|
))
|
||||||
|
i = j # 다음 진입은 j 캔들 종가
|
||||||
|
hit = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not hit:
|
||||||
|
# 데이터 끝까지 TP 미달성
|
||||||
|
last_px = float(df["close"].iloc[-1])
|
||||||
|
pnl = CAPITAL * (last_px - entry_px) / entry_px
|
||||||
|
status = "HOLDING_PROFIT" if pnl >= 0 else "HOLDING_LOSS"
|
||||||
|
trades.append(Trade(
|
||||||
|
entry_time = entry_time,
|
||||||
|
exit_time = df.index[-1],
|
||||||
|
entry_px = entry_px,
|
||||||
|
exit_px = last_px,
|
||||||
|
pnl = pnl,
|
||||||
|
holding_h = len(df) - i - 1,
|
||||||
|
status = status,
|
||||||
|
))
|
||||||
|
break
|
||||||
|
|
||||||
|
closed = [t for t in trades if t.status == "TP"]
|
||||||
|
holding = [t for t in trades if t.status != "TP"]
|
||||||
|
|
||||||
|
total_pnl = sum(t.pnl for t in trades)
|
||||||
|
realized_pnl = sum(t.pnl for t in closed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"n_total": len(trades),
|
||||||
|
"n_tp": len(closed),
|
||||||
|
"win_rate": len(closed) / len(trades) * 100 if trades else 0,
|
||||||
|
"realized_pnl": realized_pnl,
|
||||||
|
"total_pnl": total_pnl,
|
||||||
|
"avg_h": sum(t.holding_h for t in closed) / len(closed) if closed else 0,
|
||||||
|
"max_h": max(t.holding_h for t in trades) if trades else 0,
|
||||||
|
"holding": holding,
|
||||||
|
"trades": trades,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 메인 ──────────────────────────────────────────────────────
|
||||||
|
def main() -> None:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 5% 익절 전략 백테스트 (손절 없음)")
|
||||||
|
print(f" 기간: 최근 {COUNT}h ({COUNT//24}일) | 자본: {CAPITAL:,}원 | TP: {TP_PCT*100:.0f}%")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
all_results = []
|
||||||
|
for ticker in TICKERS:
|
||||||
|
r = backtest_ticker(ticker)
|
||||||
|
time.sleep(0.1)
|
||||||
|
if r:
|
||||||
|
all_results.append(r)
|
||||||
|
|
||||||
|
# ─── 종목별 출력 ───────────────────────────────────────────
|
||||||
|
print(f"\n{'종목':<12} {'거래':<5} {'TP':>5} {'승률':>7} {'실현손익':>12} "
|
||||||
|
f"{'미결손익':>12} {'평균보유':>8} {'최장보유':>8}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
total_realized = 0
|
||||||
|
total_open_pnl = 0
|
||||||
|
|
||||||
|
for r in all_results:
|
||||||
|
h_pnl = sum(t.pnl for t in r["holding"])
|
||||||
|
hold_str = ""
|
||||||
|
if r["holding"]:
|
||||||
|
h = r["holding"][0]
|
||||||
|
mark = "⚠️ " if h.status == "HOLDING_LOSS" else "📈"
|
||||||
|
hold_str = f" {mark}{h.pnl:+,.0f}원 ({h.holding_h}h 보유중)"
|
||||||
|
|
||||||
|
print(f"{r['ticker']:<12} {r['n_total']:<5} {r['n_tp']:>5} "
|
||||||
|
f"{r['win_rate']:>6.1f}% {r['realized_pnl']:>+12,.0f} "
|
||||||
|
f"{h_pnl:>+12,.0f} {r['avg_h']:>7.1f}h {r['max_h']:>7}h"
|
||||||
|
+ hold_str)
|
||||||
|
|
||||||
|
total_realized += r["realized_pnl"]
|
||||||
|
total_open_pnl += h_pnl
|
||||||
|
|
||||||
|
print("-" * 80)
|
||||||
|
print(f"{'합계':<12} {'':5} {'':>5} {'':>7} {total_realized:>+12,.0f} "
|
||||||
|
f"{total_open_pnl:>+12,.0f}")
|
||||||
|
|
||||||
|
# ─── 미결 포지션 상세 ──────────────────────────────────────
|
||||||
|
open_trades = [(r["ticker"], h) for r in all_results for h in r["holding"]]
|
||||||
|
if open_trades:
|
||||||
|
print("\n▶ 현재 미결 포지션")
|
||||||
|
print(f" {'종목':<12} {'진입시각':<20} {'진입가':>12} {'현재가':>12} {'수익률':>8} {'보유시간':>8}")
|
||||||
|
for ticker, h in open_trades:
|
||||||
|
chg = (h.exit_px - h.entry_px) / h.entry_px * 100
|
||||||
|
print(f" {ticker:<12} {str(h.entry_time)[:16]:<20} "
|
||||||
|
f"{h.entry_px:>12,.2f} {h.exit_px:>12,.2f} "
|
||||||
|
f"{chg:>+7.2f}% {h.holding_h:>7}h")
|
||||||
|
|
||||||
|
# ─── 최장 잠김 분석 ────────────────────────────────────────
|
||||||
|
all_trades_flat = [(r["ticker"], t) for r in all_results for t in r["trades"]]
|
||||||
|
if all_trades_flat:
|
||||||
|
worst = max(all_trades_flat, key=lambda x: x[1].holding_h)
|
||||||
|
print(f"\n▶ 최장 자본 잠김")
|
||||||
|
print(f" {worst[0]}: {worst[1].holding_h}h ({worst[1].holding_h/24:.1f}일) "
|
||||||
|
f"진입 {str(worst[1].entry_time)[:16]} → "
|
||||||
|
f"{'TP 달성' if worst[1].status == 'TP' else '미결'}")
|
||||||
|
|
||||||
|
# ─── 전략 평가 ─────────────────────────────────────────────
|
||||||
|
if all_results:
|
||||||
|
total_trades = sum(r["n_total"] for r in all_results)
|
||||||
|
total_tp = sum(r["n_tp"] for r in all_results)
|
||||||
|
avg_hold_all = [r["avg_h"] for r in all_results if r["avg_h"] > 0]
|
||||||
|
still_holding = sum(1 for r in all_results if r["holding"])
|
||||||
|
|
||||||
|
print("\n▶ 종합 평가")
|
||||||
|
print(f" 전체 거래: {total_trades}건 | TP 성공: {total_tp}건 | "
|
||||||
|
f"승률: {total_tp/total_trades*100:.1f}%")
|
||||||
|
if avg_hold_all:
|
||||||
|
print(f" TP까지 평균 보유: {sum(avg_hold_all)/len(avg_hold_all):.1f}h "
|
||||||
|
f"({sum(avg_hold_all)/len(avg_hold_all)/24:.1f}일)")
|
||||||
|
print(f" 현재 미결 포지션: {still_holding}개 종목")
|
||||||
|
print(f" 총 실현 손익: {total_realized:+,.0f}원")
|
||||||
|
print(f" 총 평가 손익: {total_realized + total_open_pnl:+,.0f}원")
|
||||||
|
|
||||||
|
print("\n▶ 전략 한계")
|
||||||
|
if still_holding > 0:
|
||||||
|
print(f" ⚠️ {still_holding}개 종목이 TP 미달성 상태로 자본이 잠겨있습니다.")
|
||||||
|
print(" ⚠️ 손절 없음 → 하락장에서 자본이 무한정 묶일 수 있습니다.")
|
||||||
|
print(" ⚠️ 기회비용: 잠긴 자본으로 다른 거래 불가.")
|
||||||
|
print(" 💡 개선안: 최대 보유기간(시간) 제한 또는 % 손절 추가 권장.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
진입/청산: 10분봉 단위로 체크 (실제 시스템의 15초 폴링 근사)
|
진입/청산: 10분봉 단위로 체크 (실제 시스템의 15초 폴링 근사)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import pickle
|
import pickle
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
C (단순): 거래량 급증만 (베이스라인, 노이즈 확인용)
|
C (단순): 거래량 급증만 (베이스라인, 노이즈 확인용)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import time
|
import time
|
||||||
@@ -6,6 +6,9 @@ WF_WINDOW 크기(2, 3, 5)에 따라 차단/허용 여부를 시뮬레이션.
|
|||||||
허용된 거래 → 실제 P&L 반영
|
허용된 거래 → 실제 P&L 반영
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -12,6 +12,9 @@ Phase 2: 마지막 실거래 이후 price_history 기반 신호로 추가 거래
|
|||||||
D: WF 없음
|
D: WF 없음
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os as _os, sys as _sys
|
||||||
|
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||||
|
|
||||||
import os, time
|
import os, time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
Reference in New Issue
Block a user