1. daemon/runner.py: skip scan entirely in bear regime
- calls get_regime() at start of each scan loop
- logs bear block with score before sleeping
2. core/strategy.py: trend-continuation entry filter
- check_trend_6h(): 6h price trend >= 1% (rejects flash spikes)
- 15-min confirmation watchlist (_watchlist dict)
- should_buy() adds watchlist to existing 12h+regime+momentum logic
- CONFIRM_SECONDS env var (default 900 = 15min)
- TREND_6H_MIN_PCT env var (default 1.0%)
3. backtest.py: partial take-profit scenario comparison (--tp-cmp)
- simulate(): partial_tp_pct / partial_tp_ratio params
- blended pnl = ratio * partial_pnl + (1-ratio) * remaining_pnl
- main_tp_cmp(): 3 scenarios A/B/C (none / +5% 50% / +3% 50%)
- result: partial TP reduces cumulative return (-56% → -63%)
big winners carry the strategy; trimming them hurts expected value
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1801 lines
69 KiB
Python
1801 lines
69 KiB
Python
"""Strategy C 백테스트 — 상위 N종목 × 최근 3개월 시뮬레이션.
|
||
|
||
실행:
|
||
python backtest.py # 손절선 비교 (1% vs 1.5% vs 2%), 15분봉
|
||
python backtest.py --10m # 10분봉, 손절선 비교
|
||
python backtest.py --15m # 15분봉, 손절선 비교
|
||
python backtest.py --20m # 20분봉, 손절선 비교
|
||
python backtest.py --interval-cmp # 봉주기 비교 (10m vs 15m vs 20m), 손절 1.5% 고정
|
||
python backtest.py --btc-cmp # BTC 필터 유무 비교 (없음 / 2h하락 / MA20 / 둘다)
|
||
python backtest.py --vol # 변동성 돌파 전략 (K=0.3/0.5/0.7, 일봉 기반)
|
||
python backtest.py --hard-stop-cmp # 하드스탑 비교 (트레일링 1.5% 고정, 하드 1.5/3/5/없음)
|
||
python backtest.py --top50-cmp # 종목수(20vs50) + 거래대금 급증 비교 (2x/3x/4x)
|
||
python backtest.py --trend-cmp # 추세 상한선 비교 (+5/7/10/15% 상한)
|
||
python backtest.py --ticker-cmp # 종목 승률 필터 비교 (SQL 즉시 분석)
|
||
python backtest.py --tp-cmp # 부분 익절 구조 비교 (없음 / +5% 50% / +3% 50%)
|
||
|
||
캐시: Oracle ADB backtest_ohlcv / backtest_daily / backtest_trades 테이블
|
||
시뮬레이션 결과는 backtest_trades에 파라미터 해시로 저장 — 동일 파라미터 재실행 시 즉시 반환
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
from contextlib import contextmanager
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
|
||
import hashlib
|
||
import json
|
||
import oracledb
|
||
import pandas as pd
|
||
import numpy as np
|
||
import pyupbit
|
||
import requests
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
MIN_CANDLES = 100 # DB에 이 수 이상의 봉이 있으면 API 재호출 없이 DB 사용
|
||
|
||
# ── 파라미터 ────────────────────────────────────────────────────────────────
|
||
TOP_N = 20
|
||
MONTHS = 3
|
||
MA_PERIOD = 20
|
||
VOLUME_MULT = 2.0
|
||
STOP_LOSS = 0.05 # 비교 대상 — run_scenario() 에서 오버라이드
|
||
TIME_STOP_H = 8
|
||
TIME_STOP_GAIN = 0.03
|
||
PER_POSITION = 3_333_333
|
||
FEE = 0.0005
|
||
|
||
CANDLES_PER_PAGE = 200
|
||
|
||
EXCLUDE = {"KRW-USDT", "KRW-USDC", "KRW-DAI", "KRW-BUSD", "KRW-USD1", "KRW-USDE"}
|
||
|
||
# ── 봉주기별 설정 ─────────────────────────────────────────────────────────
|
||
INTERVAL_CONFIG = {
|
||
"minute10": {"label": "10분봉", "trend": 0.007, "max_pages": 70},
|
||
"minute15": {"label": "15분봉", "trend": 0.010, "max_pages": 50},
|
||
"minute20": {"label": "20분봉", "trend": 0.013, "max_pages": 35},
|
||
"minute60": {"label": "1시간봉", "trend": 0.030, "max_pages": 30},
|
||
}
|
||
|
||
# ── 실행 모드 결정 ────────────────────────────────────────────────────────
|
||
COMPARE_INTERVALS = "--interval-cmp" in sys.argv # 봉주기 비교 모드
|
||
COMPARE_BTC = "--btc-cmp" in sys.argv # BTC 필터 비교 모드
|
||
COMPARE_VOL = "--vol" in sys.argv # 변동성 돌파 비교 모드
|
||
COMPARE_HARDSTOP = "--hard-stop-cmp" in sys.argv # 하드 스탑 비교 모드
|
||
COMPARE_TOP50 = "--top50-cmp" in sys.argv # 종목수 + 거래대금 급증 비교 모드
|
||
COMPARE_TREND = "--trend-cmp" in sys.argv # 추세 상한선 비교 모드
|
||
COMPARE_TICKER = "--ticker-cmp" in sys.argv # 종목 승률 필터 비교 모드
|
||
COMPARE_WF = "--walkforward-cmp" in sys.argv # walk-forward 필터 비교 모드
|
||
COMPARE_TIMESTOP = "--timestop-cmp" in sys.argv # 타임스탑 조건 비교 모드
|
||
COMPARE_COMBO = "--combo-cmp" in sys.argv # 추세+거래량 조합 비교 모드
|
||
COMPARE_TP = "--tp-cmp" in sys.argv # 부분 익절 구조 비교 모드
|
||
|
||
if "--10m" in sys.argv:
|
||
DEFAULT_INTERVAL = "minute10"
|
||
elif "--15m" in sys.argv:
|
||
DEFAULT_INTERVAL = "minute15"
|
||
elif "--20m" in sys.argv:
|
||
DEFAULT_INTERVAL = "minute20"
|
||
else:
|
||
DEFAULT_INTERVAL = "minute15" # 기본값: 15분봉
|
||
|
||
|
||
# ── 1. 데이터 수집 ────────────────────────────────────────────────────────────
|
||
|
||
def get_top_tickers(n: int = TOP_N) -> list[str]:
|
||
"""24시간 거래대금 기준 상위 N개 KRW 종목 (6개월 이상 데이터 있는 것만)."""
|
||
all_tickers = pyupbit.get_tickers(fiat="KRW")
|
||
resp = requests.get(
|
||
"https://api.upbit.com/v1/ticker",
|
||
params={"markets": ",".join(all_tickers[:100])},
|
||
timeout=10,
|
||
)
|
||
data = [d for d in resp.json() if d["market"] not in EXCLUDE]
|
||
data.sort(key=lambda x: x.get("acc_trade_price_24h", 0), reverse=True)
|
||
|
||
result = []
|
||
for d in data:
|
||
if len(result) >= n:
|
||
break
|
||
ticker = d["market"]
|
||
daily = pyupbit.get_ohlcv(ticker, interval="day", count=200)
|
||
if daily is None or len(daily) < 180:
|
||
print(f" {ticker} 스킵 (데이터 {0 if daily is None else len(daily)}일)")
|
||
time.sleep(0.1)
|
||
continue
|
||
result.append(ticker)
|
||
time.sleep(0.1)
|
||
return result
|
||
|
||
|
||
def fetch_ohlcv(ticker: str, months: int = MONTHS,
|
||
interval: str = DEFAULT_INTERVAL,
|
||
max_pages: int = 50) -> pd.DataFrame:
|
||
"""N개월치 OHLCV 수집 (200개씩 역방향 페이징)."""
|
||
start = datetime.now() - timedelta(days=30 * months)
|
||
frames: list[pd.DataFrame] = []
|
||
to = datetime.now()
|
||
|
||
for _ in range(max_pages):
|
||
df = pyupbit.get_ohlcv(ticker, interval=interval, count=CANDLES_PER_PAGE, to=to)
|
||
if df is None or df.empty:
|
||
break
|
||
frames.append(df)
|
||
if df.index[0] <= start:
|
||
break
|
||
to = df.index[0] - timedelta(seconds=1)
|
||
time.sleep(0.12)
|
||
|
||
if not frames:
|
||
return pd.DataFrame()
|
||
|
||
result = pd.concat(frames).sort_index()
|
||
result = result[~result.index.duplicated(keep="last")]
|
||
return result[result.index >= start].copy()
|
||
|
||
|
||
def fetch_daily(ticker: str) -> pd.DataFrame:
|
||
"""일봉 200개 수집 (MA 계산용)."""
|
||
df = pyupbit.get_ohlcv(ticker, interval="day", count=200)
|
||
return df if df is not None else pd.DataFrame()
|
||
|
||
|
||
# ── Oracle DB 캐시 ────────────────────────────────────────────────────────────
|
||
|
||
_db_pool: Optional[oracledb.ConnectionPool] = None
|
||
|
||
|
||
def _get_pool() -> oracledb.ConnectionPool:
|
||
global _db_pool
|
||
if _db_pool is None:
|
||
kwargs: dict = dict(
|
||
user=os.environ["ORACLE_USER"],
|
||
password=os.environ["ORACLE_PASSWORD"],
|
||
dsn=os.environ["ORACLE_DSN"],
|
||
min=1, max=3, increment=1,
|
||
)
|
||
wallet = os.environ.get("ORACLE_WALLET")
|
||
if wallet:
|
||
kwargs["config_dir"] = wallet
|
||
_db_pool = oracledb.create_pool(**kwargs)
|
||
return _db_pool
|
||
|
||
|
||
@contextmanager
|
||
def _conn():
|
||
pool = _get_pool()
|
||
conn = pool.acquire()
|
||
try:
|
||
yield conn
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
pool.release(conn)
|
||
|
||
|
||
def ensure_tables() -> None:
|
||
"""백테스트 캐시 테이블 없으면 생성."""
|
||
ddl_ohlcv = """
|
||
CREATE TABLE backtest_ohlcv (
|
||
ticker VARCHAR2(20) NOT NULL,
|
||
interval_cd VARCHAR2(20) NOT NULL,
|
||
ts TIMESTAMP NOT NULL,
|
||
open_p NUMBER(20,8),
|
||
high_p NUMBER(20,8),
|
||
low_p NUMBER(20,8),
|
||
close_p NUMBER(20,8),
|
||
volume_p NUMBER(30,8),
|
||
fetched_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||
CONSTRAINT pk_bt_ohlcv PRIMARY KEY (ticker, interval_cd, ts)
|
||
)
|
||
"""
|
||
ddl_daily = """
|
||
CREATE TABLE backtest_daily (
|
||
ticker VARCHAR2(20) NOT NULL,
|
||
ts DATE NOT NULL,
|
||
open_p NUMBER(20,8),
|
||
high_p NUMBER(20,8),
|
||
low_p NUMBER(20,8),
|
||
close_p NUMBER(20,8),
|
||
volume_p NUMBER(30,8),
|
||
fetched_at TIMESTAMP DEFAULT SYSTIMESTAMP,
|
||
CONSTRAINT pk_bt_daily PRIMARY KEY (ticker, ts)
|
||
)
|
||
"""
|
||
ddl_trades = """
|
||
CREATE TABLE backtest_trades (
|
||
id VARCHAR2(36) DEFAULT SYS_GUID() PRIMARY KEY,
|
||
params_hash VARCHAR2(32) NOT NULL,
|
||
ticker VARCHAR2(20) NOT NULL,
|
||
interval_cd VARCHAR2(20) NOT NULL,
|
||
stop_loss NUMBER(8,5) NOT NULL,
|
||
trend_min NUMBER(8,5) NOT NULL,
|
||
trend_max NUMBER(8,5) NOT NULL,
|
||
volume_mult NUMBER(6,2) NOT NULL,
|
||
entry_ts TIMESTAMP NOT NULL,
|
||
exit_ts TIMESTAMP NOT NULL,
|
||
buy_price NUMBER(20,8) NOT NULL,
|
||
sell_price NUMBER(20,8) NOT NULL,
|
||
pnl_pct NUMBER(10,4) NOT NULL,
|
||
elapsed_h NUMBER(8,2) NOT NULL,
|
||
reason VARCHAR2(30) NOT NULL,
|
||
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
|
||
)
|
||
"""
|
||
ddl_trades_idx = """
|
||
CREATE INDEX idx_bt_trades_hash ON backtest_trades (params_hash)
|
||
"""
|
||
with _conn() as conn:
|
||
for ddl in (ddl_ohlcv, ddl_daily, ddl_trades):
|
||
try:
|
||
conn.cursor().execute(ddl)
|
||
except oracledb.DatabaseError as e:
|
||
if e.args[0].code != 955: # ORA-00955: 이미 존재
|
||
raise
|
||
try:
|
||
conn.cursor().execute(ddl_trades_idx)
|
||
except oracledb.DatabaseError as e:
|
||
if e.args[0].code not in (955, 1408): # 이미 존재
|
||
raise
|
||
|
||
|
||
def _is_fresh(ticker: str, interval_cd: str) -> bool:
|
||
"""DB에 해당 ticker+interval 데이터가 3개월치 충분히 있는지 확인.
|
||
|
||
한 번 수집된 데이터는 API 재호출 없이 DB에서만 사용한다.
|
||
"""
|
||
start = datetime.now() - timedelta(days=30 * MONTHS)
|
||
sql = """
|
||
SELECT COUNT(*) FROM backtest_ohlcv
|
||
WHERE ticker = :t AND interval_cd = :i AND ts >= :s
|
||
"""
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, {"t": ticker, "i": interval_cd, "s": start})
|
||
row = cur.fetchone()
|
||
return bool(row and row[0] >= MIN_CANDLES)
|
||
|
||
|
||
def _is_daily_fresh(ticker: str) -> bool:
|
||
"""일봉 캐시 확인 (데이터 충분히 있으면 API 재호출 안 함)."""
|
||
sql = "SELECT COUNT(*) FROM backtest_daily WHERE ticker = :t"
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, {"t": ticker})
|
||
row = cur.fetchone()
|
||
return bool(row and row[0] >= 30)
|
||
|
||
|
||
def load_ohlcv_from_db(ticker: str, interval_cd: str, months: int) -> pd.DataFrame:
|
||
"""DB에서 OHLCV 로드."""
|
||
start = datetime.now() - timedelta(days=30 * months)
|
||
sql = """
|
||
SELECT ts, open_p, high_p, low_p, close_p, volume_p
|
||
FROM backtest_ohlcv
|
||
WHERE ticker = :t AND interval_cd = :i AND ts >= :s
|
||
ORDER BY ts
|
||
"""
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, {"t": ticker, "i": interval_cd, "s": start})
|
||
rows = cur.fetchall()
|
||
if not rows:
|
||
return pd.DataFrame()
|
||
idx = pd.DatetimeIndex([r[0] for r in rows])
|
||
df = pd.DataFrame(
|
||
[(float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])) for r in rows],
|
||
columns=["open", "high", "low", "close", "volume"],
|
||
index=idx,
|
||
)
|
||
df.index.name = None
|
||
return df
|
||
|
||
|
||
def load_daily_from_db(ticker: str) -> pd.DataFrame:
|
||
"""DB에서 일봉 로드."""
|
||
sql = """
|
||
SELECT ts, open_p, high_p, low_p, close_p, volume_p
|
||
FROM backtest_daily
|
||
WHERE ticker = :t
|
||
ORDER BY ts
|
||
"""
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, {"t": ticker})
|
||
rows = cur.fetchall()
|
||
if not rows:
|
||
return pd.DataFrame()
|
||
idx = pd.DatetimeIndex([r[0] for r in rows])
|
||
df = pd.DataFrame(
|
||
[(float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])) for r in rows],
|
||
columns=["open", "high", "low", "close", "volume"],
|
||
index=idx,
|
||
)
|
||
df.index.name = None
|
||
return df
|
||
|
||
|
||
def save_ohlcv_to_db(ticker: str, interval_cd: str, df: pd.DataFrame) -> None:
|
||
"""OHLCV DataFrame을 DB에 저장 (중복 키 무시 — 기존 데이터 유지)."""
|
||
rows = [
|
||
(ticker, interval_cd, ts.to_pydatetime(),
|
||
float(row["open"]), float(row["high"]),
|
||
float(row["low"]), float(row["close"]), float(row["volume"]))
|
||
for ts, row in df.iterrows()
|
||
]
|
||
sql_ins = """
|
||
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)
|
||
"""
|
||
with _conn() as conn:
|
||
conn.cursor().executemany(sql_ins, rows, batcherrors=True)
|
||
|
||
|
||
def save_daily_to_db(ticker: str, df: pd.DataFrame) -> None:
|
||
"""일봉 DataFrame을 DB에 저장 (중복 키 무시)."""
|
||
rows = [
|
||
(ticker, ts.to_pydatetime().date(),
|
||
float(row["open"]), float(row["high"]),
|
||
float(row["low"]), float(row["close"]), float(row["volume"]))
|
||
for ts, row in df.iterrows()
|
||
]
|
||
sql_ins = """
|
||
INSERT INTO backtest_daily
|
||
(ticker, ts, open_p, high_p, low_p, close_p, volume_p)
|
||
VALUES (:1, :2, :3, :4, :5, :6, :7)
|
||
"""
|
||
with _conn() as conn:
|
||
conn.cursor().executemany(sql_ins, rows, batcherrors=True)
|
||
|
||
|
||
def fetch_all_data(tickers: list[str], interval: str) -> dict:
|
||
"""모든 종목의 OHLCV + daily 데이터를 수집.
|
||
|
||
DB 캐시(24h 이내)가 있으면 DB에서 로드, 없으면 API → DB 저장.
|
||
|
||
Returns:
|
||
{ticker: {"ohlcv": DataFrame, "daily": DataFrame}}
|
||
"""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
max_pages = cfg["max_pages"]
|
||
ensure_tables()
|
||
result = {}
|
||
for i, ticker in enumerate(tickers, 1):
|
||
# ── OHLCV ──────────────────────────────────────────────────────────
|
||
if _is_fresh(ticker, interval):
|
||
ohlcv = load_ohlcv_from_db(ticker, interval, MONTHS)
|
||
src_ohlcv = "DB캐시"
|
||
else:
|
||
ohlcv = fetch_ohlcv(ticker, MONTHS, interval, max_pages)
|
||
if not ohlcv.empty:
|
||
save_ohlcv_to_db(ticker, interval, ohlcv)
|
||
src_ohlcv = "API→DB저장"
|
||
time.sleep(0.3)
|
||
|
||
# ── Daily ───────────────────────────────────────────────────────────
|
||
if _is_daily_fresh(ticker):
|
||
daily = load_daily_from_db(ticker)
|
||
src_daily = "DB캐시"
|
||
else:
|
||
daily = fetch_daily(ticker)
|
||
if not daily.empty:
|
||
save_daily_to_db(ticker, daily)
|
||
src_daily = "API→DB저장"
|
||
time.sleep(0.1)
|
||
|
||
if ohlcv.empty or daily.empty:
|
||
print(f" [{i}/{len(tickers)}] {ticker} → 데이터 없음, 스킵")
|
||
continue
|
||
|
||
print(f" [{i}/{len(tickers)}] {ticker} "
|
||
f"(OHLCV:{src_ohlcv} | Daily:{src_daily} | {len(ohlcv)}봉)")
|
||
result[ticker] = {"ohlcv": ohlcv, "daily": daily}
|
||
return result
|
||
|
||
|
||
# ── 시뮬레이션 결과 DB 캐시 ──────────────────────────────────────────────────
|
||
|
||
def _params_hash(params: dict) -> str:
|
||
"""파라미터 딕셔너리 → SHA256 hex (32자)."""
|
||
s = json.dumps(params, sort_keys=True, default=str)
|
||
return hashlib.sha256(s.encode()).hexdigest()[:32]
|
||
|
||
|
||
def _trades_cached(params_hash: str) -> bool:
|
||
sql = "SELECT COUNT(*) FROM backtest_trades WHERE params_hash = :h"
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, {"h": params_hash})
|
||
return bool(cur.fetchone()[0] > 0)
|
||
|
||
|
||
def save_trades_to_db(trades: list[dict], params: dict) -> None:
|
||
"""시뮬레이션 결과 bulk insert."""
|
||
if not trades:
|
||
return
|
||
ph = _params_hash(params)
|
||
sql = """
|
||
INSERT INTO backtest_trades
|
||
(params_hash, ticker, interval_cd,
|
||
stop_loss, trend_min, trend_max, volume_mult,
|
||
entry_ts, exit_ts, buy_price, sell_price,
|
||
pnl_pct, elapsed_h, reason)
|
||
VALUES
|
||
(:ph, :ticker, :icd,
|
||
:sl, :tmin, :tmax, :vmult,
|
||
:entry, :exit, :bp, :sp,
|
||
:pnl, :el, :reason)
|
||
"""
|
||
rows = [
|
||
{
|
||
"ph": ph,
|
||
"ticker": t["ticker"],
|
||
"icd": params["interval_cd"],
|
||
"sl": params["stop_loss"],
|
||
"tmin": params["trend_min_gain"],
|
||
"tmax": params["trend_max_gain"],
|
||
"vmult": params["volume_mult"],
|
||
"entry": t["entry"],
|
||
"exit": t["exit"],
|
||
"bp": t["buy_price"],
|
||
"sp": t["sell_price"],
|
||
"pnl": t["pnl_pct"],
|
||
"el": t["elapsed_h"],
|
||
"reason": t["reason"],
|
||
}
|
||
for t in trades
|
||
]
|
||
with _conn() as conn:
|
||
conn.cursor().executemany(sql, rows)
|
||
print(f" → DB 저장: {len(rows)}건 (hash={ph[:8]}...)")
|
||
|
||
|
||
def load_trades_from_db(params: dict) -> Optional[list[dict]]:
|
||
"""캐시된 결과 반환. 없으면 None."""
|
||
ph = _params_hash(params)
|
||
sql = """
|
||
SELECT ticker, entry_ts, exit_ts, buy_price, sell_price,
|
||
pnl_pct, elapsed_h, reason
|
||
FROM backtest_trades
|
||
WHERE params_hash = :h
|
||
ORDER BY entry_ts
|
||
"""
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, {"h": ph})
|
||
rows = cur.fetchall()
|
||
if not rows:
|
||
return None
|
||
return [
|
||
{
|
||
"ticker": r[0],
|
||
"entry": r[1],
|
||
"exit": r[2],
|
||
"buy_price": float(r[3]),
|
||
"sell_price": float(r[4]),
|
||
"pnl_pct": float(r[5]),
|
||
"elapsed_h": float(r[6]),
|
||
"reason": r[7],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def build_daily_features(daily: pd.DataFrame) -> pd.DataFrame:
|
||
"""MA20, 평균 거래량 계산 (look-ahead 방지: shift(1) 사용)."""
|
||
d = daily.copy()
|
||
d["ma20"] = d["close"].rolling(MA_PERIOD).mean().shift(1)
|
||
d["vol_avg"] = d["volume"].rolling(MA_PERIOD).mean().shift(1)
|
||
d["prev_vol"] = d["volume"].shift(1)
|
||
d.index = pd.Index([dt.date() for dt in d.index])
|
||
return d
|
||
|
||
|
||
def fetch_btc_data(interval: str) -> dict:
|
||
"""BTC OHLCV + 일봉 데이터 로드 (DB 캐시 우선).
|
||
|
||
Returns:
|
||
{"ohlcv": DataFrame, "daily": DataFrame}
|
||
"""
|
||
ticker = "KRW-BTC"
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
ensure_tables()
|
||
|
||
if _is_fresh(ticker, interval):
|
||
ohlcv = load_ohlcv_from_db(ticker, interval, MONTHS)
|
||
print(f" BTC OHLCV: DB캐시 ({len(ohlcv)}봉)")
|
||
else:
|
||
ohlcv = fetch_ohlcv(ticker, MONTHS, interval, cfg["max_pages"])
|
||
if not ohlcv.empty:
|
||
save_ohlcv_to_db(ticker, interval, ohlcv)
|
||
print(f" BTC OHLCV: API→DB저장 ({len(ohlcv)}봉)")
|
||
time.sleep(0.3)
|
||
|
||
if _is_daily_fresh(ticker):
|
||
daily = load_daily_from_db(ticker)
|
||
print(f" BTC Daily: DB캐시 ({len(daily)}일)")
|
||
else:
|
||
daily = fetch_daily(ticker)
|
||
if not daily.empty:
|
||
save_daily_to_db(ticker, daily)
|
||
print(f" BTC Daily: API→DB저장 ({len(daily)}일)")
|
||
|
||
return {"ohlcv": ohlcv, "daily": daily}
|
||
|
||
|
||
def build_btc_context(btc_data: dict, lookback_candles: int = 8) -> dict:
|
||
"""BTC 필터용 시계열 인덱스 준비.
|
||
|
||
Args:
|
||
lookback_candles: 단기 하락 판단 기준봉 수 (15분봉 기준 8봉 = 2시간)
|
||
|
||
Returns:
|
||
{
|
||
"close": Series (timestamp → BTC 종가),
|
||
"2h_change": Series (timestamp → 2h 전 대비 변화율),
|
||
"daily_ma20": dict (date → BTC 일봉 MA20),
|
||
}
|
||
"""
|
||
ohlcv = btc_data["ohlcv"]
|
||
daily = btc_data["daily"].copy()
|
||
|
||
btc_close = ohlcv["close"]
|
||
btc_2h_change = btc_close.pct_change(periods=lookback_candles)
|
||
|
||
daily["ma20"] = daily["close"].rolling(20).mean().shift(1)
|
||
daily.index = pd.Index([dt.date() for dt in daily.index])
|
||
btc_daily_ma20 = daily["ma20"].to_dict()
|
||
|
||
return {
|
||
"close": btc_close,
|
||
"2h_change": btc_2h_change,
|
||
"daily_ma20": btc_daily_ma20,
|
||
}
|
||
|
||
|
||
# ── 2. 시뮬레이션 ─────────────────────────────────────────────────────────────
|
||
|
||
def simulate(
|
||
ticker: str,
|
||
hourly: pd.DataFrame,
|
||
daily_feat: pd.DataFrame,
|
||
stop_loss: float,
|
||
trend_min_gain: float,
|
||
trend_max_gain: float = 999.0, # 추세 상한선 (이 % 초과 급등 시 진입 차단)
|
||
reentry_above_sell: bool = False,
|
||
btc_ctx: Optional[dict] = None,
|
||
btc_drop_threshold: float = -0.015,
|
||
btc_use_ma_filter: bool = False,
|
||
hard_stop: Optional[float] = None, # 매수가 기준 하드 스탑 (None=트레일링과 동일)
|
||
volume_mult: float = VOLUME_MULT, # 거래대금 급증 기준 배수
|
||
wf_min_wr: float = 0.0, # walk-forward: 직전 N건 승률 임계값 (0=비활성)
|
||
wf_window: int = 5, # walk-forward: 승률 계산 윈도우 크기
|
||
time_stop_h: float = TIME_STOP_H, # 타임스탑 기준 시간 (0=비활성)
|
||
time_stop_gain: float = TIME_STOP_GAIN, # 타임스탑 최소 수익률
|
||
partial_tp_pct: float = 0.0, # 부분 익절 트리거 수익률 (0=비활성)
|
||
partial_tp_ratio: float = 0.5, # 부분 익절 비율 (기본 50%)
|
||
) -> list[dict]:
|
||
"""단일 종목 전략 시뮬레이션.
|
||
|
||
stop_loss : 트레일링 스탑 (최고가 대비 하락률)
|
||
hard_stop : 하드 스탑 (매수가 대비 하락률). None이면 stop_loss 값 사용.
|
||
volume_mult : 진입 조건 — 전일 거래량 > N일 평균 × volume_mult
|
||
wf_min_wr : walk-forward 필터 — 직전 wf_window건 승률이 이 값 미만이면 진입 차단
|
||
윈도우가 채워지기 전(워밍업)에는 필터 미적용
|
||
time_stop_h : 타임스탑 기준 시간 (0 이하 = 비활성)
|
||
partial_tp_pct : 이 수익률 도달 시 partial_tp_ratio 만큼 익절 (0=비활성)
|
||
partial_tp_ratio: 부분 익절 비율 (0~1). 나머지는 트레일링 스탑으로 유지
|
||
"""
|
||
_hard_stop = hard_stop if hard_stop is not None else stop_loss
|
||
trades: list[dict] = []
|
||
pos: Optional[dict] = None
|
||
last_sell_price: Optional[float] = None
|
||
wf_history: list[bool] = [] # True=수익, False=손실 (직전 wf_window건)
|
||
|
||
closes = hourly["close"].values
|
||
times = hourly.index.to_pydatetime()
|
||
|
||
for i in range(1, len(hourly)):
|
||
current = closes[i]
|
||
t = times[i]
|
||
date = t.date()
|
||
|
||
# ── 포지션 관리 (매도 우선) ──────────────────────────────────────────
|
||
if pos is not None:
|
||
if current > pos["peak"]:
|
||
pos["peak"] = current
|
||
|
||
peak = pos["peak"]
|
||
bp = pos["buy_price"]
|
||
drop_pk = (peak - current) / peak
|
||
drop_buy = (bp - current) / bp
|
||
pnl = (current - bp) / bp
|
||
elapsed = (t - pos["entry"]).total_seconds() / 3600
|
||
|
||
# ── 부분 익절 트리거 ──────────────────────────────────────────────
|
||
if partial_tp_pct > 0 and not pos.get("partial_done") and pnl >= partial_tp_pct:
|
||
pos["partial_done"] = True
|
||
pos["partial_price"] = current
|
||
pos["partial_pnl"] = (
|
||
current * (1 - FEE) - bp * (1 + FEE)
|
||
) / (bp * (1 + FEE)) * 100
|
||
|
||
reason = None
|
||
if drop_pk >= stop_loss:
|
||
reason = "trailing_stop"
|
||
elif _hard_stop < 999 and drop_buy >= _hard_stop:
|
||
reason = "hard_stop"
|
||
elif time_stop_h > 0 and elapsed >= time_stop_h and pnl < time_stop_gain:
|
||
reason = "time_stop"
|
||
|
||
if reason:
|
||
# 나머지 포지션 청산 pnl
|
||
rem_pnl = (
|
||
current * (1 - FEE) - bp * (1 + FEE)
|
||
) / (bp * (1 + FEE)) * 100
|
||
|
||
# 부분 익절이 실행됐으면 가중 평균 pnl 계산
|
||
if partial_tp_pct > 0 and pos.get("partial_done"):
|
||
net_pnl = (
|
||
partial_tp_ratio * pos["partial_pnl"]
|
||
+ (1 - partial_tp_ratio) * rem_pnl
|
||
)
|
||
else:
|
||
net_pnl = rem_pnl
|
||
|
||
trades.append({
|
||
"ticker": ticker,
|
||
"entry": pos["entry"],
|
||
"exit": t,
|
||
"buy_price": round(bp, 4),
|
||
"sell_price": round(current, 4),
|
||
"pnl_pct": round(net_pnl, 3),
|
||
"elapsed_h": round(elapsed, 1),
|
||
"reason": reason,
|
||
})
|
||
wf_history.append(net_pnl > 0) # walk-forward 이력 업데이트
|
||
last_sell_price = current
|
||
pos = None
|
||
continue
|
||
|
||
# ── 매수 신호 ────────────────────────────────────────────────────────
|
||
if pos is None:
|
||
if reentry_above_sell and last_sell_price is not None:
|
||
if current < last_sell_price:
|
||
continue
|
||
|
||
prev_close = closes[i - 1]
|
||
if prev_close <= 0:
|
||
continue
|
||
gain_pct = (current - prev_close) / prev_close
|
||
if gain_pct < trend_min_gain:
|
||
continue
|
||
if gain_pct > trend_max_gain:
|
||
continue
|
||
|
||
# ── Walk-forward 승률 필터 ────────────────────────────────────────
|
||
# 워밍업(window 미달) 동안은 필터 미적용, 이후 직전 N건 승률 체크
|
||
if wf_min_wr > 0 and len(wf_history) >= wf_window:
|
||
recent_wr = sum(wf_history[-wf_window:]) / wf_window
|
||
if recent_wr < wf_min_wr:
|
||
continue
|
||
|
||
if date not in daily_feat.index:
|
||
continue
|
||
dr = daily_feat.loc[date]
|
||
if pd.isna(dr["ma20"]) or pd.isna(dr["vol_avg"]) or dr["vol_avg"] <= 0:
|
||
continue
|
||
if not (current > dr["ma20"] and dr["prev_vol"] > dr["vol_avg"] * volume_mult):
|
||
continue
|
||
|
||
# ── BTC 전체 흐름 필터 ────────────────────────────────────────────
|
||
if btc_ctx is not None:
|
||
# 1) BTC 2시간 단기 하락 필터
|
||
btc_2h_chg = btc_ctx["2h_change"].asof(t)
|
||
if not pd.isna(btc_2h_chg) and btc_2h_chg < btc_drop_threshold:
|
||
continue
|
||
|
||
# 2) BTC 일봉 MA20 필터
|
||
if btc_use_ma_filter:
|
||
btc_ma20 = btc_ctx["daily_ma20"].get(date)
|
||
btc_price = btc_ctx["close"].asof(t)
|
||
if btc_ma20 and not pd.isna(btc_ma20) and btc_price < btc_ma20:
|
||
continue
|
||
|
||
bp = current * (1 + FEE)
|
||
pos = {
|
||
"buy_price": bp,
|
||
"peak": current,
|
||
"entry": t,
|
||
"partial_done": False,
|
||
"partial_price": None,
|
||
"partial_pnl": None,
|
||
}
|
||
|
||
# 기간 종료 시 미청산 포지션 강제 종료
|
||
if pos is not None:
|
||
current = closes[-1]
|
||
elapsed = (times[-1] - pos["entry"]).total_seconds() / 3600
|
||
bp = pos["buy_price"]
|
||
rem_pnl = (
|
||
current * (1 - FEE) - bp * (1 + FEE)
|
||
) / (bp * (1 + FEE)) * 100
|
||
if partial_tp_pct > 0 and pos.get("partial_done"):
|
||
net_pnl = (
|
||
partial_tp_ratio * pos["partial_pnl"]
|
||
+ (1 - partial_tp_ratio) * rem_pnl
|
||
)
|
||
else:
|
||
net_pnl = rem_pnl
|
||
trades.append({
|
||
"ticker": ticker,
|
||
"entry": pos["entry"],
|
||
"exit": times[-1],
|
||
"buy_price": round(bp, 4),
|
||
"sell_price": round(current, 4),
|
||
"pnl_pct": round(net_pnl, 3),
|
||
"elapsed_h": round(elapsed, 1),
|
||
"reason": "open_at_end",
|
||
})
|
||
|
||
return trades
|
||
|
||
|
||
# ── 3. 결과 리포트 ────────────────────────────────────────────────────────────
|
||
|
||
def report(all_trades: list[dict], label: str = "") -> None:
|
||
if not all_trades:
|
||
print(f"\n{label} — 거래 없음")
|
||
return
|
||
|
||
df = pd.DataFrame(all_trades)
|
||
n = len(df)
|
||
wins = int((df["pnl_pct"] > 0).sum())
|
||
losses = n - wins
|
||
wr = wins / n * 100
|
||
avg_pnl = df["pnl_pct"].mean()
|
||
avg_win = df[df["pnl_pct"] > 0]["pnl_pct"].mean() if wins > 0 else 0.0
|
||
avg_loss = df[df["pnl_pct"] < 0]["pnl_pct"].mean() if losses > 0 else 0.0
|
||
rr = abs(avg_win / avg_loss) if avg_loss != 0 else float("inf")
|
||
|
||
capital = float(PER_POSITION)
|
||
peak_cap = capital
|
||
mdd = 0.0
|
||
for pnl in df["pnl_pct"]:
|
||
capital *= 1 + pnl / 100
|
||
if capital > peak_cap:
|
||
peak_cap = capital
|
||
dd = (peak_cap - capital) / peak_cap
|
||
if dd > mdd:
|
||
mdd = dd
|
||
total_return = (capital - PER_POSITION) / PER_POSITION * 100
|
||
|
||
if n > 0:
|
||
span_days = (df["exit"].max() - df["entry"].min()).total_seconds() / 86400
|
||
trades_per_month = n / (span_days / 30) if span_days > 0 else 0
|
||
else:
|
||
trades_per_month = 0
|
||
|
||
bar = "=" * 60
|
||
print(f"\n{bar}")
|
||
print(f" {label} | {MONTHS}개월 | 상위 {TOP_N}종목")
|
||
print(bar)
|
||
print(f" 총 거래수 : {n}건 ({trades_per_month:.1f}건/월)")
|
||
print(f" 승률 : {wr:.1f}% ({wins}승 {losses}패)")
|
||
print(f" 평균 수익률 : {avg_pnl:+.2f}%")
|
||
print(f" 평균 수익 (win) : {avg_win:+.2f}%")
|
||
print(f" 평균 손실 (loss) : {avg_loss:+.2f}%")
|
||
print(f" 손익비 : {rr:.2f} (>1 이면 유리)")
|
||
print(f" 누적 수익률 : {total_return:+.1f}% "
|
||
f"({PER_POSITION:,}원 → {capital:,.0f}원)")
|
||
print(f" 최대 낙폭 (MDD) : -{mdd * 100:.1f}%")
|
||
print(bar)
|
||
|
||
print("\n [청산 사유별]")
|
||
for reason, grp in df.groupby("reason"):
|
||
w = int((grp["pnl_pct"] > 0).sum())
|
||
print(f" {reason:15s}: {len(grp):3d}건 | "
|
||
f"승률 {w/len(grp)*100:.0f}% | 평균 {grp['pnl_pct'].mean():+.2f}%")
|
||
|
||
print("\n [종목별]")
|
||
for ticker, grp in df.groupby("ticker"):
|
||
w = int((grp["pnl_pct"] > 0).sum())
|
||
print(f" {ticker:12s}: {len(grp):3d}건 | "
|
||
f"승률 {w/len(grp)*100:.0f}% | 평균 {grp['pnl_pct'].mean():+.2f}%")
|
||
|
||
print(f"\n [최근 거래 20건]")
|
||
print(f" {'종목':<12} {'진입':<18} {'청산':<18} "
|
||
f"{'수익률':>7} {'보유':>6} {'사유'}")
|
||
print(f" {'-'*75}")
|
||
for _, row in df.tail(20).iterrows():
|
||
entry_str = row["entry"].strftime("%m/%d %H:%M")
|
||
exit_str = row["exit"].strftime("%m/%d %H:%M")
|
||
sign = "✅" if row["pnl_pct"] > 0 else "❌"
|
||
print(f" {row['ticker']:<12} {entry_str:<18} {exit_str:<18} "
|
||
f"{row['pnl_pct']:>+6.2f}% {row['elapsed_h']:>5.1f}h "
|
||
f"{row['reason']} {sign}")
|
||
|
||
print(f"\n{bar}\n")
|
||
|
||
|
||
def compare_report(scenarios: list[tuple], title: str = "시나리오 비교") -> None:
|
||
"""여러 시나리오 핵심 지표 비교. scenarios = [(label, trades), ...]"""
|
||
bar = "=" * 72
|
||
|
||
def stats(trades: list[dict]) -> dict:
|
||
if not trades:
|
||
return {}
|
||
df = pd.DataFrame(trades)
|
||
n = len(df)
|
||
wins = int((df["pnl_pct"] > 0).sum())
|
||
capital = float(PER_POSITION)
|
||
peak_cap = capital
|
||
mdd = 0.0
|
||
for pnl in df["pnl_pct"]:
|
||
capital *= 1 + pnl / 100
|
||
if capital > peak_cap:
|
||
peak_cap = capital
|
||
dd = (peak_cap - capital) / peak_cap
|
||
if dd > mdd:
|
||
mdd = dd
|
||
total_ret = (capital - PER_POSITION) / PER_POSITION * 100
|
||
df_w = df[df["pnl_pct"] > 0]["pnl_pct"]
|
||
df_l = df[df["pnl_pct"] < 0]["pnl_pct"]
|
||
return {
|
||
"n": n, "wins": wins,
|
||
"wr": wins / n * 100,
|
||
"avg": df["pnl_pct"].mean(),
|
||
"ret": total_ret,
|
||
"mdd": mdd * 100,
|
||
"avg_w": df_w.mean() if len(df_w) else 0,
|
||
"avg_l": df_l.mean() if len(df_l) else 0,
|
||
}
|
||
|
||
all_stats = [(label, stats(trades)) for label, trades in scenarios]
|
||
|
||
header = f"{'항목':<16}" + "".join(f"{label:>16}" for label, _ in all_stats)
|
||
print(f"\n{bar}")
|
||
print(f" {title} | {MONTHS}개월 | 상위 {TOP_N}종목")
|
||
print(f" {header}")
|
||
print(bar)
|
||
for key, label_k in [("n","총 거래수"), ("wr","승률(%)"),
|
||
("avg","평균 손익(%)"), ("ret","누적 수익(%)"),
|
||
("mdd","MDD(%)"), ("avg_w","평균 수익(win)"),
|
||
("avg_l","평균 손실(loss)")]:
|
||
row = f" {label_k:<16}"
|
||
for _, s in all_stats:
|
||
v = s.get(key, 0)
|
||
row += f"{v:>+15.1f}%" if key != "n" else f"{v:>16.0f} "
|
||
print(row)
|
||
print(f"{bar}\n")
|
||
|
||
|
||
# ── 4. 시나리오 실행 ──────────────────────────────────────────────────────────
|
||
|
||
def run_scenario(
|
||
data: dict,
|
||
stop_loss: float,
|
||
trend_min_gain: float,
|
||
trend_max_gain: float = 999.0,
|
||
reentry_above_sell: bool = False,
|
||
btc_ctx: Optional[dict] = None,
|
||
btc_drop_threshold: float = -0.015,
|
||
btc_use_ma_filter: bool = False,
|
||
hard_stop: Optional[float] = None,
|
||
volume_mult: float = VOLUME_MULT,
|
||
interval_cd: str = DEFAULT_INTERVAL,
|
||
use_db_cache: bool = False,
|
||
daily_features: Optional[dict] = None, # 미리 계산된 daily_feat {ticker: df}
|
||
wf_min_wr: float = 0.0, # walk-forward 승률 임계값
|
||
wf_window: int = 5, # walk-forward 윈도우
|
||
time_stop_h: float = TIME_STOP_H, # 타임스탑 기준 시간 (0=비활성)
|
||
time_stop_gain: float = TIME_STOP_GAIN, # 타임스탑 최소 수익률
|
||
partial_tp_pct: float = 0.0, # 부분 익절 트리거 수익률 (0=비활성)
|
||
partial_tp_ratio: float = 0.5, # 부분 익절 비율
|
||
) -> list[dict]:
|
||
"""주어진 파라미터로 전체 종목 시뮬레이션 (데이터는 외부에서 주입).
|
||
|
||
use_db_cache=True 이면:
|
||
- 동일 파라미터 결과가 DB에 있으면 즉시 반환 (재시뮬레이션 없음)
|
||
- 없으면 시뮬레이션 후 DB에 저장
|
||
daily_features 를 주입하면 build_daily_features() 중복 호출 생략.
|
||
"""
|
||
params = {
|
||
"interval_cd": interval_cd,
|
||
"stop_loss": stop_loss,
|
||
"trend_min_gain": trend_min_gain,
|
||
"trend_max_gain": round(trend_max_gain, 4),
|
||
"volume_mult": volume_mult,
|
||
"reentry": reentry_above_sell,
|
||
"wf_min_wr": round(wf_min_wr, 3),
|
||
"wf_window": wf_window,
|
||
"time_stop_h": time_stop_h,
|
||
"time_stop_gain": round(time_stop_gain, 4),
|
||
}
|
||
|
||
if use_db_cache:
|
||
cached = load_trades_from_db(params)
|
||
if cached is not None:
|
||
print(f" → DB 캐시 히트 ({len(cached)}건, hash={_params_hash(params)[:8]}...)")
|
||
return cached
|
||
|
||
all_trades: list[dict] = []
|
||
for ticker, frames in data.items():
|
||
df = (daily_features or {}).get(ticker)
|
||
if df is None:
|
||
df = build_daily_features(frames["daily"])
|
||
trades = simulate(
|
||
ticker, frames["ohlcv"], df,
|
||
stop_loss=stop_loss,
|
||
trend_min_gain=trend_min_gain,
|
||
trend_max_gain=trend_max_gain,
|
||
reentry_above_sell=reentry_above_sell,
|
||
btc_ctx=btc_ctx,
|
||
btc_drop_threshold=btc_drop_threshold,
|
||
btc_use_ma_filter=btc_use_ma_filter,
|
||
hard_stop=hard_stop,
|
||
volume_mult=volume_mult,
|
||
wf_min_wr=wf_min_wr,
|
||
wf_window=wf_window,
|
||
time_stop_h=time_stop_h,
|
||
time_stop_gain=time_stop_gain,
|
||
partial_tp_pct=partial_tp_pct,
|
||
partial_tp_ratio=partial_tp_ratio,
|
||
)
|
||
all_trades.extend(trades)
|
||
|
||
if use_db_cache:
|
||
save_trades_to_db(all_trades, params)
|
||
|
||
return all_trades
|
||
|
||
|
||
# ── 5. 메인 ──────────────────────────────────────────────────────────────────
|
||
|
||
def main_stop_loss_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""손절선 비교 모드: 1% vs 1.5% vs 2% (봉주기 고정)."""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 손절선 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" (재진입: 직전 매도가 이상만 허용 | 모멘텀 기준: {trend*100:.1f}%)")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {list(data.keys())}")
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
|
||
common = dict(reentry_above_sell=True, interval_cd=interval,
|
||
use_db_cache=True, daily_features=daily_features)
|
||
|
||
print("\n▶ A: 손절 1% ...")
|
||
trades_a = run_scenario(data, stop_loss=0.01, trend_min_gain=trend, **common)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: 손절 1.5% ...")
|
||
trades_b = run_scenario(data, stop_loss=0.015, trend_min_gain=trend, **common)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: 손절 2% ...")
|
||
trades_c = run_scenario(data, stop_loss=0.02, trend_min_gain=trend, **common)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
compare_report([
|
||
("손절1%", trades_a),
|
||
("손절1.5%", trades_b),
|
||
("손절2%", trades_c),
|
||
], title=f"손절선 비교 ({label}) — 1% vs 1.5% vs 2%")
|
||
|
||
report(trades_a, f"손절 1% | {label}")
|
||
report(trades_b, f"손절 1.5% | {label}")
|
||
report(trades_c, f"손절 2% | {label}")
|
||
|
||
|
||
def main_interval_cmp() -> None:
|
||
"""봉주기 비교 모드: 10분 vs 15분 vs 20분 (손절 1.5% 고정)."""
|
||
intervals = ["minute10", "minute15", "minute20"]
|
||
stop_loss = 0.015
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 봉주기 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목")
|
||
print(f" 손절 {stop_loss*100:.1f}% 고정 | (10분봉 vs 15분봉 vs 20분봉)")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 목록 조회 중...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
|
||
all_scenario_trades = []
|
||
all_scenario_data = {}
|
||
|
||
for interval in intervals:
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
print(f"\n▶ {label} 데이터 로드 중 (DB 캐시 24h 유효)...")
|
||
data = fetch_all_data(tickers, interval)
|
||
all_scenario_data[interval] = data
|
||
print(f" 사용 종목: {list(data.keys())} ({len(data)}개)")
|
||
|
||
print(f" 시뮬레이션 중 (손절={stop_loss*100:.1f}%, 모멘텀={trend*100:.1f}%)...")
|
||
trades = run_scenario(data, stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True)
|
||
all_scenario_trades.append((label, trades))
|
||
print(f" 완료 ({len(trades)}건)")
|
||
|
||
compare_report(
|
||
all_scenario_trades,
|
||
title=f"봉주기 비교 (손절 {stop_loss*100:.1f}% 고정) — 10분 vs 15분 vs 20분"
|
||
)
|
||
|
||
for (label, trades), interval in zip(all_scenario_trades, intervals):
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
report(trades, f"{label} | 손절 {stop_loss*100:.1f}%")
|
||
|
||
|
||
# ── 변동성 돌파 전략 ──────────────────────────────────────────────────────────
|
||
|
||
def simulate_vol(
|
||
ticker: str,
|
||
daily: pd.DataFrame,
|
||
K: float = 0.5,
|
||
ma_period: int = 5,
|
||
use_ma_filter: bool = False,
|
||
) -> list[dict]:
|
||
"""변동성 돌파 전략 시뮬레이션 (일봉 기반).
|
||
|
||
진입: 당일 고가 ≥ 전일종가 + K × 전일변동폭 → 목표가에 매수
|
||
청산: 익일 시가에 전량 매도
|
||
"""
|
||
if len(daily) < ma_period + 2:
|
||
return []
|
||
|
||
closes = daily["close"].values
|
||
highs = daily["high"].values
|
||
lows = daily["low"].values
|
||
opens = daily["open"].values
|
||
times = daily.index.to_pydatetime()
|
||
ma = pd.Series(closes).rolling(ma_period).mean().values
|
||
|
||
trades: list[dict] = []
|
||
|
||
for i in range(1, len(daily) - 1): # i+1 필요 (익일 시가 매도)
|
||
prev_range = highs[i - 1] - lows[i - 1]
|
||
if prev_range <= 0:
|
||
continue
|
||
|
||
target = closes[i - 1] + K * prev_range
|
||
|
||
# MA 필터: 전일 종가 > MA(5) 인 상승장에서만 진입
|
||
if use_ma_filter:
|
||
if np.isnan(ma[i - 1]) or closes[i - 1] < ma[i - 1]:
|
||
continue
|
||
|
||
# 당일 목표가 돌파 여부
|
||
if highs[i] < target:
|
||
continue
|
||
|
||
buy_price = target * (1 + FEE)
|
||
sell_price = opens[i + 1] * (1 - FEE)
|
||
pnl = (sell_price - buy_price) / buy_price * 100
|
||
|
||
trades.append({
|
||
"ticker": ticker,
|
||
"entry": times[i],
|
||
"exit": times[i + 1],
|
||
"buy_price": round(buy_price, 4),
|
||
"sell_price": round(sell_price, 4),
|
||
"pnl_pct": round(pnl, 3),
|
||
"elapsed_h": 24.0,
|
||
"reason": "vol_breakout",
|
||
})
|
||
|
||
return trades
|
||
|
||
|
||
def run_vol_scenario(
|
||
data: dict,
|
||
K: float,
|
||
use_ma_filter: bool = False,
|
||
) -> list[dict]:
|
||
"""변동성 돌파 전체 종목 시뮬레이션."""
|
||
all_trades: list[dict] = []
|
||
for ticker, frames in data.items():
|
||
trades = simulate_vol(ticker, frames["daily"], K=K, use_ma_filter=use_ma_filter)
|
||
all_trades.extend(trades)
|
||
return all_trades
|
||
|
||
|
||
def main_vol_cmp() -> None:
|
||
"""변동성 돌파 전략 K값 비교."""
|
||
print(f"\n{'='*60}")
|
||
print(f" 변동성 돌파 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | 일봉")
|
||
print(f" 진입: 목표가 돌파 시 매수 | 청산: 익일 시가")
|
||
print(f"{'='*60}")
|
||
|
||
# 일봉만 필요하므로 daily 캐시 사용
|
||
print("\n▶ 종목 목록 조회 중...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
|
||
ensure_tables()
|
||
data: dict = {}
|
||
for i, ticker in enumerate(tickers, 1):
|
||
if _is_daily_fresh(ticker):
|
||
daily = load_daily_from_db(ticker)
|
||
src = "DB캐시"
|
||
else:
|
||
daily = fetch_daily(ticker)
|
||
if not daily.empty:
|
||
save_daily_to_db(ticker, daily)
|
||
src = "API→DB저장"
|
||
time.sleep(0.1)
|
||
|
||
if daily.empty:
|
||
continue
|
||
start = datetime.now() - timedelta(days=30 * MONTHS)
|
||
daily = daily[daily.index >= start]
|
||
print(f" [{i}/{len(tickers)}] {ticker} ({src} | {len(daily)}일)")
|
||
data[ticker] = {"daily": daily}
|
||
|
||
print(f"\n 사용 종목: {len(data)}개")
|
||
|
||
print("\n▶ A: K=0.3 ...")
|
||
trades_a = run_vol_scenario(data, K=0.3)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: K=0.5 ...")
|
||
trades_b = run_vol_scenario(data, K=0.5)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: K=0.7 ...")
|
||
trades_c = run_vol_scenario(data, K=0.7)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
print("\n▶ D: K=0.5 + MA5 필터 ...")
|
||
trades_d = run_vol_scenario(data, K=0.5, use_ma_filter=True)
|
||
print(f" 완료 ({len(trades_d)}건)")
|
||
|
||
compare_report([
|
||
("K=0.3", trades_a),
|
||
("K=0.5", trades_b),
|
||
("K=0.7", trades_c),
|
||
("K=0.5+MA5", trades_d),
|
||
], title="변동성 돌파 비교 (K=0.3/0.5/0.7, 일봉)")
|
||
|
||
for lbl, trades in [
|
||
("K=0.3", trades_a),
|
||
("K=0.5", trades_b),
|
||
("K=0.7", trades_c),
|
||
("K=0.5+MA5", trades_d),
|
||
]:
|
||
report(trades, lbl)
|
||
|
||
|
||
def main_top50_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""종목수(Top20 vs Top50) + 거래대금 급증 배수 비교."""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
stop_loss = 0.015
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 종목수/거래대금 비교 백테스트 | {MONTHS}개월 | {label}")
|
||
print(f" 손절 {stop_loss*100:.1f}% 고정 | Top20 vs Top50, 거래대금 2x/3x/4x")
|
||
print(f"{'='*60}")
|
||
|
||
common = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True)
|
||
|
||
# ── Top 20 데이터 (DB 캐시 대부분 존재) ──────────────────────────────────
|
||
print("\n▶ Top 20 종목 데이터 로드 중...")
|
||
tickers_20 = get_top_tickers(20)
|
||
print(f" → {tickers_20}")
|
||
data_20 = fetch_all_data(tickers_20, interval)
|
||
print(f" 사용 종목: {len(data_20)}개")
|
||
|
||
# ── Top 50 데이터 (추가 30개는 API 수집 필요할 수 있음) ──────────────────
|
||
print("\n▶ Top 50 종목 데이터 로드 중...")
|
||
tickers_50 = get_top_tickers(50)
|
||
print(f" → {tickers_50}")
|
||
data_50 = fetch_all_data(tickers_50, interval)
|
||
print(f" 사용 종목: {len(data_50)}개")
|
||
|
||
# ── 시나리오 실행 ─────────────────────────────────────────────────────────
|
||
print("\n▶ A: Top20 / 거래대금 2배 (현재 설정) ...")
|
||
trades_a = run_scenario(data_20, **common, volume_mult=2.0)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: Top50 / 거래대금 2배 ...")
|
||
trades_b = run_scenario(data_50, **common, volume_mult=2.0)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: Top50 / 거래대금 3배 ...")
|
||
trades_c = run_scenario(data_50, **common, volume_mult=3.0)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
print("\n▶ D: Top50 / 거래대금 4배 ...")
|
||
trades_d = run_scenario(data_50, **common, volume_mult=4.0)
|
||
print(f" 완료 ({len(trades_d)}건)")
|
||
|
||
compare_report([
|
||
("Top20/Vol×2", trades_a),
|
||
("Top50/Vol×2", trades_b),
|
||
("Top50/Vol×3", trades_c),
|
||
("Top50/Vol×4", trades_d),
|
||
], title=f"종목수/거래대금 비교 ({label}, 손절 {stop_loss*100:.1f}%)")
|
||
|
||
for lbl, trades in [
|
||
("Top20 / 거래대금 2배 (현재)", trades_a),
|
||
("Top50 / 거래대금 2배", trades_b),
|
||
("Top50 / 거래대금 3배", trades_c),
|
||
("Top50 / 거래대금 4배", trades_d),
|
||
]:
|
||
report(trades, lbl)
|
||
|
||
|
||
def main_hard_stop_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""하드 스탑 비교 모드 (트레일링 1.5% 고정, 하드 스탑 가변)."""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
trailing = 0.015
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 하드스탑 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" 트레일링 {trailing*100:.1f}% 고정 | 하드스탑 가변")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {len(data)}개")
|
||
|
||
common = dict(stop_loss=trailing, trend_min_gain=trend, reentry_above_sell=True)
|
||
|
||
print("\n▶ A: 하드스탑 1.5% (트레일링과 동일, 현재 설정) ...")
|
||
trades_a = run_scenario(data, **common, hard_stop=0.015)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: 하드스탑 3% ...")
|
||
trades_b = run_scenario(data, **common, hard_stop=0.03)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: 하드스탑 5% ...")
|
||
trades_c = run_scenario(data, **common, hard_stop=0.05)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
print("\n▶ D: 하드스탑 없음 (트레일링만) ...")
|
||
trades_d = run_scenario(data, **common, hard_stop=999)
|
||
print(f" 완료 ({len(trades_d)}건)")
|
||
|
||
compare_report([
|
||
("하드1.5%(현재)", trades_a),
|
||
("하드3%", trades_b),
|
||
("하드5%", trades_c),
|
||
("하드없음", trades_d),
|
||
], title=f"하드스탑 비교 ({label}, 트레일링 {trailing*100:.1f}% 고정)")
|
||
|
||
for lbl, trades in [
|
||
("하드스탑 1.5% (현재)", trades_a),
|
||
("하드스탑 3%", trades_b),
|
||
("하드스탑 5%", trades_c),
|
||
("하드스탑 없음", trades_d),
|
||
]:
|
||
report(trades, lbl)
|
||
|
||
|
||
def main_btc_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""BTC 전체 흐름 필터 비교 모드."""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
stop_loss = 0.015
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" BTC 필터 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" 손절 {stop_loss*100:.1f}% 고정")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {len(data)}개")
|
||
|
||
print("\n▶ BTC 데이터 로드 중...")
|
||
btc_raw = fetch_btc_data(interval)
|
||
btc_ctx = build_btc_context(btc_raw, lookback_candles=8) # 8봉 = 2시간 (15분봉 기준)
|
||
|
||
common = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True)
|
||
|
||
print("\n▶ A: BTC 필터 없음 ...")
|
||
trades_a = run_scenario(data, **common)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: BTC 2h 하락 -1.5% 차단 ...")
|
||
trades_b = run_scenario(data, **common, btc_ctx=btc_ctx, btc_drop_threshold=-0.015)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: BTC MA20 아래 차단 ...")
|
||
trades_c = run_scenario(data, **common, btc_ctx=btc_ctx,
|
||
btc_drop_threshold=-9999, # 하락 필터 OFF
|
||
btc_use_ma_filter=True)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
print("\n▶ D: BTC 2h 하락 + MA20 동시 적용 ...")
|
||
trades_d = run_scenario(data, **common, btc_ctx=btc_ctx,
|
||
btc_drop_threshold=-0.015, btc_use_ma_filter=True)
|
||
print(f" 완료 ({len(trades_d)}건)")
|
||
|
||
compare_report([
|
||
("필터없음", trades_a),
|
||
("BTC2h하락", trades_b),
|
||
("BTCMA20", trades_c),
|
||
("2h+MA20", trades_d),
|
||
], title=f"BTC 전체흐름 필터 비교 ({label}, 손절{stop_loss*100:.1f}%)")
|
||
|
||
for label_s, trades in [
|
||
("A: 필터 없음", trades_a),
|
||
("B: BTC 2h 하락 차단", trades_b),
|
||
("C: BTC MA20 차단", trades_c),
|
||
("D: 2h 하락 + MA20", trades_d),
|
||
]:
|
||
report(trades, label_s)
|
||
|
||
|
||
def main_trend_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""추세 상한선 비교: 상한 없음 / +5% / +7% / +10%."""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
stop_loss = 0.015 # 트레일링 스탑 고정
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 추세 상한선 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" (트레일링={stop_loss*100:.1f}%, 하한={trend*100:.1f}%, 재진입차단O)")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {list(data.keys())}")
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
|
||
common = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True,
|
||
interval_cd=interval, use_db_cache=True, daily_features=daily_features)
|
||
|
||
print("\n▶ A: 상한선 없음 (현행) ...")
|
||
trades_a = run_scenario(data, **common)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: 상한 +5% ...")
|
||
trades_b = run_scenario(data, **common, trend_max_gain=0.05)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: 상한 +7% ...")
|
||
trades_c = run_scenario(data, **common, trend_max_gain=0.07)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
print("\n▶ D: 상한 +10% ...")
|
||
trades_d = run_scenario(data, **common, trend_max_gain=0.10)
|
||
print(f" 완료 ({len(trades_d)}건)")
|
||
|
||
print("\n▶ E: 상한 +15% ...")
|
||
trades_e = run_scenario(data, **common, trend_max_gain=0.15)
|
||
print(f" 완료 ({len(trades_e)}건)")
|
||
|
||
compare_report([
|
||
("상한없음(현행)", trades_a),
|
||
("상한+5%", trades_b),
|
||
("상한+7%", trades_c),
|
||
("상한+10%", trades_d),
|
||
("상한+15%", trades_e),
|
||
], title=f"추세 상한선 비교 ({label}, 손절{stop_loss*100:.1f}%)")
|
||
|
||
for label_s, trades in [
|
||
("A: 상한 없음", trades_a),
|
||
("B: 상한 +5%", trades_b),
|
||
("C: 상한 +7%", trades_c),
|
||
("D: 상한 +10%", trades_d),
|
||
("E: 상한 +15%", trades_e),
|
||
]:
|
||
report(trades, label_s)
|
||
|
||
|
||
def main_ticker_walkforward_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""Walk-forward 종목 필터 비교.
|
||
|
||
각 거래 시점에서 '직전 N건 승률'만 참조 → look-ahead bias 없음.
|
||
워밍업(N건 미달) 기간에는 필터 미적용.
|
||
|
||
테스트 조합:
|
||
A: 필터 없음 (baseline)
|
||
B: 직전 5건 승률 ≥ 30%
|
||
C: 직전 5건 승률 ≥ 40%
|
||
D: 직전 10건 승률 ≥ 30%
|
||
E: 직전 10건 승률 ≥ 40%
|
||
"""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
stop_loss = 0.015
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" Walk-Forward 필터 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" (손절={stop_loss*100:.1f}%, 하한={trend*100:.1f}% / look-ahead bias 없음)")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {list(data.keys())}")
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
|
||
base = dict(stop_loss=stop_loss, trend_min_gain=trend, reentry_above_sell=True,
|
||
interval_cd=interval, use_db_cache=True, daily_features=daily_features)
|
||
|
||
scenarios = [
|
||
("A: 필터없음", dict(**base)),
|
||
("B: W5≥30%", dict(**base, wf_min_wr=0.30, wf_window=5)),
|
||
("C: W5≥40%", dict(**base, wf_min_wr=0.40, wf_window=5)),
|
||
("D: W10≥30%", dict(**base, wf_min_wr=0.30, wf_window=10)),
|
||
("E: W10≥40%", dict(**base, wf_min_wr=0.40, wf_window=10)),
|
||
]
|
||
|
||
results = []
|
||
for sc_label, sc_params in scenarios:
|
||
print(f"\n▶ {sc_label} ...")
|
||
trades = run_scenario(data, **sc_params)
|
||
print(f" 완료 ({len(trades)}건)")
|
||
results.append((sc_label, trades))
|
||
|
||
compare_report(results, title=f"Walk-Forward 필터 비교 ({label})")
|
||
|
||
for sc_label, trades in results:
|
||
report(trades, sc_label)
|
||
|
||
|
||
def main_ticker_filter_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""종목 승률 필터 비교 — 기존 DB 캐시 기반, 순수 SQL 분석.
|
||
|
||
먼저 기본 시나리오(상한없음, 손절1.5%)를 DB에 캐시해두고,
|
||
이후 SQL로 win_rate 임계값별 누적 수익을 즉시 계산.
|
||
※ 전체 기간 승률로 필터링하므로 look-ahead bias 있음 — 방향성 확인용.
|
||
"""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
stop_loss = 0.015
|
||
|
||
params = {
|
||
"interval_cd": interval,
|
||
"stop_loss": stop_loss,
|
||
"trend_min_gain": trend,
|
||
"trend_max_gain": round(999.0, 4),
|
||
"volume_mult": VOLUME_MULT,
|
||
"reentry": True,
|
||
}
|
||
ph = _params_hash(params)
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 종목 승률 필터 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" (손절={stop_loss*100:.1f}%, 하한={trend*100:.1f}% / look-ahead 주의)")
|
||
print(f"{'='*60}")
|
||
|
||
ensure_tables()
|
||
|
||
# DB에 결과 없으면 시뮬레이션
|
||
if not _trades_cached(ph):
|
||
print("\n▶ DB 캐시 없음 — 시뮬레이션 실행 중...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
data = fetch_all_data(tickers, interval)
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
run_scenario(data, stop_loss=stop_loss, trend_min_gain=trend,
|
||
reentry_above_sell=True, interval_cd=interval,
|
||
use_db_cache=True, daily_features=daily_features)
|
||
else:
|
||
print(f"\n▶ DB 캐시 히트 (hash={ph[:8]}...) — 시뮬레이션 생략")
|
||
|
||
# ── 순수 SQL 분석 ──────────────────────────────────────────────────────────
|
||
with _conn() as conn:
|
||
cur = conn.cursor()
|
||
|
||
# 전체 요약
|
||
cur.execute("""
|
||
SELECT COUNT(*),
|
||
ROUND(SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1),
|
||
ROUND(AVG(pnl_pct), 3),
|
||
ROUND(SUM(pnl_pct), 2)
|
||
FROM backtest_trades WHERE params_hash = :h
|
||
""", {"h": ph})
|
||
total_n, total_wr, total_avg, total_sum = cur.fetchone()
|
||
|
||
# 티커별 성과 (SQL)
|
||
cur.execute("""
|
||
SELECT ticker,
|
||
COUNT(*) as trades,
|
||
ROUND(SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1) as win_rate,
|
||
ROUND(AVG(pnl_pct), 3) as avg_pnl,
|
||
ROUND(SUM(pnl_pct), 2) as total_pnl
|
||
FROM backtest_trades
|
||
WHERE params_hash = :h
|
||
GROUP BY ticker
|
||
ORDER BY avg_pnl DESC
|
||
""", {"h": ph})
|
||
ticker_rows = cur.fetchall()
|
||
|
||
print(f"\n▶ 티커별 성과 (전체 {total_n}건 | 승률 {total_wr}% | 합산 {total_sum:+.1f}%)")
|
||
print(f" {'종목':<12} {'건수':>4} {'승률':>6} {'평균':>7} {'합산':>8}")
|
||
print(f" {'-'*45}")
|
||
for row in ticker_rows:
|
||
flag = "✅" if float(row[3]) > 0 else "❌"
|
||
print(f" {row[0]:<12} {row[1]:>4}건 {row[2]:>5.0f}% {row[3]:>+6.2f}% {row[4]:>+7.1f}% {flag}")
|
||
|
||
# 승률 임계값별 비교 (순수 SQL — 즉시 결과)
|
||
print(f"\n▶ 승률 임계값별 필터 비교 (SQL)")
|
||
print(f" {'조건':<20} {'거래수':>5} {'승률':>6} {'누적합산':>9}")
|
||
print(f" {'-'*45}")
|
||
|
||
for min_wr in [0, 20, 25, 30, 35, 40]:
|
||
cur.execute("""
|
||
WITH good AS (
|
||
SELECT ticker
|
||
FROM backtest_trades
|
||
WHERE params_hash = :h
|
||
GROUP BY ticker
|
||
HAVING SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*) >= :wr
|
||
)
|
||
SELECT COUNT(*),
|
||
ROUND(SUM(CASE WHEN t.pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1),
|
||
ROUND(SUM(t.pnl_pct), 2)
|
||
FROM backtest_trades t
|
||
WHERE t.params_hash = :h AND t.ticker IN (SELECT ticker FROM good)
|
||
""", {"h": ph, "wr": min_wr})
|
||
r = cur.fetchone()
|
||
n_r, wr_r, sum_r = r[0], r[1], r[2]
|
||
label_s = "필터 없음(전체)" if min_wr == 0 else f"승률>{min_wr}% 종목만"
|
||
print(f" {label_s:<20} {n_r:>5}건 {wr_r:>5.1f}% {sum_r:>+8.1f}%")
|
||
|
||
# 어떤 종목이 포함/제외되는지 (30% 기준)
|
||
cur.execute("""
|
||
SELECT ticker,
|
||
ROUND(SUM(CASE WHEN pnl_pct > 0 THEN 1 ELSE 0 END)*100/COUNT(*), 1) as wr
|
||
FROM backtest_trades
|
||
WHERE params_hash = :h
|
||
GROUP BY ticker
|
||
ORDER BY wr
|
||
""", {"h": ph})
|
||
print(f"\n [30% 기준 제외 대상]")
|
||
for row in cur.fetchall():
|
||
if float(row[1]) < 30:
|
||
print(f" ✗ {row[0]:<12} 승률 {row[1]:.0f}%")
|
||
|
||
|
||
def main_timestop_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""타임스탑 조건 비교: 트레일링 1.5%+8h(현행) vs 10%+8h vs 10%+24h vs 10%+없음."""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 타임스탑 조건 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" (A) 트레일링 1.5% + 타임스탑 8h ← 현재 설정")
|
||
print(f" (B) 트레일링 10% + 타임스탑 8h")
|
||
print(f" (C) 트레일링 10% + 타임스탑 24h")
|
||
print(f" (D) 트레일링 10% + 타임스탑 없음")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {list(data.keys())}")
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
|
||
common = dict(
|
||
reentry_above_sell=True, interval_cd=interval,
|
||
use_db_cache=True, daily_features=daily_features,
|
||
trend_min_gain=trend,
|
||
)
|
||
|
||
print("\n▶ A: 트레일링 1.5% + 타임스탑 8h (현재 설정) ...")
|
||
trades_a = run_scenario(data, stop_loss=0.015, time_stop_h=8, **common)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: 트레일링 10% + 타임스탑 8h ...")
|
||
trades_b = run_scenario(data, stop_loss=0.10, time_stop_h=8, **common)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: 트레일링 10% + 타임스탑 24h ...")
|
||
trades_c = run_scenario(data, stop_loss=0.10, time_stop_h=24, **common)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
print("\n▶ D: 트레일링 10% + 타임스탑 없음 ...")
|
||
trades_d = run_scenario(data, stop_loss=0.10, time_stop_h=0, **common)
|
||
print(f" 완료 ({len(trades_d)}건)")
|
||
|
||
compare_report([
|
||
("1.5%+8h(현행)", trades_a),
|
||
("10%+8h", trades_b),
|
||
("10%+24h", trades_c),
|
||
("10%+타임없음", trades_d),
|
||
], title=f"트레일링 + 타임스탑 조합 비교 ({label})")
|
||
|
||
report(trades_a, f"A: 트레일링 1.5% + 타임스탑 8h | {label}")
|
||
report(trades_b, f"B: 트레일링 10% + 타임스탑 8h | {label}")
|
||
report(trades_c, f"C: 트레일링 10% + 타임스탑 24h | {label}")
|
||
report(trades_d, f"D: 트레일링 10% + 타임스탑 없음 | {label}")
|
||
|
||
|
||
def main_combo_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""추세 임계값 + 거래량 배수 조합 비교.
|
||
|
||
목적: 거래 빈도를 줄여 수수료 부담을 낮추는 최적 조합 탐색.
|
||
|
||
A (현행): trend=1.0%, vol=2x
|
||
B: trend=1.5%, vol=2x (추세만 강화)
|
||
C: trend=1.0%, vol=3x (거래량만 강화)
|
||
D: trend=1.5%, vol=3x (둘 다 강화)
|
||
E: trend=2.0%, vol=3x (최강 강화)
|
||
"""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
base_trend = cfg["trend"] # 15분봉 기준 1.0%
|
||
|
||
combos = [
|
||
("A 현행 1.0%/2x", base_trend, 2.0),
|
||
("B 추세1.5%/2x", base_trend * 1.5, 2.0),
|
||
("C 추세1.0%/3x", base_trend, 3.0),
|
||
("D 추세1.5%/3x", base_trend * 1.5, 3.0),
|
||
("E 추세2.0%/3x", base_trend * 2.0, 3.0),
|
||
]
|
||
|
||
print(f"\n{'='*64}")
|
||
print(f" 추세+거래량 조합 비교 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" 목적: 거래 빈도↓ → 수수료 부담↓ → 순수익↑")
|
||
print(f" FEE = 0.05% × 2 = 0.1%/건 | 현행 ~85건/월 = 월 8.5% 수수료")
|
||
print(f"{'='*64}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {list(data.keys())}")
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
|
||
results = []
|
||
for combo_label, trend, vol in combos:
|
||
print(f"\n▶ {combo_label} (trend={trend*100:.1f}%, vol={vol:.0f}x) ...")
|
||
trades = run_scenario(
|
||
data, stop_loss=0.015,
|
||
trend_min_gain=trend, volume_mult=vol,
|
||
reentry_above_sell=True,
|
||
interval_cd=interval, use_db_cache=True,
|
||
daily_features=daily_features,
|
||
)
|
||
n = len(trades)
|
||
fee_pct = n * 0.1 # 총 수수료 부담 (%)
|
||
print(f" 완료 ({n}건 | 예상 수수료 {fee_pct:.1f}%)")
|
||
results.append((combo_label, trades))
|
||
|
||
compare_report(results, title=f"추세+거래량 조합 비교 ({label})")
|
||
|
||
# 수수료 분석 추가 출력
|
||
print(" [수수료 분석]")
|
||
print(f" {'조합':<20} {'거래수':>6} {'월평균':>6} {'총수수료':>8} {'수수료 전':>10} {'수수료 후':>10}")
|
||
print(f" {'-'*64}")
|
||
for (clabel, trades) in results:
|
||
if not trades:
|
||
continue
|
||
df = pd.DataFrame(trades)
|
||
n = len(df)
|
||
span_days = (df["exit"].max() - df["entry"].min()).total_seconds() / 86400
|
||
per_month = n / (span_days / 30) if span_days > 0 else 0
|
||
fee_total = n * 0.1
|
||
net_pnl = df["pnl_pct"].sum()
|
||
gross_pnl = net_pnl + fee_total # 수수료 전 추정
|
||
print(f" {clabel:<20} {n:>6}건 {per_month:>5.0f}/월 {fee_total:>7.1f}% "
|
||
f"{gross_pnl:>+9.1f}% {net_pnl:>+9.1f}%")
|
||
print()
|
||
|
||
for clabel, trades in results:
|
||
report(trades, clabel)
|
||
|
||
|
||
def main_tp_cmp(interval: str = DEFAULT_INTERVAL) -> None:
|
||
"""부분 익절 구조 비교: A(없음) vs B(+5% 50% 익절) vs C(+3% 50% 익절).
|
||
|
||
트레일링 스탑·타임스탑 파라미터는 현재 봇 운영 기준과 동일하게 고정.
|
||
"""
|
||
cfg = INTERVAL_CONFIG[interval]
|
||
label = cfg["label"]
|
||
trend = cfg["trend"]
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" 부분 익절 구조 비교 백테스트 | {MONTHS}개월 | 상위 {TOP_N}종목 | {label}")
|
||
print(f" (트레일링 1.5% | 타임스탑 8h/3% | 모멘텀 {trend*100:.1f}%)")
|
||
print(f"{'='*60}")
|
||
|
||
print("\n▶ 종목 데이터 로드 중 (DB 캐시 우선)...")
|
||
tickers = get_top_tickers(TOP_N)
|
||
print(f" → {tickers}")
|
||
data = fetch_all_data(tickers, interval)
|
||
print(f" 사용 종목: {list(data.keys())}")
|
||
daily_features = {t: build_daily_features(f["daily"]) for t, f in data.items()}
|
||
|
||
common = dict(
|
||
stop_loss=0.015,
|
||
trend_min_gain=trend,
|
||
interval_cd=interval,
|
||
daily_features=daily_features,
|
||
time_stop_h=8.0,
|
||
time_stop_gain=0.03,
|
||
)
|
||
|
||
print("\n▶ A: 부분 익절 없음 (현행) ...")
|
||
trades_a = run_scenario(data, partial_tp_pct=0.0, **common)
|
||
print(f" 완료 ({len(trades_a)}건)")
|
||
|
||
print("\n▶ B: +5% 도달 시 50% 익절 ...")
|
||
trades_b = run_scenario(data, partial_tp_pct=0.05, partial_tp_ratio=0.5, **common)
|
||
print(f" 완료 ({len(trades_b)}건)")
|
||
|
||
print("\n▶ C: +3% 도달 시 50% 익절 ...")
|
||
trades_c = run_scenario(data, partial_tp_pct=0.03, partial_tp_ratio=0.5, **common)
|
||
print(f" 완료 ({len(trades_c)}건)")
|
||
|
||
compare_report([
|
||
("A: 익절없음", trades_a),
|
||
("B: +5%→50%익절", trades_b),
|
||
("C: +3%→50%익절", trades_c),
|
||
], title=f"부분 익절 구조 비교 ({label})")
|
||
|
||
report(trades_a, f"A: 부분 익절 없음 | {label}")
|
||
report(trades_b, f"B: +5% 50% 익절 | {label}")
|
||
report(trades_c, f"C: +3% 50% 익절 | {label}")
|
||
|
||
|
||
def main() -> None:
|
||
if COMPARE_TP:
|
||
main_tp_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_COMBO:
|
||
main_combo_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_TIMESTOP:
|
||
main_timestop_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_TOP50:
|
||
main_top50_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_HARDSTOP:
|
||
main_hard_stop_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_VOL:
|
||
main_vol_cmp()
|
||
elif COMPARE_BTC:
|
||
main_btc_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_TREND:
|
||
main_trend_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_WF:
|
||
main_ticker_walkforward_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_TICKER:
|
||
main_ticker_filter_cmp(DEFAULT_INTERVAL)
|
||
elif COMPARE_INTERVALS:
|
||
main_interval_cmp()
|
||
else:
|
||
main_stop_loss_cmp(DEFAULT_INTERVAL)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|