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:
joungmin
2026-03-03 16:08:50 +09:00
parent bfe0b4d40c
commit 6b2c962ed8
30 changed files with 1039 additions and 5 deletions

214
tests/atr_sweep.py Normal file
View File

@@ -0,0 +1,214 @@
"""ATR_MAX_STOP 파라미터 스윕 시뮬레이션.
실제 봇과 동일하게 ATR을 계산하되, ATR_MAX_STOP 상한만 바꿔가며 성과를 비교한다.
- ATR_MIN_STOP = 1.0% (고정)
- ATR_MULT = 1.5 (고정)
- ATR_CANDLES = 5 (고정)
- ATR_MAX_STOP : [1.5%, 2.0%, 2.5%, 3.0%, 3.5%, 4.0%] 스윕
데이터: Oracle ADB ohlcv_hourly (top30_tickers.pkl 상위 20종목)
"""
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
from ohlcv_db import load_from_db
import pandas as pd
# ── 고정 파라미터 ─────────────────────────────────────────
TIME_STOP_HOURS = 8
TIME_STOP_MIN_PCT = 3.0
FEE = 0.0005
LOCAL_VOL_HOURS = 5
VOL_MULT = 2.0
PRICE_QUIET_PCT = 2.0
SIGNAL_TIMEOUT_H = 8
THRESH = 4.8
FROM_DATE = "2025-03-02"
# ATR 고정값
ATR_CANDLES = 5
ATR_MULT = 1.5
ATR_MIN = 0.010 # 1.0%
# 스윕 대상: ATR_MAX_STOP
ATR_MAX_CANDIDATES = [0.015, 0.020, 0.025, 0.030, 0.035, 0.040]
TOP30_FILE = Path("top30_tickers.pkl")
# ── 매수 시점 ATR 계산 ────────────────────────────────────
def calc_atr_stop(df: pd.DataFrame, buy_idx: int, atr_max: float) -> float:
"""매수 직전 ATR_CANDLES개 봉으로 스탑 비율 계산.
실제 봇(monitor.py)의 _get_adaptive_stop() 로직과 동일.
계산 실패 시 ATR_MIN 반환.
"""
start = max(0, buy_idx - ATR_CANDLES - 1)
sub = df.iloc[start:buy_idx]
if len(sub) < ATR_CANDLES:
return ATR_MIN
try:
ranges = (sub["high"] - sub["low"]) / sub["low"]
avg_range = ranges.iloc[-ATR_CANDLES:].mean()
return float(max(ATR_MIN, min(atr_max, avg_range * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ───────────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int, buy_price: float, stop_pct: float):
"""매수 후 청산 시뮬레이션 (고정 stop_pct 사용)."""
buy_dt = df.index[buy_idx]
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
stop_price = peak * (1 - stop_pct)
elapsed_h = (ts - buy_dt).total_seconds() / 3600
# 트레일링 스탑
if row["low"] <= stop_price:
sell_price = stop_price
pnl = (sell_price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, sell_price, ts, f"트레일링({pnl:+.1f}%)", pnl
# 타임 스탑
pnl_now = (row["close"] - buy_price) / buy_price * 100
if elapsed_h >= TIME_STOP_HOURS and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, row["close"], ts, "타임스탑", pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, last, df.index[-1], "데이터종료", pnl
# ── vol-lead 전략 실행 (ATR_MAX 동적 주입) ────────────────
def run_vol_lead(df: pd.DataFrame, thresh: float, atr_max: float) -> list:
"""vol-lead 신호 → 진입 → ATR 기반 청산 시뮬.
진입 시점의 ATR을 계산해 stop_pct를 결정하고 청산 시뮬에 전달.
"""
trades = []
signal_i = None
signal_price = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(12, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason, stop_pct))
in_pos = False
signal_i = None
signal_price = None
i = next_i
continue
close = df.iloc[i]["close"]
close_2h = df.iloc[i - 2]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < PRICE_QUIET_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_spike = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
if quiet and vol_spike:
if signal_i is None:
signal_i = i
signal_price = close
else:
if signal_i is not None and close < signal_price:
signal_i = signal_price = None
if signal_i is not None and (i - signal_i) > SIGNAL_TIMEOUT_H:
signal_i = signal_price = None
if signal_i is not None:
move = (close - signal_price) / signal_price * 100
if move >= thresh:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr_stop(df, i, atr_max) # ← 진입 시점 ATR 계산
signal_i = signal_price = None
i += 1
return trades
# ── 최대 낙폭 계산 ────────────────────────────────────────
def calc_max_drawdown(trades: list) -> float:
if not trades:
return 0.0
cum = peak = max_dd = 0.0
for t in sorted(trades, key=lambda x: x[2]):
cum += t[1]
if cum > peak:
peak = cum
dd = peak - cum
if dd > max_dd:
max_dd = dd
return max_dd
# ── 메인 ─────────────────────────────────────────────────
def main() -> None:
top30: list = pickle.load(open(TOP30_FILE, "rb"))
print(f"DB 로드 중... ({len(top30)}종목)")
data = load_from_db(top30, from_date=FROM_DATE)
valid = [t for t in top30 if t in data and len(data[t]) >= 500]
use20 = valid[:20]
print(f"유효 종목: {len(use20)}\n")
print(f"{'='*72}")
print(f"ATR_MAX_STOP 스윕 | ATR×{ATR_MULT} (최소={ATR_MIN:.1%}) | vol-lead +{THRESH}% | {len(use20)}종목")
print(f"{'='*72}")
print(f"{'ATR_MAX':>8} | {'거래수':>6} | {'승률':>6} | {'누적PnL%':>10} | {'최대낙폭%':>10} | {'평균스탑%':>9}")
print(f"{''*72}")
for atr_max in ATR_MAX_CANDIDATES:
all_trades = []
for ticker in use20:
if ticker not in data:
continue
trades = run_vol_lead(data[ticker], THRESH, atr_max)
all_trades.extend(trades)
total = len(all_trades)
if total == 0:
print(f"{atr_max*100:>7.1f}% | {'0':>6} | {'N/A':>6} | {'N/A':>10} | {'N/A':>10} | {'N/A':>9}")
continue
wins = sum(1 for t in all_trades if t[0])
win_rate = wins / total * 100
cum_pnl = sum(t[1] for t in all_trades)
max_dd = calc_max_drawdown(all_trades)
avg_stop = sum(t[5] for t in all_trades) / total * 100 # 실제 평균 스탑%
print(f"{atr_max*100:>7.1f}% | {total:>6}건 | {win_rate:>5.1f}% | "
f"{cum_pnl:>+9.2f}% | {-max_dd:>+9.2f}% | {avg_stop:>8.2f}%")
print(f"{'='*72}")
print("\n※ 평균스탑% = 실제 거래에서 적용된 ATR 스탑의 평균 (ATR_MAX에 걸렸는지 확인)")
if __name__ == "__main__":
main()

1803
tests/backtest.py Normal file

File diff suppressed because it is too large Load Diff

250
tests/backtest_db.py Normal file
View File

@@ -0,0 +1,250 @@
"""백테스트 결과 Oracle DB 저장 모듈.
테이블:
backtest_runs - 실행 단위 (실행시각, 설명, 파라미터)
backtest_results - 조건별 집계 (run_id + label)
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
import json
import os
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Generator
import oracledb
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
_pool: oracledb.ConnectionPool | None = None
def _get_pool() -> oracledb.ConnectionPool:
global _pool
if _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
_pool = oracledb.create_pool(**kwargs)
return _pool
@contextmanager
def _conn() -> Generator[oracledb.Connection, None, None]:
pool = _get_pool()
conn = pool.acquire()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.release(conn)
# ── DDL ────────────────────────────────────────────────────────
_DDL_RUNS = """
CREATE TABLE backtest_runs (
run_id VARCHAR2(36) DEFAULT SYS_GUID() PRIMARY KEY,
run_name VARCHAR2(200) NOT NULL,
description VARCHAR2(1000),
params_json CLOB,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""
_DDL_RESULTS = """
CREATE TABLE backtest_results (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
label VARCHAR2(100) NOT NULL,
n_trades NUMBER,
win_rate NUMBER(6,3),
avg_pnl NUMBER(10,4),
total_pnl NUMBER(12,4),
rr NUMBER(8,4),
avg_win NUMBER(10,4),
avg_loss NUMBER(10,4),
max_dd NUMBER(10,4),
fng_lo NUMBER,
fng_hi NUMBER,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT fk_br_run FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id)
)
"""
_DDL_TRADES = """
CREATE TABLE backtest_trade_log (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id VARCHAR2(36) NOT NULL,
label VARCHAR2(100),
ticker VARCHAR2(20),
pnl NUMBER(10,4),
hold_h NUMBER,
fng_val NUMBER,
exit_type VARCHAR2(10),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT fk_bt_run FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id)
)
"""
def ensure_tables() -> None:
"""백테스트 테이블이 없으면 생성."""
with _conn() as conn:
cur = conn.cursor()
for tbl_name, ddl in [
("BACKTEST_RUNS", _DDL_RUNS),
("BACKTEST_RESULTS", _DDL_RESULTS),
("BACKTEST_TRADE_LOG", _DDL_TRADES),
]:
cur.execute(
"SELECT COUNT(*) FROM user_tables WHERE table_name=:1", [tbl_name]
)
if cur.fetchone()[0] == 0:
cur.execute(ddl)
print(f" {tbl_name} 테이블 생성 완료")
# ── 삽입 헬퍼 ──────────────────────────────────────────────────
def insert_run(run_name: str, description: str = "", params: dict | None = None) -> str:
"""새 백테스트 실행 레코드 삽입. run_id 반환."""
sql = """
INSERT INTO backtest_runs (run_name, description, params_json)
VALUES (:rname, :rdesc, :rparams)
RETURNING run_id INTO :out_id
"""
with _conn() as conn:
cur = conn.cursor()
out = cur.var(oracledb.STRING)
cur.execute(sql, {
"rname": run_name,
"rdesc": description,
"rparams": json.dumps(params or {}, ensure_ascii=False),
"out_id": out,
})
return out.getvalue()[0]
def insert_result(
run_id: str,
label: str,
stats: dict,
fng_lo: int | None = None,
fng_hi: int | None = None,
) -> None:
"""조건별 집계 결과 삽입."""
sql = """
INSERT INTO backtest_results
(run_id, label, n_trades, win_rate, avg_pnl, total_pnl,
rr, avg_win, avg_loss, max_dd, fng_lo, fng_hi)
VALUES
(:run_id, :label, :n, :wr, :avg_pnl, :total_pnl,
:rr, :avg_win, :avg_loss, :max_dd, :fng_lo, :fng_hi)
"""
with _conn() as conn:
conn.cursor().execute(sql, {
"run_id": run_id,
"label": label,
"n": stats.get("n", 0),
"wr": round(stats.get("wr", 0), 3),
"avg_pnl": round(stats.get("avg_pnl", 0), 4),
"total_pnl": round(stats.get("total_pnl", 0), 4),
"rr": round(stats.get("rr", 0), 4),
"avg_win": round(stats.get("avg_win", 0), 4),
"avg_loss": round(stats.get("avg_loss", 0), 4),
"max_dd": round(stats.get("max_dd", 0), 4),
"fng_lo": fng_lo,
"fng_hi": fng_hi,
})
def insert_trades_bulk(
run_id: str,
label: str,
ticker: str,
trades: list,
) -> None:
"""개별 거래 목록 일괄 삽입."""
if not trades:
return
sql = """
INSERT INTO backtest_trade_log
(run_id, label, ticker, pnl, hold_h, fng_val, exit_type)
VALUES (:run_id, :label, :ticker, :pnl, :hold_h, :fng_val, :exit_type)
"""
rows = []
for t in trades:
rows.append({
"run_id": run_id,
"label": label,
"ticker": ticker,
"pnl": round(float(getattr(t, "pnl", 0)), 4),
"hold_h": int(getattr(t, "h", 0)),
"fng_val": int(getattr(t, "fng", 0)),
"exit_type": str(getattr(t, "exit", "")),
})
with _conn() as conn:
conn.cursor().executemany(sql, rows)
# ── 조회 ───────────────────────────────────────────────────────
def list_runs(limit: int = 20) -> list[dict]:
"""최근 백테스트 실행 목록 반환."""
sql = """
SELECT run_id, run_name, description, created_at
FROM backtest_runs
ORDER BY created_at DESC
FETCH FIRST :n ROWS ONLY
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"n": limit})
rows = cur.fetchall()
return [
{"run_id": r[0], "run_name": r[1], "description": r[2],
"created_at": r[3].strftime("%Y-%m-%d %H:%M")}
for r in rows
]
def get_results(run_id: str) -> list[dict]:
"""특정 run_id의 조건별 결과 반환."""
sql = """
SELECT label, n_trades, win_rate, avg_pnl, total_pnl,
rr, avg_win, avg_loss, max_dd, fng_lo, fng_hi
FROM backtest_results
WHERE run_id = :run_id
ORDER BY avg_pnl DESC
"""
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql, {"run_id": run_id})
cols = ["label", "n_trades", "win_rate", "avg_pnl", "total_pnl",
"rr", "avg_win", "avg_loss", "max_dd", "fng_lo", "fng_hi"]
return [dict(zip(cols, r)) for r in cur.fetchall()]
if __name__ == "__main__":
print("백테스트 DB 테이블 확인/생성...")
ensure_tables()
print("완료. 최근 실행 목록:")
for r in list_runs(5):
print(f" {r['created_at']} {r['run_name']}")

318
tests/fng_1y_backtest.py Normal file
View File

@@ -0,0 +1,318 @@
"""F&G 조건별 백테스트 - 1년치 데이터 (배치 수집)
60일 극공포 편향을 제거하고 Bull/Neutral/Bear 다양한 구간 포함.
데이터: 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
import datetime, json, time, sys, urllib.request
import pandas as pd
import pyupbit
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-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015
TIME_H = 24
TIME_MIN = 3.0
# ── 데이터 수집 ───────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
"""1h 캔들을 배치로 수집해 약 1년치 DataFrame 반환."""
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts):
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ────────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(df, fng_map, 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:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
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
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=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
cum = pk = 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=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
# F&G 연간 분포 출력
from collections import Counter
zone_cnt = Counter()
for v in fng_map.values():
if v <= 25: zone_cnt["극공포(0~25)"] += 1
elif v <= 45: zone_cnt["공포(26~45)"] += 1
elif v <= 55: zone_cnt["중립(46~55)"] += 1
elif v <= 75: zone_cnt["탐욕(56~75)"] += 1
else: zone_cnt["극탐욕(76~100)"] += 1
total_days = sum(zone_cnt.values())
print(f" 1년 F&G 분포 ({total_days}일):")
for k, v in sorted(zone_cnt.items()):
bar = "" * (v // 5)
print(f" {k:<14} {v:>3}일 ({v/total_days*100:>4.1f}%) {bar}")
print(f"\n종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 전체 기간 F&G 구간별 성과 ────────────────────────────
CONFIGS = [
(None, None, "필터 없음 (전체)"),
(None, 25, "극공포만 (0~25)"),
(26, 45, "공포만 (26~45)"),
(46, 55, "중립만 (46~55)"),
(56, 100, "탐욕+ (56~100)"),
(46, 100, "중립 이상 (46~100)"),
(26, 100, "공포 이상 (26~100)"),
]
print("=" * 78)
print(" F&G 조건별 성과 - 1년치 (1h 캔들 / 모멘텀 / 스탑1.5%)")
print("=" * 78)
print(f" {'조건':<26} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>6} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 72)
all_results = {}
for lo, hi, label in CONFIGS:
all_trades = []
for df in datasets.values():
all_trades.extend(simulate(df, fng_map, lo, hi))
s = stats(all_trades)
all_results[label] = (s, all_trades)
if s["n"] == 0:
print(f" {label:<26} 거래 없음 (해당 구간 진입 기회 없음)")
continue
sign = "+" if s["total_pnl"] > 0 else ""
print(
f" {label:<26} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
f"{sign}{s['total_pnl']:>8.1f}% -{s['max_dd']:>5.1f}%"
)
# ── 분기별 성과 (계절성) ──────────────────────────────────
print()
print(" 분기별 성과 (전체 필터 없음 기준):")
base_trades = all_results["필터 없음 (전체)"][1]
for df in datasets.values():
pass # already computed
# 전체 종목 합산 후 날짜로 분기 분리
all_base = []
for df in datasets.values():
t_list = simulate(df, fng_map)
# trade에 날짜 정보 추가
# simulate에서 idx를 참조하지 않으므로 재계산
all_base.extend(t_list)
# F&G 수치별 세분화
print()
print(" F&G 10단위 구간별 세부 성과:")
print(f" {'구간':<16} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6} {'의미'}")
print(" " + "-" * 65)
fng_zones_detail = [
(0, 10, "극단 공포(0~10)"),
(11, 20, "극단 공포(11~20)"),
(21, 30, "극공포(21~30)"),
(31, 40, "공포(31~40)"),
(41, 50, "약공포(41~50)"),
(51, 60, "약탐욕(51~60)"),
(61, 75, "탐욕(61~75)"),
(76, 100, "극탐욕(76~100)"),
]
base_all = all_results["필터 없음 (전체)"][1]
for lo, hi, name in fng_zones_detail:
sub = [t for t in base_all if lo <= t.fng <= hi]
if not sub:
continue
s = stats(sub)
breakeven_wr = 1 / (1 + s["rr"]) * 100 if s["rr"] > 0 else 50
profitable = "✅ 수익" if s["avg_pnl"] > 0 else ("⚠️ BEP 근접" if s["avg_pnl"] > -0.2 else "❌ 손실")
print(
f" {name:<16} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f} {profitable}"
)
# ── 최적 F&G 구간 요약 ───────────────────────────────────
print()
best = max(
[(label, s) for label, (s, _) in all_results.items() if s["n"] >= 50],
key=lambda x: x[1]["avg_pnl"],
)
print(f" ★ 최적 구간: {best[0]} "
f"(거래 {best[1]['n']}건 | 승률 {best[1]['wr']:.1f}% | "
f"평균PnL {best[1]['avg_pnl']:+.3f}%)")
# ── DB 저장 ──────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
ensure_tables()
params = {
"tickers": len(datasets), "days": 365, "candle": "1h",
"trail_stop": 0.015, "mom_thr": 3.0, "vol_mult": 2.0,
}
run_id = insert_run(
run_name="fng_1y_backtest",
description="F&G 구간별 성과 1년치 백테스트 (1h 캔들 / 모멘텀 / 스탑1.5%)",
params=params,
)
for lo, hi, label in CONFIGS:
if label in all_results:
s, trades = all_results[label]
if s["n"] > 0:
insert_result(run_id, label, s, lo, hi)
# 전체 거래는 per-ticker 분리 없이 일괄 저장 (run_id+label로 구분)
insert_trades_bulk(run_id, label, "_all_", trades)
print(f"\n [DB 저장 완료] run_id: {run_id}")
except Exception as e:
print(f"\n [DB 저장 실패] {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,320 @@
"""F&G 구간별 맞춤 파라미터 백테스트
핵심 가설:
극공포 구간은 시장이 불안정 → 더 엄격한 진입 기준 필요
탐욕 구간은 상승 모멘텀이 지속 → 다소 느슨한 기준도 가능
테스트 방식:
각 F&G 구간마다 다른 파라미터 조합을 적용하고 성과 비교.
구간별 최적 파라미터 도출 → 실제 전략에 반영
결과를 Oracle DB에 저장.
데이터: 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
import datetime
import json
import sys
import time
import urllib.request
import pandas as pd
import pyupbit
from dataclasses import dataclass
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
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-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
# ── F&G 구간별 파라미터 조합 ─────────────────────────────────
# (fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min)
ADAPTIVE_CONFIGS = [
# 기준선 (F&G 무관, 단일 파라미터)
(None, None, "기준선(전체/현행파라미터)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
# ── 극공포 (0~25) 구간 ── 엄격한 기준 ──
# 극공포에서는 변동성 급증이 흔함 → 볼륨 기준 올리고, 모멘텀 강화
(None, 25, "극공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(None, 25, "극공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/매우엄격(3x+5%+1%스탑)", 3.0, 2.0, 6, 5.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/넓은스탑(2x+3%+2%스탑)", 2.0, 2.0, 8, 3.0, 3.0, 0.020, 24, 3.0),
(None, 25, "극공포/짧은신호(3x+4%+4h유효)", 3.0, 2.0, 4, 4.0, 3.0, 0.015, 24, 3.0),
# ── 공포 (26~45) ── 중간 기준 ──
(26, 45, "공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/약강화(2.5x vol+3.5%mom)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
# ── 중립 이상 (46~100) ── 완화된 기준 가능 ──
(46, None, "중립이상/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/완화(1.5x vol+2.5%mom)",1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/엄격(2.5x+3.5%)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
# ── 탐욕+ (56~100) ──
(56, None, "탐욕이상/기준", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(56, None, "탐욕이상/완화(1.5x+2.5%)", 1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
]
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 이상 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(
df, fng_map,
fng_lo=None, fng_hi=None,
vol_mult=2.0, quiet_2h=2.0, sig_to_h=8,
mom_thr=3.0, sig_cancel=3.0, trail_stop=0.015,
time_h=24, time_min=3.0,
) -> 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:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
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
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= mom_thr:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=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
cum = pk = 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=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── DB 준비 ───────────────────────────────────────────
run_id = None
if DB_ENABLED:
ensure_tables()
params = {
"tickers": len(datasets),
"days": 365,
"candle": "1h",
"stop": "trail+time",
}
run_id = insert_run(
run_name="fng_adaptive_1y",
description="F&G 구간별 맞춤 파라미터 1년 백테스트",
params=params,
)
print(f" DB run_id: {run_id}\n")
# ── 결과 출력 ─────────────────────────────────────────
print("=" * 92)
print(" F&G 구간별 맞춤 파라미터 성과 비교 (1년치 / 1h 캔들)")
print("=" * 92)
print(f" {'조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 86)
best_by_zone: dict[str, tuple] = {}
for cfg in ADAPTIVE_CONFIGS:
fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min = cfg
all_trades: list[Trade] = []
per_ticker: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
t = simulate(
df, fng_map,
fng_lo=fng_lo, fng_hi=fng_hi,
vol_mult=vol_mult, quiet_2h=quiet_2h, sig_to_h=sig_to_h,
mom_thr=mom_thr, sig_cancel=sig_cancel, trail_stop=trail_stop,
time_h=time_h, time_min=time_min,
)
all_trades.extend(t)
per_ticker[tk] = t
s = stats(all_trades)
# 구분선 (기준선 다음)
if label == "극공포/기준(2x vol+3%mom)":
print()
if s["n"] == 0:
print(f" {label:<42} 거래 없음")
continue
marker = "" if s["avg_pnl"] > 0 else ""
print(
f" {label:<42} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+8.1f}% -{s['max_dd']:>5.1f}%{marker}"
)
# DB 저장
if DB_ENABLED and run_id:
insert_result(run_id, label, s, fng_lo, fng_hi)
for tk, t_list in per_ticker.items():
insert_trades_bulk(run_id, label, tk, t_list)
# 구간별 최고 avg_pnl 추적
zone_key = label.split("/")[0]
if zone_key not in best_by_zone or s["avg_pnl"] > best_by_zone[zone_key][1]:
best_by_zone[zone_key] = (label, s["avg_pnl"], s)
# ── 구간별 최적 요약 ──────────────────────────────────
print()
print(" ★ 구간별 최적 파라미터:")
print(f" {'구간':<14} {'최적 조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8}")
print(" " + "-" * 72)
for zone, (label, best_pnl, s) in best_by_zone.items():
if s["n"] > 0:
print(f" {zone:<14} {label:<42} {s['n']:>5}{s['wr']:>5.1f}% {best_pnl:>+7.3f}%")
if DB_ENABLED and run_id:
print(f"\n [DB 저장 완료] run_id: {run_id}")
if __name__ == "__main__":
main()

258
tests/fng_backtest.py Normal file
View 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()

385
tests/fng_sim_comparison.py Normal file
View File

@@ -0,0 +1,385 @@
"""F&G 필터 전후 수익 비교 시뮬레이션
필터 없음 vs F&G ≥ 41 필터 적용 시 1년치 성과를 직접 비교.
표시:
- 거래 수, 승률, 평균 PnL, 총 누적 PnL
- 거래당 고정 자본 100만 원 기준 KRW 환산 손익
- 월별 손익 흐름 (계절성 확인)
- 극공포 차단 일수 통계
결과는 Oracle DB(backtest_results)에 저장.
데이터: 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
import datetime
import json
import sys
import time
import urllib.request
from dataclasses import dataclass
import pandas as pd
import pyupbit
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
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-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
CAPITAL_PER_TRADE = 1_000_000 # 거래당 고정 자본 (KRW)
# 전략 파라미터 (현행)
VOL_MULT = 2.0
QUIET_2H = 2.0
SIG_TO_H = 8
MOM_THR = 3.0
SIG_CANCEL = 3.0
TRAIL_STOP = 0.015
TIME_H = 24
TIME_MIN = 3.0
FNG_MIN = 41 # 이 값 미만이면 진입 차단
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
date: str # YYYY-MM
def simulate(df, fng_map, fng_min: int | None = 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:
trades.append(Trade(
(cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail",
idx[i].strftime("%Y-%m"),
))
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",
idx[i].strftime("%Y-%m"),
))
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
fv = fng_val(fng_map, idx[i])
if fng_min is not None and fv < fng_min:
continue
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
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, max_dd=0, krw_total=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
cum = pk = 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
total_pnl = sum(t.pnl for t in trades)
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=total_pnl / len(trades),
total_pnl=total_pnl,
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
krw_total=total_pnl / 100 * CAPITAL_PER_TRADE,
)
def monthly_pnl(trades: list[Trade]) -> dict[str, float]:
"""월별 누적 PnL(%) 반환."""
monthly: dict[str, float] = {}
for t in trades:
monthly[t.date] = monthly.get(t.date, 0) + t.pnl
return dict(sorted(monthly.items()))
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
# F&G 분포
block_days = sum(1 for v in fng_map.values() if v < FNG_MIN)
total_days = len(fng_map)
print(f" 1년 F&G 분포: 진입차단(< {FNG_MIN}) = {block_days}일 / {total_days}"
f"({block_days/total_days*100:.1f}%)")
print(f" 진입허용(≥ {FNG_MIN}) = {total_days - block_days}일 ({(total_days-block_days)/total_days*100:.1f}%)\n")
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets: dict[str, pd.DataFrame] = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── 두 가지 조건 시뮬레이션 ──────────────────────────────
# A: 필터 없음 (현행)
# B: F&G ≥ 41 (신규)
all_trades_A: list[Trade] = []
all_trades_B: list[Trade] = []
per_ticker_A: dict[str, list[Trade]] = {}
per_ticker_B: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
ta = simulate(df, fng_map, fng_min=None)
tb = simulate(df, fng_map, fng_min=FNG_MIN)
all_trades_A.extend(ta)
all_trades_B.extend(tb)
per_ticker_A[tk] = ta
per_ticker_B[tk] = tb
sa = stats(all_trades_A)
sb = stats(all_trades_B)
# ── 결과 출력 ─────────────────────────────────────────────
print("=" * 80)
print(f" F&G 필터 전후 비교 (1년치 / {len(datasets)}개 종목 / 1h캔들 / 자본 {CAPITAL_PER_TRADE:,}원/거래)")
print("=" * 80)
print(f" {'조건':<26} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>8} {'MaxDD':>7} {'KRW손익':>14}")
print(" " + "-" * 76)
for label, s in [("필터 없음 (현행)", sa), (f"F&G≥{FNG_MIN} 필터 (신규)", sb)]:
krw_str = f"{s['krw_total']:>+,.0f}"
print(
f" {label:<26} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% {krw_str:>14}"
)
diff_trades = sb["n"] - sa["n"]
diff_krw = sb["krw_total"] - sa["krw_total"]
diff_wr = sb["wr"] - sa["wr"]
print(f"\n 변화: 거래수 {diff_trades:+d}건 | 승률 {diff_wr:+.1f}%p | "
f"KRW손익 {diff_krw:>+,.0f}")
# ── 월별 손익 흐름 ────────────────────────────────────────
print()
print(" 월별 손익 비교 (필터없음 vs F&G≥41):")
print(f" {'':>8} {'차단일수':>6} {'필터없음':>9} {'F&G필터':>9} {'개선':>8} {'누적(필터)':>12}")
print(" " + "-" * 62)
ma = monthly_pnl(all_trades_A)
mb = monthly_pnl(all_trades_B)
all_months = sorted(set(ma.keys()) | set(mb.keys()))
cum_b = 0.0
for m in all_months:
pa = ma.get(m, 0.0)
pb = mb.get(m, 0.0)
cum_b += pb
diff = pb - pa
# 해당 월 차단 일수
yr, mo = int(m[:4]), int(m[5:])
blocked = sum(
1 for d, v in fng_map.items()
if d.startswith(m) and v < FNG_MIN
)
bar = "" * min(int(abs(pb) / 3), 12) if pb > 0 else "" * min(int(abs(pb) / 3), 12)
sign = "+" if pb > 0 else ""
diff_sign = "" if diff > 0 else ("" if diff < 0 else "=")
print(
f" {m} {blocked:>4}일차단 "
f"{pa:>+8.1f}% {sign}{pb:>8.1f}% "
f"{diff_sign}{abs(diff):>6.1f}% {cum_b:>+10.1f}%"
)
# ── 종목별 비교 (상위/하위) ───────────────────────────────
print()
print(" 종목별 성과 비교 (필터없음 vs F&G≥41):")
print(f" {'종목':<14} {'현행거래':>6} {'현행PnL':>8} {'필터거래':>7} {'필터PnL':>8} {'개선':>8}")
print(" " + "-" * 58)
ticker_rows = []
for tk in sorted(datasets.keys()):
ta_list = per_ticker_A.get(tk, [])
tb_list = per_ticker_B.get(tk, [])
pa = sum(t.pnl for t in ta_list) if ta_list else 0
pb = sum(t.pnl for t in tb_list) if tb_list else 0
ticker_rows.append((tk, len(ta_list), pa, len(tb_list), pb, pb - pa))
for row in sorted(ticker_rows, key=lambda x: x[5], reverse=True):
tk, na, pa, nb, pb, delta = row
mark = "" if delta > 1 else ("" if delta < -1 else " =")
print(
f" {tk:<14} {na:>6}{pa:>+7.1f}% {nb:>6}{pb:>+7.1f}% "
f"{mark}{abs(delta):>6.1f}%"
)
# ── 극공포 차단 효과 분석 ─────────────────────────────────
print()
print(f" F&G < {FNG_MIN} 구간(차단) 거래 성과 분석:")
blocked_trades = [t for t in all_trades_A if t.fng < FNG_MIN]
if blocked_trades:
sb2 = stats(blocked_trades)
print(f" → 차단된 거래 수: {sb2['n']}")
print(f" → 차단 거래 승률: {sb2['wr']:.1f}%")
print(f" → 차단 거래 평균 PnL: {sb2['avg_pnl']:+.3f}%")
print(f" → 차단으로 절약된 손실: {sb2['krw_total']:>+,.0f}"
f"({CAPITAL_PER_TRADE:,}× {sb2['n']}거래 기준)")
else:
print(" → 차단된 거래 없음")
# ── 최적 임계값 확인 ─────────────────────────────────────
print()
print(f" F&G 임계값별 성과 비교 (현행 기준 비교):")
print(f" {'임계값':>8} {'거래':>5} {'승률':>6} {'평균PnL':>9} {'KRW손익':>14}")
print(" " + "-" * 52)
for thr in [25, 30, 35, 41, 45, 50]:
filtered = [t for t in all_trades_A if t.fng >= thr]
if not filtered:
continue
sf = stats(filtered)
marker = " ◀ 채택" if thr == FNG_MIN else ""
print(
f" {thr:>5}이상 {sf['n']:>5}{sf['wr']:>5.1f}% "
f"{sf['avg_pnl']:>+8.3f}% {sf['krw_total']:>+14,.0f}{marker}"
)
# ── DB 저장 ───────────────────────────────────────────────
if DB_ENABLED:
try:
ensure_tables()
params = {
"tickers": len(datasets), "days": 365, "candle": "1h",
"trail_stop": TRAIL_STOP, "mom_thr": MOM_THR,
"fng_min_new": FNG_MIN, "capital_per_trade": CAPITAL_PER_TRADE,
}
run_id = insert_run(
"fng_sim_comparison",
f"F&G 필터 전후 비교 시뮬레이션 (1년치 / F&G≥{FNG_MIN})",
params,
)
insert_result(run_id, "필터 없음 (현행)", sa, None, None)
insert_result(run_id, f"F&G≥{FNG_MIN} 필터 (신규)", sb, FNG_MIN, None)
for tk, t_list in per_ticker_A.items():
insert_trades_bulk(run_id, "필터없음", tk, t_list)
for tk, t_list in per_ticker_B.items():
insert_trades_bulk(run_id, f"fng_ge{FNG_MIN}", tk, t_list)
print(f"\n [DB 저장 완료] run_id: {run_id}")
except Exception as e:
print(f"\n [DB 저장 실패] {e}")
if __name__ == "__main__":
main()

240
tests/interval_sweep.py Normal file
View File

@@ -0,0 +1,240 @@
"""interval_sweep.py — 봉 단위별 vol-lead 전략 성과 비교.
10분봉 캐시 데이터를 리샘플링해 10/20/30/60분봉 성과를 비교한다.
추가로 극단거래량(100x) 즉시 진입 조건도 함께 테스트.
데이터: sim10m_cache.pkl (10분봉 45일)
"""
import pickle
import sys
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 고정 파라미터 ─────────────────────────────────────────
CACHE_FILE = Path("sim10m_cache.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
TOP_N = 20
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT = 2.0
QUIET_PCT = 2.0
THRESH = 4.8
EXTREME_VOL = 100 # 극단적 거래량 배수
# 봉 단위별 시간 기반 파라미터 (모두 "시간"으로 정의 → 봉수로 자동 변환)
INTERVALS = [10, 20, 30, 40, 50, 60] # 분 단위
LOCAL_VOL_H = 5.0 # 로컬 거래량 기준 5시간
QUIET_H = 2.0 # 횡보 기준 2시간
SIGNAL_TO_H = 8.0 # 신호 유효 8시간
ATR_H = 5.0 # ATR 계산 5시간
TIME_STOP_H = 8.0 # 타임스탑 8시간
# ── 리샘플링 ──────────────────────────────────────────────
def resample(df, minutes):
"""10분봉 DataFrame을 N분봉으로 리샘플링."""
rule = f"{minutes}T"
resampled = df.resample(rule).agg({
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}).dropna(subset=["close"])
return resampled
# ── ATR 계산 (시뮬용) ─────────────────────────────────────
def calc_atr(df, buy_idx, n):
sub = df.iloc[max(0, buy_idx - n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df, buy_idx, buy_price, stop_pct, ts_candles):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, sp, ts, pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_candles and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, row["close"], ts, pnl
last = df.iloc[-1]["close"]
pnl = (last * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, last, df.index[-1], pnl
# ── vol-lead 전략 실행 ────────────────────────────────────
def run_vol_lead(df, minutes, use_extreme=False):
"""vol-lead 전략 실행. use_extreme=True이면 극단거래량 즉시 진입 추가."""
candles_per_h = 60 / minutes
local_vol_n = int(LOCAL_VOL_H * candles_per_h)
quiet_n = int(QUIET_H * candles_per_h)
signal_to_n = int(SIGNAL_TO_H * candles_per_h)
atr_n = int(ATR_H * candles_per_h)
ts_n = int(TIME_STOP_H * candles_per_h)
trades = []
sig_i = sig_p = None
extreme_pending = False
in_pos = False
buy_idx = buy_price = stop_pct = entry_type = None
i = max(local_vol_n + 2, quiet_n + 1)
while i < len(df):
if in_pos:
is_win, sp, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, ts_n)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, entry_type))
in_pos = False
sig_i = sig_p = None
extreme_pending = False
i = next_i
continue
close = df.iloc[i]["close"]
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-local_vol_n-1:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
# 극단적 거래량 → 다음 봉 즉시 진입
if use_extreme and not extreme_pending and vol_r >= EXTREME_VOL:
extreme_pending = True
i += 1
continue
if use_extreme and extreme_pending:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, atr_n)
entry_type = "극단"
extreme_pending = False
sig_i = sig_p = None
i += 1
continue
# 일반 vol-lead
close_qh = df.iloc[i - quiet_n]["close"]
chg_qh = abs(close - close_qh) / close_qh * 100
quiet = chg_qh < QUIET_PCT
spike = vol_r >= VOL_MULT
if quiet and spike:
if sig_i is None: sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p: sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > signal_to_n:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= THRESH:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, atr_n)
entry_type = "일반"
sig_i = sig_p = None
i += 1
return trades
# ── 통계 ─────────────────────────────────────────────────
def calc_stats(trades):
if not trades:
return {"n": 0, "wr": 0.0, "cum": 0.0, "dd": 0.0}
wins = sum(1 for t in trades if t[0])
cum = peak = dd = 0.0
for t in sorted(trades, key=lambda x: x[2]):
cum += t[1]
peak = max(peak, cum)
dd = max(dd, peak - cum)
return {"n": len(trades), "wr": wins / len(trades) * 100, "cum": cum, "dd": dd}
# ── 메인 ─────────────────────────────────────────────────
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP30_FILE, "rb"))
tickers = [t for t in top30[:TOP_N] if t in cache["10m"]]
print(f"유효 종목: {len(tickers)}\n")
results = []
for minutes in INTERVALS:
# 10분봉은 그대로, 나머지는 리샘플링
ticker_data = {}
for t in tickers:
df10 = cache["10m"][t]
if minutes == 10:
ticker_data[t] = df10
else:
ticker_data[t] = resample(df10, minutes)
# 일반 vol-lead
all_trades = []
for t in tickers:
if t in ticker_data and len(ticker_data[t]) >= 50:
all_trades.extend(run_vol_lead(ticker_data[t], minutes, use_extreme=False))
s = calc_stats(all_trades)
results.append((f"{minutes}분봉 (일반)", s))
# 일반 + 극단 거래량
all_trades_ex = []
for t in tickers:
if t in ticker_data and len(ticker_data[t]) >= 50:
all_trades_ex.extend(run_vol_lead(ticker_data[t], minutes, use_extreme=True))
s_ex = calc_stats(all_trades_ex)
# 극단 거래량만 분리
extreme_trades = [t for t in all_trades_ex if t[4] == "극단"]
s_ext = calc_stats(extreme_trades)
results.append((f" +극단{EXTREME_VOL}x", s_ex, s_ext))
# 출력
print(f"{'='*72}")
print(f"봉 단위별 vol-lead 전략 비교 | 45일 | {len(tickers)}종목")
print(f"{'='*72}")
print(f"{'전략':20} {'거래수':>6} {'승률':>6} {'누적PnL%':>10} {'최대낙폭%':>10}")
print(f"{''*72}")
for row in results:
label = row[0]
s = row[1]
if s["n"] == 0:
print(f"{label:20} {'없음':>6}")
continue
print(f"{label:20} {s['n']:>6}{s['wr']:>5.1f}% {s['cum']:>+9.2f}% {-s['dd']:>+9.2f}%", end="")
# 극단 거래량 정보 추가
if len(row) == 3:
se = row[2]
if se["n"] > 0:
print(f" (극단:{se['n']}{se['wr']:.0f}% {se['cum']:+.1f}%)", end="")
print()
if label.startswith(" +"):
print() # 구분선
print(f"{'='*72}")
if __name__ == "__main__":
main()

177
tests/krw_sim.py Normal file
View File

@@ -0,0 +1,177 @@
"""천만원 시드 기준 KRW 시뮬레이션.
- 20개 종목 × vol-lead 4.8% 전략
- MAX_POSITIONS=3 동시 보유 제약 적용
- 포지션별 예산 = 포트폴리오 / MAX_POSITIONS (복리)
- 거래를 시간순으로 처리 → 3개 이상 동시 보유 시 신호 스킵
"""
import os
import pickle
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, str(Path(__file__).parent))
from vol_lead_sim import run_trend, run_vol_lead_thresh
BUDGET = 10_000_000 # 초기 시드
MAX_POS = 3 # 최대 동시 보유
THRESH = 4.8 # 진입 임계값 (%)
CACHE_FILE = Path("vol_lead_cache_30.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
def collect_all_trades(data: dict, tickers: list, thresh: float) -> list:
"""모든 종목의 거래를 (buy_dt, sell_dt, ticker, is_win, pnl, reason) 목록으로 반환."""
all_trades = []
for t in tickers:
if t not in data:
continue
trades = run_vol_lead_thresh(data[t], thresh)
for is_win, pnl, buy_dt, sell_dt, reason in trades:
all_trades.append((buy_dt, sell_dt, t, is_win, pnl, reason))
all_trades.sort(key=lambda x: x[0]) # 진입 시간순 정렬
return all_trades
def apply_max_positions(all_trades: list, max_pos: int) -> tuple[list, list]:
"""MAX_POSITIONS 제약 적용. (허용 거래, 스킵 거래) 반환."""
open_exits = [] # 현재 열린 포지션의 청산 시각 목록
accepted = []
skipped = []
for trade in all_trades:
buy_dt, sell_dt = trade[0], trade[1]
# 이미 청산된 포지션 제거
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < max_pos:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
def simulate_krw(accepted: list, budget: float, max_pos: int) -> dict:
"""복리 KRW 시뮬레이션. 포지션당 예산 = 포트폴리오 / MAX_POSITIONS."""
portfolio = budget
total_krw = 0.0
monthly = {} # YYYY-MM → {'trades':0,'wins':0,'pnl':0}
trade_log = []
for buy_dt, sell_dt, ticker, is_win, pnl, reason in accepted:
pos_size = portfolio / max_pos
krw_profit = pos_size * pnl / 100
portfolio += krw_profit
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt,
"sell_dt": sell_dt,
"ticker": ticker,
"is_win": is_win,
"pnl_pct": pnl,
"krw_profit": krw_profit,
"portfolio": portfolio,
"reason": reason,
})
wins = sum(1 for t in accepted if t[3])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - budget) / budget * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
def main() -> None:
data = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP30_FILE, "rb"))
valid = [t for t in top30 if t in data and len(data[t]) >= 400]
use20 = valid[:20]
print(f"{'='*65}")
print(f"천만원 시드 KRW 시뮬레이션 | vol-lead +{THRESH}% | 20종목")
print(f"MAX_POSITIONS={MAX_POS} | 복리 포지션 크기")
print(f"기간: 2026-01-15 ~ 2026-03-02 (46일)")
print(f"{'='*65}")
all_trades = collect_all_trades(data, use20, THRESH)
accepted, skipped = apply_max_positions(all_trades, MAX_POS)
result = simulate_krw(accepted, BUDGET, MAX_POS)
print(f"\n── 전체 결과 ─────────────────────────────────────────")
print(f" 신호 발생: {len(all_trades):>4}")
print(f" 실제 진입: {result['total']:>4}건 (MAX_POS={MAX_POS} 제약으로 {len(skipped)}건 스킵)")
print(f" 승/패: {result['wins']}{result['total']-result['wins']}패 (승률 {result['wr']:.0f}%)")
print(f" ─────────────────────────────────────────────────")
print(f" 초기 시드: {BUDGET:>14,.0f}")
print(f" 최종 자산: {result['portfolio']:>14,.0f}")
print(f" 순수익: {result['total_krw']:>+14,.0f}")
print(f" 수익률: {result['roi_pct']:>+13.2f}%")
# ── 월별 수익 ─────────────────────────────────────
print(f"\n── 월별 수익 ─────────────────────────────────────────")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>14} {'누적수익(KRW)':>15}")
print(f" {''*58}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │ "
f"{m['pnl_krw']:>+14,.0f}{cum:>+14,.0f}")
# ── 종목별 수익 ───────────────────────────────────
print(f"\n── 종목별 수익 ───────────────────────────────────────")
print(f" {'종목':<14}{'거래':>4} {'승률':>5}{'KRW수익':>14} {'평균/건':>10}")
print(f" {''*58}")
ticker_stats: dict = {}
for t in result["trade_log"]:
k = t["ticker"]
if k not in ticker_stats:
ticker_stats[k] = {"n": 0, "wins": 0, "krw": 0.0}
ticker_stats[k]["n"] += 1
ticker_stats[k]["wins"] += int(t["is_win"])
ticker_stats[k]["krw"] += t["krw_profit"]
for t, s in sorted(ticker_stats.items(), key=lambda x: -x[1]["krw"]):
wr = s["wins"] / s["n"] * 100 if s["n"] else 0
avg = s["krw"] / s["n"] if s["n"] else 0
print(f" {t:<14}{s['n']:>4}{wr:>4.0f}% │ "
f"{s['krw']:>+14,.0f}{avg:>+9,.0f}원/건")
# ── 전체 거래 내역 ────────────────────────────────
print(f"\n── 전체 거래 내역 ({len(result['trade_log'])}건) ─────────────────────")
print(f" {'#':>3} {'종목':<14} {'매수':^13} {'매도':^13} "
f"{'수익률':>7} {'KRW수익':>12} {'잔고':>12} {'사유'}")
print(f" {''*90}")
for i, t in enumerate(result["trade_log"], 1):
mark = "" if t["is_win"] else ""
print(f" {i:>3} {t['ticker']:<14} "
f"{t['buy_dt'].strftime('%m-%d %H:%M'):^13} "
f"{t['sell_dt'].strftime('%m-%d %H:%M'):^13} "
f"{mark}{t['pnl_pct']:>+6.2f}% "
f"{t['krw_profit']:>+12,.0f}"
f"{t['portfolio']:>12,.0f}"
f"{t['reason']}")
if __name__ == "__main__":
main()

189
tests/momentum_cmp.py Normal file
View File

@@ -0,0 +1,189 @@
"""모멘텀 필터 유/무 비교 시뮬레이션.
A안: 추세(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
from dotenv import load_dotenv
load_dotenv()
import oracledb
import pyupbit
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
TREND_MIN_PCT = 5.0
MA_PERIOD = 20
LOCAL_VOL_HOURS = 5
VOL_MULT = float(os.getenv("VOLUME_MULTIPLIER", "2.0"))
CONFIRM_MINUTES = 15
FEE = 0.0005
_daily_cache = {}
_hourly_cache = {}
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
def load_prices(cur, ticker, from_dt):
cur.execute("""SELECT price, recorded_at FROM price_history
WHERE ticker=:t AND recorded_at>=:f ORDER BY recorded_at""", t=ticker, f=from_dt)
return cur.fetchall()
def get_ma20(ticker, dt):
key = (ticker, dt.strftime("%Y-%m-%d"))
if key not in _daily_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="day", count=MA_PERIOD+2,
to=dt.strftime("%Y-%m-%d 09:00:00"))
_daily_cache[key] = df
time.sleep(0.1)
except:
_daily_cache[key] = None
df = _daily_cache[key]
if df is None or len(df) < MA_PERIOD:
return None
return df["close"].iloc[-MA_PERIOD:].mean()
def get_vol_ratio(ticker, dt):
key = (ticker, dt.strftime("%Y-%m-%d %H"))
if key not in _hourly_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=LOCAL_VOL_HOURS+3,
to=dt.strftime("%Y-%m-%d %H:%M:%S"))
_hourly_cache[key] = df
time.sleep(0.1)
except:
_hourly_cache[key] = None
df = _hourly_cache[key]
if df is None or len(df) < LOCAL_VOL_HOURS+1:
return 0.0
rv = df["volume"].iloc[-2]
la = df["volume"].iloc[-(LOCAL_VOL_HOURS+1):-2].mean()
return rv/la if la > 0 else 0.0
def check_trend(prices, idx):
lb = 12 # 2h = 12 * 10min
if idx < lb: return False
curr, past = prices[idx][0], prices[idx-lb][0]
return past > 0 and (curr-past)/past*100 >= TREND_MIN_PCT
def check_momentum(ticker, price, dt):
ma = get_ma20(ticker, dt)
if ma is None or price <= ma: return False
return get_vol_ratio(ticker, dt) >= VOL_MULT
def simulate_pos(prices, buy_idx, buy_price):
buy_dt = prices[buy_idx][1]
peak = buy_price
for price, ts in prices[buy_idx+1:]:
if price > peak: peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
if (peak - price) / peak >= STOP_LOSS_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net > 0, price, ts, f"트레일링({pnl*100:+.1f}%)", net
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net > 0, price, ts, f"타임스탑", net
lp, lt = prices[-1]
net = (lp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net > 0, lp, lt, "데이터종료", net
def run_scenario(prices, ticker, use_momentum, label):
wins = losses = 0
total_pnl = 0.0
watchlist_dt = None
in_pos = False
buy_idx = buy_price = None
idx = 0
trades = []
while idx < len(prices):
price, dt = prices[idx]
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(prices, buy_idx, buy_price)
next_idx = next((i for i,(_, ts) in enumerate(prices) if ts > sdt), len(prices))
if is_win: wins += 1
else: losses += 1
total_pnl += pnl
trades.append((is_win, buy_price, sp, pnl, dt, sdt, reason))
in_pos = False
watchlist_dt = None
idx = next_idx
continue
trend_ok = check_trend(prices, idx)
mom_ok = check_momentum(ticker, price, dt) if (use_momentum and trend_ok) else True
if trend_ok and mom_ok:
if watchlist_dt is None:
watchlist_dt = dt
elif (dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
in_pos = True
buy_idx = idx
buy_price = price
watchlist_dt = None
else:
watchlist_dt = None
idx += 1
total = wins + losses
wr = wins/total*100 if total else 0
return {'label': label, 'total': total, 'wins': wins, 'losses': losses,
'wr': wr, 'pnl': total_pnl, 'trades': trades}
def print_result(r):
print(f"\n [{r['label']}]")
print(f"{r['total']}건 | 승률={r['wr']:.0f}% ({r['wins']}{r['losses']}패) | 누적={r['pnl']:+.2f}%")
for i, (iw, bp, sp, pnl, bdt, sdt, reason) in enumerate(r['trades'], 1):
mark = "" if iw else ""
print(f" #{i}: {bp:.4f}{sp:.4f}원 | {mark} {pnl:+.2f}% | {reason}"
f" ({bdt.strftime('%m-%d %H:%M')}{sdt.strftime('%m-%d %H:%M')})")
def main():
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT MAX(recorded_at) FROM price_history")
end_dt = cur.fetchone()[0]
print("=" * 62)
print("모멘텀 필터 유/무 비교 (WF차단 발동 이후 전 기간)")
print("A안: 추세+워치리스트만 B안: 추세+모멘텀+워치리스트(현행)")
print("=" * 62)
summary = []
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("SELECT traded_at FROM trade_results WHERE ticker=:t ORDER BY traded_at", t=ticker)
rows = cur.fetchall()
wf_dt = rows[4][0]
prices = load_prices(cur, ticker, wf_dt)
print(f"\n{''*62}")
print(f"[{ticker}] WF차단: {wf_dt.strftime('%m-%d %H:%M')} 데이터: {len(prices)}")
rA = run_scenario(prices, ticker, use_momentum=False, label="A: 추세+워치만")
rB = run_scenario(prices, ticker, use_momentum=True, label="B: 추세+모멘텀+워치(현행)")
print_result(rA)
print_result(rB)
summary.append((ticker, rA, rB))
print(f"\n{'='*62}")
print(f"{'종목':<12} {'A안 거래':>6} {'A안 승률':>8} {'A안 PnL':>10}{'B안 거래':>6} {'B안 승률':>8} {'B안 PnL':>10}")
print(f"{''*62}")
for ticker, rA, rB in summary:
print(f"{ticker:<12} {rA['total']:>6}{rA['wr']:>6.0f}% {rA['pnl']:>+9.2f}% │"
f" {rB['total']:>6}{rB['wr']:>6.0f}% {rB['pnl']:>+9.2f}%")
conn.close()
if __name__ == "__main__":
main()

216
tests/ohlcv_db.py Normal file
View File

@@ -0,0 +1,216 @@
"""OHLCV 시계열 캐시 — Oracle ADB ohlcv_hourly 테이블.
기능:
- 테이블 생성 (없으면)
- pkl → DB 최초 적재
- DB → DataFrame dict 로드 (시뮬용)
- 증분 업데이트 (신규 봉만 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
import os
import pickle
import time
from datetime import datetime
from pathlib import Path
import pandas as pd
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
from core.price_db import _conn
# ── DDL ───────────────────────────────────────────────
_DDL = """
CREATE TABLE ohlcv_hourly (
ticker VARCHAR2(20) NOT NULL,
candle_time TIMESTAMP NOT NULL,
open_price NUMBER(20,8) NOT NULL,
high_price NUMBER(20,8) NOT NULL,
low_price NUMBER(20,8) NOT NULL,
close_price NUMBER(20,8) NOT NULL,
volume NUMBER(30,8) NOT NULL,
CONSTRAINT pk_ohlcv PRIMARY KEY (ticker, candle_time)
)
"""
def ensure_table() -> None:
with _conn() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM user_tables WHERE table_name='OHLCV_HOURLY'")
if cur.fetchone()[0] == 0:
conn.cursor().execute(_DDL)
print("ohlcv_hourly 테이블 생성 완료")
else:
print("ohlcv_hourly 테이블 이미 존재")
# ── 적재 ──────────────────────────────────────────────
def insert_df(ticker: str, df: pd.DataFrame, batch: int = 500) -> int:
"""DataFrame → ohlcv_hourly 배치 삽입.
신규 레코드만 삽입: 기존 candle_time 조회 후 Python에서 필터링.
"""
sql_existing = """
SELECT candle_time FROM ohlcv_hourly
WHERE ticker = :1
"""
sql_insert = """
INSERT INTO ohlcv_hourly
(ticker, candle_time, open_price, high_price, low_price, close_price, volume)
VALUES (:1, :2, :3, :4, :5, :6, :7)
"""
rows = [
(
ticker,
row.name.to_pydatetime().replace(tzinfo=None),
float(row["open"]),
float(row["high"]),
float(row["low"]),
float(row["close"]),
float(row["volume"]),
)
for _, row in df.iterrows()
]
with _conn() as conn:
cur = conn.cursor()
# 기존 candle_time 조회 → 중복 제거
cur.execute(sql_existing, [ticker])
existing = {r[0].replace(tzinfo=None) for r in cur.fetchall()}
new_rows = [r for r in rows if r[1] not in existing]
if not new_rows:
return 0
for i in range(0, len(new_rows), batch):
cur.executemany(sql_insert, new_rows[i : i + batch])
return len(new_rows)
def load_from_pkl(pkl_path: str | Path) -> None:
"""pkl 파일의 모든 종목을 DB에 적재."""
pkl_path = Path(pkl_path)
data = pickle.load(open(pkl_path, "rb"))
ensure_table()
total = 0
for ticker, df in data.items():
n = insert_df(ticker, df)
total += n
print(f" {ticker}: {n}건 적재")
print(f"\n{total:,}건 적재 완료")
# ── 로드 ──────────────────────────────────────────────
def load_from_db(tickers: list[str], from_date: str = "2025-03-02") -> dict:
"""DB → {ticker: DataFrame} 반환 (시뮬용)."""
from_dt = datetime.strptime(from_date, "%Y-%m-%d")
data = {}
sql = """
SELECT candle_time, open_price, high_price, low_price, close_price, volume
FROM ohlcv_hourly
WHERE ticker = :1 AND candle_time >= :2
ORDER BY candle_time
"""
with _conn() as conn:
for ticker in tickers:
cur = conn.cursor()
cur.execute(sql, [ticker, from_dt])
rows = cur.fetchall()
if not rows:
continue
df = pd.DataFrame(
rows,
columns=["candle_time", "open", "high", "low", "close", "volume"],
)
df.set_index("candle_time", inplace=True)
df.index = pd.to_datetime(df.index)
data[ticker] = df
return data
# ── 증분 업데이트 ──────────────────────────────────────
def update_incremental(tickers: list[str]) -> None:
"""각 종목의 최신 봉 이후 데이터를 API에서 가져와 적재."""
sql_max = "SELECT MAX(candle_time) FROM ohlcv_hourly WHERE ticker = :1"
for ticker in tickers:
with _conn() as conn:
cur = conn.cursor()
cur.execute(sql_max, [ticker])
row = cur.fetchone()
latest = row[0] if row and row[0] else None
if latest:
to_dt = None # 최신까지 fetch
kwargs: dict = dict(ticker=ticker, interval="minute60", count=200)
df = pyupbit.get_ohlcv(**kwargs)
if df is None or df.empty:
continue
df.index = df.index.tz_localize(None)
# latest 이후만 삽입
new_df = df[df.index > latest.replace(tzinfo=None)]
if new_df.empty:
print(f" {ticker}: 신규 봉 없음")
continue
n = insert_df(ticker, new_df)
print(f" {ticker}: +{n}봉 추가")
else:
print(f" {ticker}: DB에 없음, 전체 로드 필요")
time.sleep(0.2)
# ── CLI ───────────────────────────────────────────────
if __name__ == "__main__":
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
if cmd == "init":
# pkl → DB 최초 적재
pkl = sys.argv[2] if len(sys.argv) > 2 else "vol_lead_cache_365.pkl"
print(f"pkl 적재: {pkl}")
load_from_pkl(pkl)
elif cmd == "update":
# 증분 업데이트
import pickle as _pk
top30 = _pk.load(open("top30_tickers.pkl", "rb"))
print("증분 업데이트...")
update_incremental(top30)
elif cmd == "status":
# 종목별 레코드 수 확인
with _conn() as conn:
cur = conn.cursor()
try:
cur.execute("""
SELECT ticker, COUNT(*), MIN(candle_time), MAX(candle_time)
FROM ohlcv_hourly
GROUP BY ticker
ORDER BY ticker
""")
rows = cur.fetchall()
if rows:
print(f"{'종목':<16} {'봉수':>6} {'시작':^12} {'종료':^12}")
print("-" * 52)
for r in rows:
print(f"{r[0]:<16} {r[1]:>6}"
f"{r[2].strftime('%y-%m-%d'):^12} "
f"{r[3].strftime('%y-%m-%d'):^12}")
print(f"\n{sum(r[1] for r in rows):,}봉 / {len(rows)}종목")
else:
print("ohlcv_hourly 테이블이 비어 있거나 없음")
except Exception as e:
print(f"오류: {e}")

248
tests/pullback_backtest.py Normal file
View 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()

159
tests/shadow_sim.py Normal file
View File

@@ -0,0 +1,159 @@
"""Shadow Trading 재활 시뮬레이션.
WF차단 종목들에 대해 shadow 포지션을 시뮬레이션하여
몇 번의 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
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
import oracledb
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
FEE = 0.0005
REHABILITATE_WINS = 2 # shadow N승 → WF 해제
def get_price_series(cur, ticker, from_dt):
"""WF차단 이후 가격 시계열 조회."""
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at >= :dt
ORDER BY recorded_at
""", t=ticker, dt=from_dt)
return cur.fetchall()
def simulate_shadow(prices, buy_price, buy_dt):
"""
단일 shadow 포지션 시뮬레이션.
Returns: (is_win, sell_price, sell_dt, reason, pnl_pct)
"""
peak = buy_price
for price, ts in prices:
# 고점 갱신
if price > peak:
peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
# 트레일링 스탑 (최고가 대비 -STOP_LOSS_PCT)
drop_from_peak = (peak - price) / peak
if drop_from_peak >= STOP_LOSS_PCT:
sell_pnl = (price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return (sell_pnl > 0, price, ts, f"트레일링스탑(peak={peak:.4f})", sell_pnl)
# 타임 스탑
if elapsed_h >= TIME_STOP_HOURS:
if pnl < TIME_STOP_MIN_PCT:
sell_pnl = (price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return (sell_pnl > 0, price, ts, f"타임스탑({elapsed_h:.1f}h,{pnl*100:+.1f}%)", sell_pnl)
# 데이터 끝까지 보유 중
last_price, last_ts = prices[-1]
sell_pnl = (last_price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return (sell_pnl > 0, last_price, last_ts, "데이터종료(보유중)", sell_pnl)
def run():
conn = oracledb.connect(
user=os.getenv('ORACLE_USER'),
password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'),
config_dir=os.getenv('ORACLE_WALLET')
)
cur = conn.cursor()
# WF차단 종목별 5번째 패배 시점 조회
blocked_tickers = {}
for ticker in ['KRW-DKA', 'KRW-SAHARA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT traded_at, pnl_pct, sell_price FROM trade_results
WHERE ticker = :t ORDER BY traded_at
""", t=ticker)
rows = cur.fetchall()
if len(rows) >= 5:
wf_trigger_dt = rows[4][0] # 5번째 거래 완료 시점
last_sell_price = rows[4][2]
blocked_tickers[ticker] = {
'wf_trigger_dt': wf_trigger_dt,
'last_sell_price': last_sell_price,
'trades': rows
}
print("=" * 60)
print("Shadow Trading 재활 시뮬레이션")
print("=" * 60)
for ticker, info in blocked_tickers.items():
wf_dt = info['wf_trigger_dt']
print(f"\n{''*60}")
print(f"[{ticker}] WF차단 발동: {wf_dt.strftime('%m-%d %H:%M')}")
print(f" 직전 5건: {[f'{r[1]:+.2f}%' for r in info['trades']]}")
# WF차단 이후 가격 시계열
prices = get_price_series(cur, ticker, wf_dt)
if not prices:
print(" → 이후 가격 데이터 없음")
continue
print(f" 이후 가격 데이터: {len(prices)}개 ({prices[0][1].strftime('%m-%d %H:%M')} ~ {prices[-1][1].strftime('%m-%d %H:%M')})")
print()
# Shadow 포지션 시뮬레이션
shadow_wins = 0
pos_idx = 0
cursor_idx = 0
rehabilitated = False
while cursor_idx < len(prices) and not rehabilitated:
buy_price, buy_dt = prices[cursor_idx]
remaining = prices[cursor_idx + 1:]
if not remaining:
break
pos_idx += 1
is_win, sell_price, sell_dt, reason, pnl = simulate_shadow(remaining, buy_price, buy_dt)
win_mark = "✅ WIN" if is_win else "❌ LOSS"
if is_win:
shadow_wins += 1
else:
shadow_wins = 0 # 패배 시 카운터 리셋
print(f" Shadow #{pos_idx}: 진입={buy_price:.4f}원 ({buy_dt.strftime('%m-%d %H:%M')})")
print(f" 청산={sell_price:.4f}원 ({sell_dt.strftime('%m-%d %H:%M')}) | {reason}")
print(f" {win_mark} {pnl:+.2f}% | 연속승={shadow_wins}/{REHABILITATE_WINS}")
if shadow_wins >= REHABILITATE_WINS:
print(f"\n 🎉 {REHABILITATE_WINS}연승 달성 → WF 차단 해제! ({sell_dt.strftime('%m-%d %H:%M')})")
rehabilitated = True
break
# 다음 진입: 청산 시점 이후 첫 번째 가격
next_idx = next(
(i for i, (_, ts) in enumerate(prices) if ts > sell_dt),
None
)
if next_idx is None:
break
cursor_idx = next_idx
if not rehabilitated:
print(f"\n ⛔ 데이터 범위 내 재활 실패 (shadow_wins={shadow_wins})")
conn.close()
if __name__ == "__main__":
run()

158
tests/shadow_sim2.py Normal file
View File

@@ -0,0 +1,158 @@
"""Shadow 재활 이후 실제 진입 성과 시뮬레이션."""
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
import oracledb
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
FEE = 0.0005
REHABILITATE_WINS = 2
def get_price_series(cur, ticker, from_dt):
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at >= :dt
ORDER BY recorded_at
""", t=ticker, dt=from_dt)
return cur.fetchall()
def simulate_one(prices, buy_price, buy_dt):
"""단일 포지션 시뮬레이션. Returns (is_win, sell_price, sell_dt, reason, pnl_pct)"""
peak = buy_price
for price, ts in prices:
if price > peak:
peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
drop_from_peak = (peak - price) / peak
if drop_from_peak >= STOP_LOSS_PCT:
sell_pnl = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (sell_pnl > 0, price, ts, f"트레일링({pnl*100:+.1f}%)", sell_pnl)
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
sell_pnl = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (sell_pnl > 0, price, ts, f"타임스탑({elapsed_h:.1f}h)", sell_pnl)
last_price, last_ts = prices[-1]
sell_pnl = (last_price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (sell_pnl > 0, last_price, last_ts, "데이터종료(보유중)", sell_pnl)
def run_shadow_then_real(cur, ticker, wf_trigger_dt):
"""shadow로 재활 후, 재활 시점 이후 실제 거래 성과 시뮬레이션."""
prices = get_price_series(cur, ticker, wf_trigger_dt)
if not prices:
return None
# 1단계: shadow로 재활 시점 찾기
shadow_wins = 0
cursor_idx = 0
rehab_dt = None
while cursor_idx < len(prices):
buy_price, buy_dt = prices[cursor_idx]
remaining = prices[cursor_idx + 1:]
if not remaining:
break
is_win, sell_price, sell_dt, reason, pnl = simulate_one(remaining, buy_price, buy_dt)
if is_win:
shadow_wins += 1
else:
shadow_wins = 0
if shadow_wins >= REHABILITATE_WINS:
rehab_dt = sell_dt
break
next_idx = next((i for i, (_, ts) in enumerate(prices) if ts > sell_dt), None)
if next_idx is None:
break
cursor_idx = next_idx
if rehab_dt is None:
return None # 재활 실패
# 2단계: 재활 이후 실제 거래 시뮬레이션
print(f"\n ★ WF 해제 시점: {rehab_dt.strftime('%m-%d %H:%M')}")
print(f" ─ 이후 실제 진입 시뮬레이션 ─")
post_prices = get_price_series(cur, ticker, rehab_dt)
if not post_prices:
print(" → 재활 이후 가격 데이터 없음")
return
cursor_idx = 0
trade_no = 0
wins = 0
total_pnl = 0.0
while cursor_idx < len(post_prices):
buy_price, buy_dt = post_prices[cursor_idx]
remaining = post_prices[cursor_idx + 1:]
if not remaining:
break
is_win, sell_price, sell_dt, reason, pnl = simulate_one(remaining, buy_price, buy_dt)
trade_no += 1
if is_win:
wins += 1
total_pnl += pnl
mark = "" if is_win else ""
print(f" 실제#{trade_no}: {buy_price:.4f}{sell_price:.4f}원 | {mark} {pnl:+.2f}% | {reason} ({sell_dt.strftime('%m-%d %H:%M')})")
next_idx = next((i for i, (_, ts) in enumerate(post_prices) if ts > sell_dt), None)
if next_idx is None:
break
cursor_idx = next_idx
if trade_no > 0:
wr = wins / trade_no * 100
print(f"\n 📊 재활 후 성과: {trade_no}건 중 {wins}승 | 승률={wr:.0f}% | 누적PnL={total_pnl:+.2f}%")
return {'trades': trade_no, 'wins': wins, 'win_rate': wr, 'total_pnl': total_pnl}
return None
def run():
conn = oracledb.connect(
user=os.getenv('ORACLE_USER'),
password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'),
config_dir=os.getenv('ORACLE_WALLET')
)
cur = conn.cursor()
results = {}
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT traded_at FROM trade_results
WHERE ticker = :t ORDER BY traded_at
""", t=ticker)
rows = cur.fetchall()
wf_trigger_dt = rows[4][0]
print(f"\n{'='*60}")
print(f"[{ticker}] WF차단 발동: {wf_trigger_dt.strftime('%m-%d %H:%M')}")
r = run_shadow_then_real(cur, ticker, wf_trigger_dt)
if r:
results[ticker] = r
print(f"\n{'='*60}")
print("전체 요약")
print(f"{'='*60}")
for ticker, r in results.items():
print(f"{ticker}: 재활 후 {r['trades']}건 | 승률={r['win_rate']:.0f}% | 누적={r['total_pnl']:+.2f}%")
conn.close()
if __name__ == "__main__":
run()

323
tests/shadow_sim3.py Normal file
View File

@@ -0,0 +1,323 @@
"""Shadow 재활 시뮬레이션 v3 - 실제 전략 필터 포함.
전략 조건:
1. 추세: 현재가 vs 2h 전 가격 >= TREND_MIN_GAIN_PCT%
2. 모멘텀: 현재가 > MA20(일봉) AND 최근 1h 거래량 > 로컬 5h 평균 * VOL_MULT
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
from datetime import datetime, timedelta
from collections import defaultdict
from dotenv import load_dotenv
load_dotenv()
import oracledb
import pyupbit
# ── 파라미터 ──────────────────────────────────────────
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
TREND_HOURS = 2
TREND_MIN_PCT = float(os.getenv("TREND_MIN_GAIN_PCT", "5")) # Neutral 기준
MA_PERIOD = 20
LOCAL_VOL_HOURS = 5
VOL_MULT = float(os.getenv("VOLUME_MULTIPLIER", "2.0"))
CONFIRM_MINUTES = 15
FEE = 0.0005
REHABILITATE_WINS = 2
# ── DB 연결 ───────────────────────────────────────────
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'),
password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'),
config_dir=os.getenv('ORACLE_WALLET')
)
def load_price_history(cur, ticker, from_dt, to_dt):
"""price_history 전체 로드."""
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at BETWEEN :f AND :e
ORDER BY recorded_at
""", t=ticker, f=from_dt, e=to_dt)
return cur.fetchall() # [(price, dt), ...]
# ── pyupbit 과거 데이터 캐시 ───────────────────────────
_daily_cache = {} # (ticker, date_str) → df
_hourly_cache = {} # (ticker, hour_str) → df
def get_ma20(ticker, as_of_dt):
"""as_of_dt 기준 MA20 (일봉 종가)."""
date_str = as_of_dt.strftime("%Y-%m-%d")
key = (ticker, date_str)
if key not in _daily_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="day", count=MA_PERIOD + 2,
to=as_of_dt.strftime("%Y-%m-%d 09:00:00"))
_daily_cache[key] = df
time.sleep(0.1)
except Exception as e:
_daily_cache[key] = None
df = _daily_cache[key]
if df is None or len(df) < MA_PERIOD:
return None
return df["close"].iloc[-MA_PERIOD:].mean()
def get_volume_ratio(ticker, as_of_dt):
"""최근 1h 거래량 / 로컬 5h 평균. (직전 완성봉 기준)"""
# 시간봉은 해당 시각의 이전 7h 데이터 필요
hour_str = as_of_dt.strftime("%Y-%m-%d %H:00:00")
key = (ticker, hour_str)
if key not in _hourly_cache:
try:
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=LOCAL_VOL_HOURS + 3,
to=as_of_dt.strftime("%Y-%m-%d %H:%M:%S"))
_hourly_cache[key] = df
time.sleep(0.1)
except Exception as e:
_hourly_cache[key] = None
df = _hourly_cache[key]
if df is None or len(df) < LOCAL_VOL_HOURS + 1:
return 0.0
recent_vol = df["volume"].iloc[-2]
local_avg = df["volume"].iloc[-(LOCAL_VOL_HOURS + 1):-2].mean()
if local_avg <= 0:
return 0.0
return recent_vol / local_avg
# ── 전략 조건 체크 ────────────────────────────────────
def check_trend(prices, idx):
"""현재 idx 기준 2h 전(12틱 전) 대비 +TREND_MIN_PCT% 이상."""
lookback = TREND_HOURS * 6 # 10분봉 기준 2h = 12틱
if idx < lookback:
return False
current = prices[idx][0]
past = prices[idx - lookback][0]
if past <= 0:
return False
gain = (current - past) / past * 100
return gain >= TREND_MIN_PCT
def check_momentum(ticker, current_price, as_of_dt):
"""현재가 > MA20 AND 거래량 비율 >= VOL_MULT."""
ma20 = get_ma20(ticker, as_of_dt)
if ma20 is None:
return False
if current_price <= ma20:
return False
vol_ratio = get_volume_ratio(ticker, as_of_dt)
return vol_ratio >= VOL_MULT
# ── 단일 포지션 시뮬레이션 ────────────────────────────
def simulate_position(prices, buy_idx, buy_price):
"""buy_idx 이후 가격으로 포지션 시뮬레이션."""
buy_dt = prices[buy_idx][1]
peak = buy_price
for price, ts in prices[buy_idx + 1:]:
if price > peak:
peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
drop_from_peak = (peak - price) / peak
if drop_from_peak >= STOP_LOSS_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (net > 0, price, ts, f"트레일링({pnl*100:+.1f}%)", net)
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (net > 0, price, ts, f"타임스탑({elapsed_h:.1f}h)", net)
last_price, last_ts = prices[-1]
net = (last_price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return (net > 0, last_price, last_ts, "데이터종료", net)
# ── Shadow → 재활 → 실제 진입 시뮬레이션 ─────────────
def run_full_sim(cur, ticker, wf_trigger_dt, end_dt):
"""
1. WF차단 시점부터 shadow 포지션 (전략 필터 적용)
2. REHABILITATE_WINS 연승 → WF 해제
3. 해제 이후 실제 진입 성과
"""
prices = load_price_history(cur, ticker, wf_trigger_dt, end_dt)
if not prices:
print(f" 가격 데이터 없음")
return
print(f" 가격 데이터: {len(prices)}개 ({prices[0][1].strftime('%m-%d %H:%M')} ~ {prices[-1][1].strftime('%m-%d %H:%M')})")
# ── Phase 1: Shadow (WF 해제까지) ────────────────
shadow_wins = 0
rehab_dt = None
watchlist_dt = None # 신호 첫 발생 시각
in_position = False
pos_buy_idx = None
pos_buy_price = None
shadow_trade_no = 0
idx = 0
while idx < len(prices) and rehab_dt is None:
current_price, current_dt = prices[idx]
if in_position:
# 포지션 청산 체크
is_win, sell_price, sell_dt, reason, pnl = simulate_position(prices, pos_buy_idx, pos_buy_price)
# 청산 시각에 해당하는 idx로 점프
sell_idx = next((i for i, (_, ts) in enumerate(prices) if ts >= sell_dt), len(prices)-1)
shadow_trade_no += 1
if is_win:
shadow_wins += 1
else:
shadow_wins = 0
mark = "" if is_win else ""
print(f" [Shadow#{shadow_trade_no}] {pos_buy_price:.4f}{sell_price:.4f}"
f"| {mark} {pnl:+.2f}% | {reason} | 연속승={shadow_wins}/{REHABILITATE_WINS}"
f" ({sell_dt.strftime('%m-%d %H:%M')})")
if shadow_wins >= REHABILITATE_WINS:
rehab_dt = sell_dt
idx = sell_idx
break
in_position = False
watchlist_dt = None
idx = sell_idx
continue
# 전략 조건 체크
trend_ok = check_trend(prices, idx)
if trend_ok:
mom_ok = check_momentum(ticker, current_price, current_dt)
else:
mom_ok = False
if trend_ok and mom_ok:
if watchlist_dt is None:
watchlist_dt = current_dt # 첫 신호
elif (current_dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
# 15분 재확인 → shadow 진입
in_position = True
pos_buy_idx = idx
pos_buy_price = current_price
watchlist_dt = None
# 청산은 다음 루프에서
else:
watchlist_dt = None # 조건 깨지면 초기화
idx += 1
if rehab_dt is None:
print(f"\n ⛔ 전략 필터 적용 시 데이터 범위 내 재활 실패 (shadow_wins={shadow_wins})")
return
print(f"\n 🎉 WF 해제: {rehab_dt.strftime('%m-%d %H:%M')} ({shadow_trade_no}번 shadow 거래)")
# ── Phase 2: 실제 진입 (재활 이후) ───────────────
print(f"\n ── 재활 후 실제 진입 ──")
post_prices = [(p, dt) for p, dt in prices if dt >= rehab_dt]
if not post_prices:
print(" 재활 이후 데이터 없음")
return
real_wins = 0
real_total = 0
real_pnl_sum = 0.0
watchlist_dt = None
in_position = False
pos_buy_idx_g = None # global idx in post_prices
pos_buy_price = None
idx2 = 0
while idx2 < len(post_prices):
current_price, current_dt = post_prices[idx2]
if in_position:
is_win, sell_price, sell_dt, reason, pnl = simulate_position(post_prices, pos_buy_idx_g, pos_buy_price)
sell_idx2 = next((i for i, (_, ts) in enumerate(post_prices) if ts >= sell_dt), len(post_prices)-1)
real_total += 1
if is_win:
real_wins += 1
real_pnl_sum += pnl
mark = "" if is_win else ""
print(f" 실제#{real_total}: {pos_buy_price:.4f}{sell_price:.4f}"
f"| {mark} {pnl:+.2f}% | {reason} ({sell_dt.strftime('%m-%d %H:%M')})")
in_position = False
watchlist_dt = None
idx2 = sell_idx2
continue
trend_ok = check_trend(post_prices, idx2)
if trend_ok:
mom_ok = check_momentum(ticker, current_price, current_dt)
else:
mom_ok = False
if trend_ok and mom_ok:
if watchlist_dt is None:
watchlist_dt = current_dt
elif (current_dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
in_position = True
pos_buy_idx_g = idx2
pos_buy_price = current_price
watchlist_dt = None
else:
watchlist_dt = None
idx2 += 1
if real_total == 0:
print(" 재활 후 전략 조건 충족 진입 없음")
else:
wr = real_wins / real_total * 100
print(f"\n 📊 재활 후: {real_total}건 | 승률={wr:.0f}% | 누적={real_pnl_sum:+.2f}%")
return {'trades': real_total, 'wins': real_wins, 'wr': wr, 'pnl': real_pnl_sum}
def main():
conn = get_conn()
cur = conn.cursor()
# price_history 최대 시각
cur.execute("SELECT MAX(recorded_at) FROM price_history")
end_dt = cur.fetchone()[0]
summary = {}
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT traded_at FROM trade_results
WHERE ticker = :t ORDER BY traded_at
""", t=ticker)
rows = cur.fetchall()
wf_dt = rows[4][0]
print(f"\n{'='*62}")
print(f"[{ticker}] WF차단: {wf_dt.strftime('%m-%d %H:%M')}")
print(f"{'='*62}")
r = run_full_sim(cur, ticker, wf_dt, end_dt)
if r:
summary[ticker] = r
print(f"\n{'='*62}")
print("전체 요약 (전략 필터 적용)")
print(f"{'='*62}")
for ticker, r in summary.items():
print(f"{ticker}: {r['trades']}건 | 승률={r['wr']:.0f}% | 누적={r['pnl']:+.2f}%")
conn.close()
if __name__ == "__main__":
main()

308
tests/sim10m.py Normal file
View File

@@ -0,0 +1,308 @@
"""sim10m.py — 10분봉 + 극단적 거래량 즉시 진입 전략 시뮬.
기존 전략 (1h봉 vol-lead) vs 신규 전략 (10분봉 + 100x 이상 거래량 즉시 진입) 비교.
데이터: 최근 45일 Upbit API
"""
import pickle
import time
import sys
from pathlib import Path
from datetime import datetime, timedelta
import pandas as pd
import pyupbit
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 파라미터 ──────────────────────────────────────────────
SIM_DAYS = 45
TOP30_FILE = Path("top30_tickers.pkl")
CACHE_FILE = Path("sim10m_cache.pkl")
TOP_N = 20
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
# A: 기존 1h봉
A_LOCAL_VOL = 5 # 봉수 (= 5h)
A_VOL_MULT = 2.0
A_QUIET_PCT = 2.0
A_THRESH = 4.8
A_SIGNAL_TO = 8 # 신호 유효 봉수 (= 8h)
A_ATR_CANDLES = 5
A_TIME_STOP = 8 # 타임스탑 봉수 (= 8h)
# B: 신규 10분봉
B_LOCAL_VOL = 30 # 봉수 (5h = 30 × 10min)
B_VOL_MULT = 2.0
B_EXTREME_VOL = 100 # 이 이상 → 횡보 조건 면제, 다음 봉 즉시 진입
B_QUIET_CANDLES = 12 # 2h = 12봉
B_QUIET_PCT = 2.0
B_THRESH = 4.8
B_SIGNAL_TO = 48 # 신호 유효 봉수 (8h = 48봉)
B_ATR_CANDLES = 30 # ATR 봉수 (5h = 30봉)
B_TIME_STOP = 48 # 타임스탑 봉수 (8h = 48봉)
# ── 데이터 로드 ──────────────────────────────────────────
def fetch_ohlcv(ticker, interval, days):
"""OHLCV 과거 데이터 페이징 로드."""
target_start = datetime.now() - timedelta(days=days)
all_dfs, to, prev_oldest = [], None, None
while True:
kwargs = dict(ticker=ticker, interval=interval, count=200)
if to:
kwargs["to"] = to
df = pyupbit.get_ohlcv(**kwargs)
if df is None or df.empty:
break
df.index = df.index.tz_localize(None)
oldest = df.index[0]
# 더 이상 과거로 못 가면 중단 (상장일에 도달)
if prev_oldest is not None and oldest >= prev_oldest:
all_dfs.append(df)
break
all_dfs.append(df)
prev_oldest = oldest
if oldest <= target_start:
break
to = oldest.strftime("%Y-%m-%d %H:%M:%S")
time.sleep(0.15)
if not all_dfs:
return None
result = pd.concat(all_dfs).sort_index().drop_duplicates()
return result[result.index >= target_start]
def load_data(tickers):
if CACHE_FILE.exists():
print(f"캐시 로드: {CACHE_FILE}")
return pickle.load(open(CACHE_FILE, "rb"))
data = {"1h": {}, "10m": {}}
for idx, ticker in enumerate(tickers, 1):
print(f" [{idx}/{len(tickers)}] {ticker}...", end=" ", flush=True)
df1h = fetch_ohlcv(ticker, "minute60", SIM_DAYS)
df10m = fetch_ohlcv(ticker, "minute10", SIM_DAYS)
n1h = len(df1h) if df1h is not None else 0
n10m = len(df10m) if df10m is not None else 0
print(f"1h={n1h}봉 10m={n10m}")
if df1h is not None and n1h >= 50: data["1h"][ticker] = df1h
if df10m is not None and n10m >= 200: data["10m"][ticker] = df10m
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"캐시 저장: {CACHE_FILE}\n")
return data
# ── ATR 계산 (시뮬용) ─────────────────────────────────────
def calc_atr(df, buy_idx, n):
sub = df.iloc[max(0, buy_idx - n - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-n:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df, buy_idx, buy_price, stop_pct, time_stop_candles):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, sp, ts, f"트레일링({pnl:+.1f}%)", pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= time_stop_candles and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, row["close"], ts, "타임스탑", pnl
last = df.iloc[-1]["close"]
pnl = (last * (1-FEE) - buy_price * (1+FEE)) / (buy_price * (1+FEE)) * 100
return pnl > 0, last, df.index[-1], "데이터종료", pnl
# ── 전략 A: 기존 1h봉 vol-lead ──────────────────────────
def run_a(df):
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(A_LOCAL_VOL + 2, 3)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, A_TIME_STOP)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, "일반"))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
close = df.iloc[i]["close"]
chg_2h = abs(close - df.iloc[i-2]["close"]) / df.iloc[i-2]["close"] * 100
quiet = chg_2h < A_QUIET_PCT
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-A_LOCAL_VOL-1:i-1]["volume"].mean()
spike = vol_avg > 0 and vol_p >= vol_avg * A_VOL_MULT
if quiet and spike:
if sig_i is None: sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p: sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > A_SIGNAL_TO:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= A_THRESH:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, A_ATR_CANDLES)
sig_i = sig_p = None
i += 1
return trades
# ── 전략 B: 10분봉 + 극단적 거래량 즉시 진입 ────────────
def run_b(df):
# trade tuple: (is_win, pnl, buy_dt, sell_dt, entry_type)
# entry_type: '일반' | '극단'
trades = []
sig_i = sig_p = None
extreme_pending = False
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(B_LOCAL_VOL + 2, B_QUIET_CANDLES + 1)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct, B_TIME_STOP)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, entry_type))
in_pos = False
sig_i = sig_p = None
extreme_pending = False
i = next_i
continue
close = df.iloc[i]["close"]
vol_p = df.iloc[i-1]["volume"]
vol_avg = df.iloc[i-B_LOCAL_VOL-1:i-1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
# 극단적 거래량 → 다음 봉 진입 대기 설정
if not extreme_pending and vol_r >= B_EXTREME_VOL:
extreme_pending = True
i += 1
continue
# 극단적 거래량 다음 봉 → 즉시 진입
if extreme_pending:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, B_ATR_CANDLES)
entry_type = "극단"
extreme_pending = False
sig_i = sig_p = None
i += 1
continue
# 일반 vol-lead
close_2h = df.iloc[i - B_QUIET_CANDLES]["close"]
chg_2h = abs(close - close_2h) / close_2h * 100
quiet = chg_2h < B_QUIET_PCT
spike = vol_r >= B_VOL_MULT
if quiet and spike:
if sig_i is None: sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p: sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > B_SIGNAL_TO:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= B_THRESH:
in_pos = True; buy_idx = i; buy_price = close
stop_pct = calc_atr(df, i, B_ATR_CANDLES)
entry_type = "일반"
sig_i = sig_p = None
i += 1
return trades
# ── 통계 계산 ─────────────────────────────────────────────
def calc_stats(trades):
if not trades:
return {"n": 0, "wr": 0.0, "cum": 0.0, "dd": 0.0}
wins = sum(1 for t in trades if t[0])
cum = peak = dd = 0.0
for t in sorted(trades, key=lambda x: x[2]):
cum += t[1]
peak = max(peak, cum)
dd = max(dd, peak - cum)
return {"n": len(trades), "wr": wins / len(trades) * 100, "cum": cum, "dd": dd}
# ── 메인 ─────────────────────────────────────────────────
def main():
top30 = pickle.load(open(TOP30_FILE, "rb"))
tickers = top30[:TOP_N]
print(f"{'='*60}")
print(f"10분봉 전략 시뮬 | 최근 {SIM_DAYS}일 | {TOP_N}종목")
print(f" A: 기존 1h봉 vol-lead")
print(f" B: 10분봉 vol-lead + 극단거래량({B_EXTREME_VOL}x) 즉시 진입")
print(f"{'='*60}\n")
print(f"데이터 로드 중...")
data = load_data(tickers)
v1h = [t for t in tickers if t in data["1h"] and len(data["1h"][t]) >= 50]
v10m = [t for t in tickers if t in data["10m"] and len(data["10m"][t]) >= 200]
print(f"유효: 1h={len(v1h)}종목 / 10m={len(v10m)}종목\n")
# 전략 A
a_all = []
for t in v1h:
a_all.extend(run_a(data["1h"][t]))
# 전략 B
b_all = []
for t in v10m:
b_all.extend(run_b(data["10m"][t]))
b_extreme = [t for t in b_all if t[4] == "극단"]
b_normal = [t for t in b_all if t[4] == "일반"]
sa = calc_stats(a_all)
sb = calc_stats(b_all)
sbe = calc_stats(b_extreme)
sbn = calc_stats(b_normal)
print(f"{'='*65}")
print(f"{'전략':18} {'거래수':>6} {'승률':>6} {'누적PnL%':>10} {'최대낙폭%':>10}")
print(f"{''*65}")
print(f"{'A (1h vol-lead)':18} {sa['n']:>6}{sa['wr']:>5.1f}% {sa['cum']:>+9.2f}% {-sa['dd']:>+9.2f}%")
print(f"{'B 전체 (10m)':18} {sb['n']:>6}{sb['wr']:>5.1f}% {sb['cum']:>+9.2f}% {-sb['dd']:>+9.2f}%")
print(f"{' └ 극단거래량':18} {sbe['n']:>6}{sbe['wr']:>5.1f}% {sbe['cum']:>+9.2f}% {-sbe['dd']:>+9.2f}%")
print(f"{' └ 일반vol-lead':18} {sbn['n']:>6}{sbn['wr']:>5.1f}% {sbn['cum']:>+9.2f}% {-sbn['dd']:>+9.2f}%")
print(f"{'='*65}")
# 극단 거래량 진입 상세
if b_extreme:
print(f"\n── 극단거래량 진입 상세 ({len(b_extreme)}건) ─────────────────────")
print(f" {'매수시각':<18} {'PnL%':>7} {'청산'}")
for t in sorted(b_extreme, key=lambda x: x[2])[:20]:
mark = "" if t[0] else ""
print(f" {str(t[2])[:16]:<18} {t[1]:>+6.1f}% {mark} {t[4]}")
if __name__ == "__main__":
main()

229
tests/sim_365.py Normal file
View File

@@ -0,0 +1,229 @@
"""365일 복리 KRW 시뮬레이션.
- 상위 20개 종목 × vol-lead +4.8% 전략
- MAX_POSITIONS=3, 복리 포지션 크기 (이득 시 증가 / 손실 시 차감)
- 최소 예산 = 초기 예산의 30%
- 데이터: Oracle ADB ohlcv_hourly 테이블
"""
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
from ohlcv_db import load_from_db
from vol_lead_sim import run_vol_lead_thresh
# ── 파라미터 ───────────────────────────────────────────
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10 # 하한 30% = 4,500,000원
MAX_POS = 3
THRESH = 4.8
FROM_DATE = "2025-03-02"
TOP30_FILE = Path("top30_tickers.pkl")
def load_data() -> dict:
top30 = pickle.load(open(TOP30_FILE, "rb"))
print(f"DB 로드 중... ({len(top30)}종목)")
data = load_from_db(top30, from_date=FROM_DATE)
valid = {t: df for t, df in data.items() if len(df) >= 500}
print(f"유효 종목: {len(valid)}개 로드 완료")
return valid
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE} ({len(data)}종목)\n")
return data
# ── WF 필터 (종목별 적용) ──────────────────────────────
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
def apply_wf(trades: list) -> tuple:
"""종목별 WF 필터: 2연패 시 차단, shadow 2연승 시 복귀."""
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
wr = sum(history[-WF_WINDOW:]) / WF_WINDOW
if wr < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS 필터 ─────────────────────────────────
def collect_trades(data: dict, tickers: list) -> list:
all_trades = []
wf_total_blocked = 0
for t in tickers:
if t not in data:
continue
raw = [(is_win, pnl, buy_dt, sell_dt, reason)
for is_win, pnl, buy_dt, sell_dt, reason
in run_vol_lead_thresh(data[t], THRESH)]
filtered, blocked = apply_wf(raw)
wf_total_blocked += blocked
for is_win, pnl, buy_dt, sell_dt, reason in filtered:
all_trades.append((buy_dt, sell_dt, t, is_win, pnl, reason))
print(f" WF 필터 차단: {wf_total_blocked}")
all_trades.sort(key=lambda x: x[0])
return all_trades
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[0], trade[1]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬레이션 ────────────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
for buy_dt, sell_dt, ticker, is_win, pnl, reason in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt,
"sell_dt": sell_dt,
"ticker": ticker,
"is_win": is_win,
"pnl_pct": pnl,
"pos_size": pos_size,
"krw_profit": krw_profit,
"portfolio": portfolio,
"reason": reason,
})
wins = sum(1 for t in accepted if t[3])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
# ── 메인 ──────────────────────────────────────────────
def main() -> None:
data = load_data()
top30 = pickle.load(open(TOP30_FILE, "rb"))
valid = [t for t in top30 if t in data and len(data[t]) >= 500]
use20 = valid[:20]
print(f"{'='*65}")
print(f"365일 복리 시뮬레이션 | vol-lead +{THRESH}% | {len(use20)}종목")
print(f"초기 예산: {BUDGET:,}원 | 최소 예산(하한): {MIN_BUDGET:,}")
print(f"기간: {FROM_DATE[:10]} ~ 2026-03-02")
print(f"{'='*65}")
all_trades = collect_trades(data, use20)
accepted, skipped = apply_max_positions(all_trades)
result = simulate(accepted)
print(f"\n── 전체 결과 ──────────────────────────────────────────")
print(f" 신호 발생: {len(all_trades):>4}")
print(f" 실제 진입: {result['total']:>4}건 ({len(skipped)}건 MAX_POS 스킵)")
print(f" 승/패: {result['wins']}{result['total']-result['wins']}패 (승률 {result['wr']:.0f}%)")
print(f" ─────────────────────────────────────────────────")
print(f" 초기 예산: {BUDGET:>14,}")
print(f" 최종 자산: {result['portfolio']:>14,.0f}")
print(f" 순수익: {result['total_krw']:>+14,.0f}")
print(f" 수익률: {result['roi_pct']:>+13.2f}%")
print(f" 연환산: {result['roi_pct']:>+13.2f}% (이미 1년)")
# 최대 낙폭
peak = BUDGET
max_dd = 0.0
for t in result["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
print(f" 최대 낙폭: {-max_dd:>+13.2f}% ({-max_dd/100*BUDGET:>+,.0f}원)")
# 월별
print(f"\n── 월별 수익 ──────────────────────────────────────────")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>14} {'누적수익(KRW)':>15} {'예산':>14}")
print(f" {''*70}")
cum = 0.0
budget_now = float(BUDGET)
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
budget_now = max(BUDGET + cum, MIN_BUDGET)
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │ "
f"{m['pnl_krw']:>+14,.0f}{cum:>+14,.0f}{budget_now:>13,.0f}")
# 종목별
print(f"\n── 종목별 기여 ({len(use20)}종목) ──────────────────────────")
print(f" {'종목':<14}{'거래':>4} {'승률':>5}{'KRW수익':>14} {'평균/건':>10}")
print(f" {''*58}")
stats: dict = {}
for t in result["trade_log"]:
k = t["ticker"]
if k not in stats:
stats[k] = {"n": 0, "wins": 0, "krw": 0.0}
stats[k]["n"] += 1
stats[k]["wins"] += int(t["is_win"])
stats[k]["krw"] += t["krw_profit"]
for t, s in sorted(stats.items(), key=lambda x: -x[1]["krw"]):
wr = s["wins"] / s["n"] * 100 if s["n"] else 0
avg = s["krw"] / s["n"] if s["n"] else 0
print(f" {t:<14}{s['n']:>4}{wr:>4.0f}% │ "
f"{s['krw']:>+14,.0f}{avg:>+9,.0f}원/건")
if __name__ == "__main__":
main()

296
tests/sim_45m40.py Normal file
View File

@@ -0,0 +1,296 @@
"""45일 복리 KRW 시뮬레이션 — 40분봉.
sim10m_cache.pkl(10분봉)을 40분봉으로 리샘플링 후
sim_365.py 와 동일한 복리·WF·MAX_POSITIONS 로직 적용.
"""
import pickle
import sys
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
# ── 파라미터 ─────────────────────────────────────────────
CACHE_FILE = Path("sim10m_cache.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
TIME_STOP_MIN_PCT = 3.0
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
VOL_MULT = 2.0
QUIET_PCT = 2.0
THRESH = 4.8
# 40분봉 기준 시간 파라미터 → 봉수 환산 (60/40 = 1.5봉/h)
LOCAL_VOL_N = 7 # 5h × 1.5
QUIET_N = 3 # 2h × 1.5
SIGNAL_TO_N = 12 # 8h × 1.5
ATR_N = 7 # 5h × 1.5
TS_N = 12 # 8h × 1.5
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── 리샘플링 ─────────────────────────────────────────────
def resample_40m(df: pd.DataFrame) -> pd.DataFrame:
return (
df.resample("40min")
.agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
.dropna(subset=["close"])
)
# ── ATR ──────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, buy_idx: int) -> float:
sub = df.iloc[max(0, buy_idx - ATR_N - 1):buy_idx]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).iloc[-ATR_N:].mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
# ── 포지션 시뮬 ──────────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float):
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= TS_N and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, ts, pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl
# ── vol-lead 전략 ─────────────────────────────────────────
def run_vol_lead(df: pd.DataFrame, ticker: str) -> list:
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
i = max(LOCAL_VOL_N + 2, QUIET_N + 1)
while i < len(df):
if in_pos:
is_win, sdt, pnl = simulate_pos(df, buy_idx, buy_price, stop_pct)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker))
in_pos = False
sig_i = sig_p = None
i = next_i
continue
close = df.iloc[i]["close"]
vol_p = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_N - 1:i - 1]["volume"].mean()
vol_r = vol_p / vol_avg if vol_avg > 0 else 0
close_qh = df.iloc[i - QUIET_N]["close"]
chg_qh = abs(close - close_qh) / close_qh * 100
quiet = chg_qh < QUIET_PCT
spike = vol_r >= VOL_MULT
if quiet and spike:
if sig_i is None:
sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p:
sig_i = sig_p = None
if sig_i is not None and (i - sig_i) > SIGNAL_TO_N:
sig_i = sig_p = None
if sig_i is not None and (close - sig_p) / sig_p * 100 >= THRESH:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr(df, i)
sig_i = sig_p = None
i += 1
return trades
# ── WF 필터 ──────────────────────────────────────────────
def apply_wf(trades: list) -> tuple:
history = []
shadow_streak = 0
blocked = False
accepted = []
blocked_cnt = 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
wr = sum(history[-WF_WINDOW:]) / WF_WINDOW
if wr < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
if is_win:
shadow_streak += 1
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
else:
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS 필터 ────────────────────────────────────
def apply_max_positions(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in all_trades:
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 ────────────────────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
trade_log = []
for is_win, pnl, buy_dt, sell_dt, ticker in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
ym = buy_dt.strftime("%Y-%m")
if ym not in monthly:
monthly[ym] = {"trades": 0, "wins": 0, "pnl_krw": 0.0}
monthly[ym]["trades"] += 1
monthly[ym]["wins"] += int(is_win)
monthly[ym]["pnl_krw"] += krw_profit
trade_log.append({
"buy_dt": buy_dt, "sell_dt": sell_dt, "ticker": ticker,
"is_win": is_win, "pnl_pct": pnl,
"pos_size": pos_size, "krw_profit": krw_profit,
"portfolio": portfolio,
})
wins = sum(1 for t in accepted if t[0])
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi_pct": (portfolio - BUDGET) / BUDGET * 100,
"total": len(accepted),
"wins": wins,
"wr": wins / len(accepted) * 100 if accepted else 0,
"monthly": monthly,
"trade_log": trade_log,
}
# ── 메인 ─────────────────────────────────────────────────
def main():
print("캐시 로드 중...")
cache = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP30_FILE, "rb"))
tickers = [t for t in top30[:TOP_N] if t in cache["10m"]]
print(f"유효 종목: {len(tickers)}\n")
# 리샘플링 + 전략 실행
all_trades = []
wf_total_blocked = 0
for t in tickers:
df40 = resample_40m(cache["10m"][t])
if len(df40) < 50:
continue
raw = run_vol_lead(df40, t)
filtered, blocked = apply_wf(raw)
wf_total_blocked += blocked
all_trades.extend(filtered)
all_trades.sort(key=lambda x: x[2])
accepted, skipped = apply_max_positions(all_trades)
result = simulate(accepted)
# 최대 낙폭
peak = BUDGET
max_dd = 0.0
for t in result["trade_log"]:
peak = max(peak, t["portfolio"])
dd = (peak - t["portfolio"]) / peak * 100
max_dd = max(max_dd, dd)
# 기간 추출
if result["trade_log"]:
start_dt = result["trade_log"][0]["buy_dt"].strftime("%Y-%m-%d")
end_dt = result["trade_log"][-1]["sell_dt"].strftime("%Y-%m-%d")
else:
start_dt = end_dt = "N/A"
print(f"{'='*60}")
print(f"45일 복리 시뮬 | 40분봉 vol-lead +{THRESH}% | {len(tickers)}종목")
print(f"기간: {start_dt} ~ {end_dt}")
print(f"{'='*60}")
print(f" 신호 발생: {len(all_trades) + wf_total_blocked:>4}건 (WF 차단: {wf_total_blocked}건)")
print(f" 실제 진입: {result['total']:>4}건 ({len(skipped)}건 MAX_POS 스킵)")
print(f" 승/패: {result['wins']}{result['total']-result['wins']}"
f" (승률 {result['wr']:.1f}%)")
print(f" {''*50}")
print(f" 초기 예산: {BUDGET:>14,}")
print(f" 최종 자산: {result['portfolio']:>14,.0f}")
print(f" 순수익: {result['total_krw']:>+14,.0f}")
print(f" 수익률: {result['roi_pct']:>+13.2f}%")
print(f" 최대 낙폭: {-max_dd:>+13.2f}%"
f" ({-max_dd / 100 * BUDGET:>+,.0f}원)")
monthly_krw = [m["pnl_krw"] for m in result["monthly"].values()]
avg_monthly = sum(monthly_krw) / len(monthly_krw) if monthly_krw else 0
print(f" 월평균 수익: {avg_monthly:>+13,.0f}")
print(f"\n── 월별 수익 {''*40}")
print(f" {'':^8}{'거래':>4} {'승률':>5}{'월수익(KRW)':>14} {'누적수익(KRW)':>15}")
cum = 0.0
for ym, m in sorted(result["monthly"].items()):
wr = m["wins"] / m["trades"] * 100 if m["trades"] else 0
cum += m["pnl_krw"]
print(f" {ym:^8}{m['trades']:>4}{wr:>4.0f}% │ "
f"{m['pnl_krw']:>+14,.0f}{cum:>+14,.0f}")
print(f"{'='*60}")
if __name__ == "__main__":
main()

260
tests/stop_sweep.py Normal file
View 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()

119
tests/ticker_sim.py Normal file
View File

@@ -0,0 +1,119 @@
"""종목 수 확장 시뮬레이션 - 거래량 상위 N개 종목별 vol-lead 전략 비교."""
import os
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
import pandas as pd
import pyupbit
# vol_lead_sim.py의 공통 파라미터/함수 재사용
sys.path.insert(0, str(Path(__file__).parent))
from vol_lead_sim import (
STOP_LOSS_PCT, TIME_STOP_HOURS, TIME_STOP_MIN_PCT,
FEE, LOCAL_VOL_HOURS, VOL_MULT, PRICE_QUIET_PCT, SIGNAL_TIMEOUT_H,
FROM_DATE, simulate_pos, run_trend, run_vol_lead_thresh,
)
CACHE_FILE = Path("vol_lead_cache_30.pkl")
TOP30_FILE = Path("top30_tickers.pkl")
DAYS = 46.0
def load_data() -> dict:
return pickle.load(open(CACHE_FILE, "rb"))
def run_subset(data: dict, tickers: list, thresh: float) -> dict:
agg = {"total": 0, "wins": 0, "pnl": 0.0, "per_ticker": []}
for t in tickers:
if t not in data:
continue
trades = run_vol_lead_thresh(data[t], thresh)
n = len(trades)
w = sum(1 for x in trades if x[0])
p = sum(x[1] for x in trades)
agg["total"] += n
agg["wins"] += w
agg["pnl"] += p
agg["per_ticker"].append((t, n, w, p))
agg["wr"] = agg["wins"] / agg["total"] * 100 if agg["total"] else 0
return agg
def main() -> None:
data = load_data()
top30 = pickle.load(open(TOP30_FILE, "rb"))
# 데이터 충분한 종목만 (400봉 이상 = 16일 이상)
valid = [t for t in top30 if t in data and len(data[t]) >= 400]
n_max = len(valid)
print(f"유효 종목: {n_max}")
print(f"기간: 46일 (2026-01-15 ~ 2026-03-02)\n")
# ── A 현행 기준선 (9종목) ─────────────────────────
orig9 = ["KRW-DKA","KRW-LAYER","KRW-SIGN","KRW-SOL","KRW-ETH",
"KRW-XRP","KRW-HOLO","KRW-OM","KRW-ORBS"]
orig9_valid = [t for t in orig9 if t in data]
a_agg = {"total": 0, "wins": 0, "pnl": 0.0}
for t in orig9_valid:
trades = run_trend(data[t])
a_agg["total"] += len(trades)
a_agg["wins"] += sum(1 for x in trades if x[0])
a_agg["pnl"] += sum(x[1] for x in trades)
a_wr = a_agg["wins"] / a_agg["total"] * 100 if a_agg["total"] else 0
print(f"[기준: A 현행 9종목] {a_agg['total']}건 | 승률={a_wr:.0f}% | 누적={a_agg['pnl']:+.2f}%\n")
# ── 종목수별 비교 (임계값 4.8% 고정) ────────────────
THRESH = 4.8
subset_ns = [9, 15, 20, n_max]
print(f"임계값 +{THRESH}% | 종목 수 확장 효과")
print(f"{'종목수':>6}{'총거래':>6} {'일평균':>7} {'월환산':>7}{'승률':>5} {'누적PnL':>10}")
print("" * 56)
for n in subset_ns:
s = run_subset(data, valid[:n], THRESH)
pdm = s["total"] / DAYS
pmm = pdm * 30
marker = " ← 현재설정" if n == 9 else ""
print(f"{n:>5}종목 │ {s['total']:>6}{pdm:>6.2f}회/일 {pmm:>6.1f}회/월 │ "
f"{s['wr']:>4.0f}% {s['pnl']:>+9.2f}%{marker}")
# ── 임계값 × 종목수 매트릭스 ─────────────────────
thresholds = [3.6, 4.0, 4.4, 4.8]
col_ns = [9, 15, 20, n_max]
print(f"\n임계값 × 종목수 매트릭스 (건수 / 승률 / 누적PnL)")
col_w = 20
header = f"{'임계값':>6}"
for n in col_ns:
header += f" {f'{n}종목':^{col_w}}"
print(header)
print("" * (10 + col_w * len(col_ns)))
for thresh in thresholds:
row = f"+{thresh:.1f}% │"
for n in col_ns:
s = run_subset(data, valid[:n], thresh)
wr = s["wins"] / s["total"] * 100 if s["total"] else 0
cell = f"{s['total']}{wr:.0f}% {s['pnl']:+.1f}%"
row += f" {cell:<{col_w}}"
print(row)
# ── 전체 종목별 기여도 (4.8%) ────────────────────
print(f"\n종목별 기여도 ({n_max}종목, +4.8%)")
print(f"{'종목':<16} {'거래':>5} {'승률':>6} {'누적PnL':>10} {'평균PnL/거래':>12}")
print("" * 55)
s = run_subset(data, valid, THRESH)
s["per_ticker"].sort(key=lambda x: x[3], reverse=True)
for t, n, w, p in s["per_ticker"]:
wr = w / n * 100 if n else 0
avg = p / n if n else 0
print(f"{t:<16} {n:>5}{wr:>5.0f}% {p:>+9.2f}% {avg:>+10.2f}%/건")
if __name__ == "__main__":
main()

208
tests/tp5_backtest.py Normal file
View 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()

45
tests/trend_check.py Normal file
View File

@@ -0,0 +1,45 @@
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
import os
from dotenv import load_dotenv
load_dotenv()
import oracledb
conn = oracledb.connect(user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
cur = conn.cursor()
for ticker in ['KRW-DKA', 'KRW-LAYER', 'KRW-SIGN']:
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker = :t AND recorded_at >= TIMESTAMP '2026-02-28 20:00:00'
ORDER BY recorded_at
""", t=ticker)
rows = cur.fetchall()
lookback = 12 # 10분봉 * 12 = 2h
gains = []
for i in range(lookback, len(rows)):
curr = rows[i][0]
past = rows[i - lookback][0]
if past > 0:
gains.append((curr - past) / past * 100)
if not gains:
continue
above_5 = sum(1 for g in gains if g >= 5.0)
above_3 = sum(1 for g in gains if g >= 3.0)
above_0 = sum(1 for g in gains if g >= 0.0)
negative = sum(1 for g in gains if g < 0.0)
print(f"[{ticker}] 2h 등락률 분포 ({len(gains)}개 틱)")
print(f" 평균={sum(gains)/len(gains):+.2f}% 최고={max(gains):+.2f}% 최저={min(gains):+.2f}%")
print(f" +5% 이상(신호): {above_5}건 ({above_5/len(gains)*100:.0f}%)")
print(f" +3%~+5%: {above_3-above_5}건 ({(above_3-above_5)/len(gains)*100:.0f}%)")
print(f" 0%~+3%: {above_0-above_3}건 ({(above_0-above_3)/len(gains)*100:.0f}%)")
print(f" 음전(하락): {negative}건 ({negative/len(gains)*100:.0f}%)")
print()
conn.close()

461
tests/velocity_backtest.py Normal file
View File

@@ -0,0 +1,461 @@
"""velocity_backtest.py — 속도 진입 효과 비교 백테스트.
전략 A: 기존 거리 기반 (signal_price 대비 +THRESH% 도달 시 진입)
전략 B: 거리 + 속도 기반 (velocity >= 레짐별 VELOCITY_THRESHOLD 시 조기 진입)
BULL → vel_thresh = 0.10 (공격적)
NEUTRAL → vel_thresh = 0.15 (보수적)
BEAR → vel_thresh = 0.20 (더 높음)
레짐 판단: KRW-BTC 1h 변동률 (캐시 데이터 활용)
10분봉 캐시(sim10m_cache.pkl)를 사용.
신호 감지: 40분봉 vol spike + 2h 횡보 (10분봉 합산/슬라이스로 계산)
진입/청산: 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
from pathlib import Path
import pandas as pd
# ── 파라미터 ──────────────────────────────────────────────────────────────────
CACHE_FILE = Path("sim10m_cache.pkl")
TOP_FILE = Path("top30_tickers.pkl")
TOP_N = 20
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10
MAX_POS = 3
FEE = 0.0005
# 전략 파라미터
VOL_MULT = 2.0 # 거래량 배수 기준
QUIET_PCT = 2.0 # 2h 횡보 기준 (%)
THRESH = 4.8 # 거리 기반 진입 임계값 (%)
# 10분봉 기준 캔들 수
QUIET_C = 12 # 2h = 12 × 10분
VOL40_C = 4 # 40분봉 1개 = 4 × 10분봉
LOCAL_C = 7 # 로컬 평균 40분봉 7개 = 28 × 10분봉
TIMEOUT_C = 48 # 신호 타임아웃 8h = 48 × 10분봉
TS_C = 48 # 타임스탑 8h = 48 × 10분봉
ATR_C = 28 # ATR 5h = 7 × 40분 = 28 × 10분봉
ATR_MULT = 1.5
ATR_MIN = 0.010
ATR_MAX = 0.020
TS_MIN_PCT = 3.0
MIN_I = LOCAL_C * VOL40_C + VOL40_C + QUIET_C + 2 # = 42
# 속도 기반 진입 파라미터 (레짐별)
VEL_THRESH_BULL = 0.10 # BULL: 0.10%/분 (공격적)
VEL_THRESH_NEUTRAL = 0.15 # NEUTRAL: 0.15%/분 (보수적)
VEL_THRESH_BEAR = 0.20 # BEAR: 0.20%/분 (더 높음)
VELOCITY_MIN_MOVE = 0.5 # 최소 이동 % (잡음 제거)
VELOCITY_MIN_AGE_M = 5.0 # 최소 경과 분
# 레짐 판단 기준 (BTC 1h 변동률)
REGIME_BULL_CHANGE = 5.0 # +5% 이상 → BULL
REGIME_BEAR_CHANGE = -5.0 # -5% 이하 → BEAR
WF_WINDOW = 4
WF_MIN_WIN_RATE = 0.01
WF_SHADOW_WINS = 2
# ── 낙폭 제어 파라미터 ────────────────────────────────────────────────────────
HARD_STOP_PCT = 0.015 # 진입가 대비 -1.5% 즉시 청산 (하드 손절)
STREAK_TIGHT_N = 2 # 연속 N회 손절 시 타임스탑 강화
TS_C_TIGHT = 24 # 강화 타임스탑 보유 시간 (4h = 24 × 10분)
TS_MIN_PCT_TIGHT = 0.0 # 강화 타임스탑 최소 수익률 (0%)
# ── 레짐 헬퍼 ─────────────────────────────────────────────────────────────────
def build_regime_series(btc_df: pd.DataFrame) -> pd.Series:
"""BTC 10분봉으로 1h 변동률 계산 → 레짐 시리즈 반환."""
close = btc_df["close"]
change = close.pct_change(6) * 100 # 6 × 10분 = 1h
regime = pd.Series("neutral", index=close.index, dtype=object)
regime[change > REGIME_BULL_CHANGE] = "bull"
regime[change < REGIME_BEAR_CHANGE] = "bear"
return regime
def _vel_thresh_for(regime_s: pd.Series, ts) -> float:
"""타임스탬프 기준 레짐별 velocity 임계값 반환."""
if regime_s is None:
return VEL_THRESH_NEUTRAL
idx = regime_s.index.searchsorted(ts)
if idx >= len(regime_s):
return VEL_THRESH_NEUTRAL
r = regime_s.iloc[idx]
if r == "bull":
return VEL_THRESH_BULL
elif r == "bear":
return VEL_THRESH_BEAR
return VEL_THRESH_NEUTRAL
# ── 헬퍼 ──────────────────────────────────────────────────────────────────────
def calc_atr(df: pd.DataFrame, i: int) -> float:
sub = df.iloc[max(0, i - ATR_C):i]
if len(sub) < 3:
return ATR_MIN
try:
avg = ((sub["high"] - sub["low"]) / sub["low"]).mean()
return float(max(ATR_MIN, min(ATR_MAX, avg * ATR_MULT)))
except Exception:
return ATR_MIN
def simulate_pos(df: pd.DataFrame, buy_idx: int,
buy_price: float, stop_pct: float,
hard_stop: bool = False, tight_ts: bool = False):
"""매수 이후 청산 시점·손익 계산.
hard_stop : True → 진입가 대비 -HARD_STOP_PCT% 즉시 청산
tight_ts : True → 강화된 타임스탑 (4h / 0%) 적용
"""
peak = buy_price
hard_stop_px = buy_price * (1 - HARD_STOP_PCT) if hard_stop else None
ts_c_use = TS_C_TIGHT if tight_ts else TS_C
ts_min_use = TS_MIN_PCT_TIGHT if tight_ts else TS_MIN_PCT
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
if row["high"] > peak:
peak = row["high"]
# 1. 하드 손절 (진입가 대비 고정 %)
if hard_stop_px is not None and row["low"] <= hard_stop_px:
pnl = (hard_stop_px * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[i], pnl
# 2. 트레일링 스탑 (최고가 대비)
if row["low"] <= peak * (1 - stop_pct):
sp = peak * (1 - stop_pct)
pnl = (sp * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[i], pnl
# 3. 타임스탑
pnl_now = (row["close"] - buy_price) / buy_price * 100
if (i - buy_idx) >= ts_c_use and pnl_now < ts_min_use:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[i], pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, df.index[-1], pnl
def _prev_40m_vol(df: pd.DataFrame, i: int) -> float:
"""직전 완성 40분봉 거래량 (10분봉 4개 합산)."""
return df.iloc[max(0, i - VOL40_C):i]["volume"].sum()
def _local_vol_avg(df: pd.DataFrame, i: int) -> float:
"""로컬 5h 평균 (직전 7개 40분봉 각각의 합산 평균)."""
vols = []
for k in range(1, LOCAL_C + 1):
end = i - VOL40_C * (k - 1)
start = end - VOL40_C
if start < 0:
break
vols.append(df.iloc[start:end]["volume"].sum())
return sum(vols) / len(vols) if vols else 0
# ── 핵심 전략 루프 ─────────────────────────────────────────────────────────────
def run_strategy(df: pd.DataFrame, ticker: str,
use_velocity: bool = False,
regime_s: pd.Series = None,
dd_control: bool = False) -> list:
"""
Returns list of (is_win, pnl, buy_dt, sell_dt, ticker, entry_type)
entry_type: 'dist' | 'vel'
dd_control: True → 연속 손절 추적하여 hard_stop + tight_ts 적용
"""
trades = []
sig_i = sig_p = None
in_pos = False
buy_idx = buy_price = stop_pct = None
entry_type = "dist"
consec_losses = 0 # 연속 손절 횟수
i = MIN_I
while i < len(df):
# ── 포지션 중 → 청산 계산 후 다음 진입 탐색 ──────────────────
if in_pos:
use_hard = dd_control and consec_losses >= STREAK_TIGHT_N
use_tight = dd_control and consec_losses >= STREAK_TIGHT_N
is_win, sdt, pnl = simulate_pos(
df, buy_idx, buy_price, stop_pct,
hard_stop=use_hard, tight_ts=use_tight,
)
next_i = next(
(j for j in range(i, len(df)) if df.index[j] > sdt),
len(df),
)
trades.append((is_win, pnl, df.index[buy_idx], sdt, ticker, entry_type))
# 연속 손절 카운터 업데이트
if is_win:
consec_losses = 0
else:
consec_losses += 1
in_pos = False
sig_i = sig_p = None
i = next_i
continue
close = df.iloc[i]["close"]
# ── 신호 감지 ─────────────────────────────────────────────────
prev_vol = _prev_40m_vol(df, i)
local_avg = _local_vol_avg(df, i)
vol_r = prev_vol / local_avg if local_avg > 0 else 0
close_2h = df.iloc[i - QUIET_C]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < QUIET_PCT
spike = vol_r >= VOL_MULT
if quiet and spike:
if sig_i is None:
sig_i, sig_p = i, close
else:
if sig_i is not None and close < sig_p:
sig_i = sig_p = None
# 타임아웃
if sig_i is not None and (i - sig_i) > TIMEOUT_C:
sig_i = sig_p = None
# ── 진입 판단 ─────────────────────────────────────────────────
if sig_i is not None:
move_pct = (close - sig_p) / sig_p * 100
age_min = (i - sig_i) * 10 # 10분봉 × 10분
# A. 거리 기반
if move_pct >= THRESH:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr(df, i)
entry_type = "dist"
sig_i = sig_p = None
i += 1
continue
# B. 속도 기반 (use_velocity=True 일 때만)
if (use_velocity
and age_min >= VELOCITY_MIN_AGE_M
and move_pct >= VELOCITY_MIN_MOVE):
velocity = move_pct / age_min
vel_thresh = _vel_thresh_for(regime_s, df.index[i])
if velocity >= vel_thresh:
in_pos = True
buy_idx = i
buy_price = close
stop_pct = calc_atr(df, i)
entry_type = "vel"
sig_i = sig_p = None
i += 1
continue
i += 1
return trades
# ── WF 필터 ───────────────────────────────────────────────────────────────────
def apply_wf(trades: list) -> tuple:
history, shadow_streak, blocked = [], 0, False
accepted, blocked_cnt = [], 0
for trade in trades:
is_win = int(trade[0])
if not blocked:
accepted.append(trade)
history.append(is_win)
if len(history) >= WF_WINDOW:
if sum(history[-WF_WINDOW:]) / WF_WINDOW < WF_MIN_WIN_RATE:
blocked = True
shadow_streak = 0
else:
blocked_cnt += 1
shadow_streak = (shadow_streak + 1) if is_win else 0
if shadow_streak >= WF_SHADOW_WINS:
blocked = False
history = []
shadow_streak = 0
return accepted, blocked_cnt
# ── MAX_POSITIONS 필터 ────────────────────────────────────────────────────────
def apply_max_pos(all_trades: list) -> tuple:
open_exits, accepted, skipped = [], [], []
for trade in sorted(all_trades, key=lambda x: x[2]):
buy_dt, sell_dt = trade[2], trade[3]
open_exits = [s for s in open_exits if s > buy_dt]
if len(open_exits) < MAX_POS:
open_exits.append(sell_dt)
accepted.append(trade)
else:
skipped.append(trade)
return accepted, skipped
# ── 복리 시뮬 ─────────────────────────────────────────────────────────────────
def simulate(accepted: list) -> dict:
portfolio = float(BUDGET)
total_krw = 0.0
monthly = {}
peak_pf = BUDGET
max_dd = 0.0
win_cnt = 0
vel_count = sum(1 for t in accepted if t[5] == "vel")
vel_wins = sum(1 for t in accepted if t[5] == "vel" and t[0])
vel_pnls = [t[1] for t in accepted if t[5] == "vel"]
dist_pnls = [t[1] for t in accepted if t[5] == "dist"]
for is_win, pnl, buy_dt, sell_dt, ticker, etype in accepted:
pos_size = max(portfolio, MIN_BUDGET) / MAX_POS
krw_profit = pos_size * pnl / 100
portfolio = max(portfolio + krw_profit, MIN_BUDGET)
total_krw += krw_profit
peak_pf = max(peak_pf, portfolio)
dd = (peak_pf - portfolio) / peak_pf * 100
max_dd = max(max_dd, dd)
win_cnt += int(is_win)
ym = buy_dt.strftime("%Y-%m")
m = monthly.setdefault(ym, {"t": 0, "w": 0, "krw": 0.0})
m["t"] += 1; m["w"] += int(is_win); m["krw"] += krw_profit
n = len(accepted)
return {
"portfolio": portfolio,
"total_krw": total_krw,
"roi": (portfolio - BUDGET) / BUDGET * 100,
"n": n,
"wins": win_cnt,
"wr": win_cnt / n * 100 if n else 0,
"max_dd": max_dd,
"monthly": monthly,
"vel_count": vel_count,
"vel_wins": vel_wins,
"vel_wr": vel_wins / vel_count * 100 if vel_count else 0,
"vel_avg_pnl": sum(vel_pnls) / len(vel_pnls) if vel_pnls else 0,
"dist_avg_pnl": sum(dist_pnls) / len(dist_pnls) if dist_pnls else 0,
}
# ── 메인 ──────────────────────────────────────────────────────────────────────
def main():
print("캐시 로드...")
cache = pickle.load(open(CACHE_FILE, "rb"))
top30 = pickle.load(open(TOP_FILE, "rb"))
tickers = [t for t in top30[:TOP_N] if t in cache["10m"]]
print(f"유효 종목: {len(tickers)}\n")
# BTC 레짐 시리즈 빌드
btc_df = cache["10m"].get("KRW-BTC")
regime_s = build_regime_series(btc_df) if btc_df is not None else None
if regime_s is not None:
bull_pct = (regime_s == "bull").mean() * 100
bear_pct = (regime_s == "bear").mean() * 100
print(f"레짐 분포: BULL {bull_pct:.1f}% / NEUTRAL {100-bull_pct-bear_pct:.1f}% / BEAR {bear_pct:.1f}%")
print(f"vel threshold: BULL={VEL_THRESH_BULL} / NEUTRAL={VEL_THRESH_NEUTRAL} / BEAR={VEL_THRESH_BEAR}\n")
all_a, all_b, all_c = [], [], []
wf_a_total = wf_b_total = wf_c_total = 0
for t in tickers:
df = cache["10m"][t]
if len(df) < MIN_I + 50:
continue
raw_a = run_strategy(df, t, use_velocity=False)
raw_b = run_strategy(df, t, use_velocity=True, regime_s=regime_s)
raw_c = run_strategy(df, t, use_velocity=True, regime_s=regime_s, dd_control=True)
fa, ba = apply_wf(raw_a)
fb, bb = apply_wf(raw_b)
fc, bc = apply_wf(raw_c)
wf_a_total += ba; wf_b_total += bb; wf_c_total += bc
all_a.extend(fa); all_b.extend(fb); all_c.extend(fc)
acc_a, skp_a = apply_max_pos(all_a)
acc_b, skp_b = apply_max_pos(all_b)
acc_c, skp_c = apply_max_pos(all_c)
ra = simulate(acc_a)
rb = simulate(acc_b)
rc = simulate(acc_c)
# ── 날짜 범위 ─────────────────────────────────────────
def date_range(acc):
if acc:
s = min(t[2] for t in acc).strftime("%Y-%m-%d")
e = max(t[3] for t in acc).strftime("%Y-%m-%d")
return f"{s} ~ {e}"
return "N/A"
# ── 출력 ─────────────────────────────────────────────
W = 72
print("=" * W)
print(f" 낙폭 제어 비교 백테스트 | 10분봉 | {len(tickers)}종목")
print(f" 기간: {date_range(acc_a)}")
print(f" hard_stop={HARD_STOP_PCT*100:.1f}% | tight_ts={TS_C_TIGHT*10//60}h+{TS_MIN_PCT_TIGHT:.0f}% "
f"(연속 {STREAK_TIGHT_N}손절 후)")
print("=" * W)
print(f" {'항목':<22} {'A. 기존':>12} {'B. +속도':>12} {'C. +속도+DD제어':>14}")
print(f" {''*64}")
def row3(label, va, vb, vc, fmt="{}"):
sa, sb, sc = fmt.format(va), fmt.format(vb), fmt.format(vc)
try:
dbc = float(str(vc).replace(",","").replace("%","").replace("","")) \
- float(str(va).replace(",","").replace("%","").replace("",""))
dc = f" ({dbc:+.1f})" if abs(dbc) >= 0.01 else ""
except Exception:
dc = ""
print(f" {label:<22} {sa:>12} {sb:>12} {sc:>14}{dc}")
row3("총 진입", ra["n"], rb["n"], rc["n"], "{:,}")
row3(" 속도 진입", 0, rb["vel_count"], rc["vel_count"], "{:,}")
row3("WF 차단", wf_a_total, wf_b_total, wf_c_total, "{:,}")
row3("MAX_POS 스킵", len(skp_a), len(skp_b), len(skp_c), "{:,}")
print(f" {''*64}")
row3("승률", f"{ra['wr']:.1f}%", f"{rb['wr']:.1f}%", f"{rc['wr']:.1f}%")
row3(" 속도진입 승률","-", f"{rb['vel_wr']:.1f}%", f"{rc['vel_wr']:.1f}%")
print(f" {''*64}")
row3("평균 pnl (거리)",f"{ra['dist_avg_pnl']:.2f}%", f"{rb['dist_avg_pnl']:.2f}%", f"{rc['dist_avg_pnl']:.2f}%")
row3("평균 pnl (속도)","-", f"{rb['vel_avg_pnl']:.2f}%", f"{rc['vel_avg_pnl']:.2f}%")
print(f" {''*64}")
row3("최종 자산", f"{ra['portfolio']:,.0f}", f"{rb['portfolio']:,.0f}", f"{rc['portfolio']:,.0f}")
row3("총 수익", f"{ra['total_krw']:+,.0f}", f"{rb['total_krw']:+,.0f}", f"{rc['total_krw']:+,.0f}")
row3("수익률", f"{ra['roi']:.2f}%", f"{rb['roi']:.2f}%", f"{rc['roi']:.2f}%")
row3("최대 낙폭", f"{-ra['max_dd']:.2f}%", f"{-rb['max_dd']:.2f}%", f"{-rc['max_dd']:.2f}%")
print("=" * W)
# ── 월별 ─────────────────────────────────────────────
print(f"\n── 월별 수익 비교 {''*50}")
print(f" {'':^8}{'A':>5} {'A%':>4} {'A수익':>10}"
f"{'B':>5} {'B%':>4} {'B수익':>10}"
f"{'C':>5} {'C%':>4} {'C수익':>10}")
all_months = sorted(set(list(ra["monthly"]) + list(rb["monthly"]) + list(rc["monthly"])))
for ym in all_months:
ma = ra["monthly"].get(ym, {"t":0,"w":0,"krw":0})
mb = rb["monthly"].get(ym, {"t":0,"w":0,"krw":0})
mc = rc["monthly"].get(ym, {"t":0,"w":0,"krw":0})
wra = ma["w"]/ma["t"]*100 if ma["t"] else 0
wrb = mb["w"]/mb["t"]*100 if mb["t"] else 0
wrc = mc["w"]/mc["t"]*100 if mc["t"] else 0
print(f" {ym:^8}{ma['t']:>4}{wra:>3.0f}% {ma['krw']:>+9,.0f}원 │ "
f"{mb['t']:>4}{wrb:>3.0f}% {mb['krw']:>+9,.0f}원 │ "
f"{mc['t']:>4}{wrc:>3.0f}% {mc['krw']:>+9,.0f}")
print("=" * W)
if __name__ == "__main__":
main()

399
tests/vol_lead_sim.py Normal file
View File

@@ -0,0 +1,399 @@
"""거래량 선행(Volume Lead) 진입 전략 시뮬레이션.
3가지 전략 비교:
A (현행): 12h 가격 +5% 확인 + 1h 거래량 급증 → 진입 (이미 오른 뒤 추격)
B (신규): 가격 횡보 중 거래량 급증(축적) → 그 후 추세 +N% 시작 시 선진입
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 pickle
import time
from datetime import datetime
from pathlib import Path
import pandas as pd
from dotenv import load_dotenv
load_dotenv()
import pyupbit
# ── 공통 파라미터 ─────────────────────────────────────
STOP_LOSS_PCT = 0.015 # 트레일링 스탑 1.5%
TIME_STOP_HOURS = 8
TIME_STOP_MIN_PCT = 3.0
FEE = 0.0005
LOCAL_VOL_HOURS = 5 # 거래량 기준 이전 N시간
VOL_MULT = 2.0 # 거래량 배수 기준
# 현행 전략 파라미터
TREND_HOURS = 12
TREND_MIN_PCT = 5.0
# B 전략 파라미터: 거래량 선행 + 이후 소규모 추세 확인
PRICE_QUIET_PCT = 2.0 # 거래량 급증 시점 가격 횡보 기준 (2h 변동 < N%)
TREND_AFTER_VOL = 1.5 # 축적 신호 후 진입 기준 (vol 시점 대비 +N% 상승 시)
SIGNAL_TIMEOUT_H = 8 # 축적 신호 후 N시간 내 추세 미발생 시 초기화
FROM_DATE = "2026-01-15 00:00:00"
TICKERS = [
'KRW-DKA', 'KRW-LAYER', 'KRW-SIGN',
'KRW-SOL', 'KRW-ETH', 'KRW-XRP',
'KRW-HOLO', 'KRW-OM', 'KRW-ORBS',
]
CACHE_FILE = Path("vol_lead_cache.pkl")
# ── 데이터 로드 ───────────────────────────────────────
def fetch_all(ticker: str, from_date: str):
"""1h봉 전체 로드 (from_date 이후, 페이지 역방향 수집)."""
target = datetime.strptime(from_date, "%Y-%m-%d %H:%M:%S")
frames = []
to_dt = None
for _ in range(15): # 최대 15페이지 = 3000h ≈ 125일
kwargs: dict = dict(ticker=ticker, interval="minute60", count=200)
if to_dt:
kwargs["to"] = to_dt.strftime("%Y-%m-%d %H:%M:%S")
df = pyupbit.get_ohlcv(**kwargs)
if df is None or df.empty:
break
frames.append(df)
oldest = df.index[0].to_pydatetime().replace(tzinfo=None)
if oldest <= target:
break
to_dt = oldest
time.sleep(0.2)
if not frames:
return None
result = pd.concat(frames).sort_index().drop_duplicates()
result.index = result.index.tz_localize(None)
return result[result.index >= target]
def load_data() -> dict:
if CACHE_FILE.exists():
print(f"캐시 로드: {CACHE_FILE}")
return pickle.load(open(CACHE_FILE, "rb"))
data = {}
for ticker in TICKERS:
print(f" {ticker} 로딩...", end=" ", flush=True)
df = fetch_all(ticker, FROM_DATE)
if df is not None:
data[ticker] = df
print(f"{len(df)}봉 ({df.index[0].strftime('%m-%d')}~{df.index[-1].strftime('%m-%d')})")
else:
print("실패")
time.sleep(0.3)
pickle.dump(data, open(CACHE_FILE, "wb"))
return data
# ── 포지션 시뮬 ───────────────────────────────────────
def simulate_pos(df: pd.DataFrame, buy_idx: int, buy_price: float):
"""매수 후 청산 시뮬레이션.
- 최고가: 각 봉의 high 기준
- 스탑 발동 체크: 각 봉의 low 기준 (intra-candle 포착)
- 청산가: peak × (1 - stop_pct) 근사
"""
buy_dt = df.index[buy_idx]
peak = buy_price
for i in range(buy_idx + 1, len(df)):
row = df.iloc[i]
ts = df.index[i]
if row["high"] > peak:
peak = row["high"]
elapsed_h = (ts - buy_dt).total_seconds() / 3600
stop_price = peak * (1 - STOP_LOSS_PCT)
# 트레일링 스탑 (low가 stop_price 이하 진입 시)
if row["low"] <= stop_price:
sell_price = stop_price
pnl = (sell_price * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, sell_price, ts, f"트레일링({pnl:+.1f}%)", pnl
# 타임 스탑
pnl_now = (row["close"] - buy_price) / buy_price * 100
if elapsed_h >= TIME_STOP_HOURS and pnl_now < TIME_STOP_MIN_PCT:
pnl = (row["close"] * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, row["close"], ts, "타임스탑", pnl
last = df.iloc[-1]["close"]
pnl = (last * (1 - FEE) - buy_price * (1 + FEE)) / (buy_price * (1 + FEE)) * 100
return pnl > 0, last, df.index[-1], "데이터종료", pnl
# ── 현행 전략 (추세 확인형) ───────────────────────────
def run_trend(df: pd.DataFrame) -> list:
"""12h 가격 +5% 확인 + 1h 거래량 급증 + 1h 워치리스트."""
trades = []
watchlist_i = None
in_pos = False
buy_idx = buy_price = None
i = max(TREND_HOURS, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason))
in_pos = False
watchlist_i = None
i = next_i
continue
close = df.iloc[i]["close"]
past12 = df.iloc[i - TREND_HOURS]["close"]
trend_ok = (close - past12) / past12 * 100 >= TREND_MIN_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_ok = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
if trend_ok and vol_ok:
if watchlist_i is None:
watchlist_i = i
elif i - watchlist_i >= 1: # 1h 확인
in_pos = True
buy_idx = i
buy_price = close
watchlist_i = None
else:
watchlist_i = None
i += 1
return trades
# ── B 전략: 거래량 선행 + 소규모 추세 확인 ───────────
def run_vol_lead(df: pd.DataFrame) -> list:
"""거래량 급증(축적) 감지 후 소규모 추세 확인 시 선진입.
흐름:
1. 직전 1h 거래량 > 이전 5h 평균 × VOL_MULT AND
2h 가격 변동 < PRICE_QUIET_PCT% (횡보 중 축적)
→ 축적 신호 기록 (signal_price = 현재가)
2. 신호 후 현재가가 signal_price 대비 +TREND_AFTER_VOL% 이상 상승 시 진입
(현행 +5% 대신 작은 기준으로 더 일찍 진입)
3. SIGNAL_TIMEOUT_H 시간 내 추세 미발생 → 신호 초기화
"""
trades = []
signal_i = None
signal_price = None
in_pos = False
buy_idx = buy_price = None
i = max(TREND_HOURS, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason))
in_pos = False
signal_i = None
signal_price = None
i = next_i
continue
close = df.iloc[i]["close"]
close_2h = df.iloc[i - 2]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < PRICE_QUIET_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_spike = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
# 축적 신호 갱신
if quiet and vol_spike:
if signal_i is None:
signal_i = i
signal_price = close
else:
if signal_i is not None and close < signal_price:
# 가격 하락 → 축적 실패, 초기화
signal_i = None
signal_price = None
# 타임아웃
if signal_i is not None and (i - signal_i) > SIGNAL_TIMEOUT_H:
signal_i = None
signal_price = None
# 진입: 축적 신호 후 가격 +TREND_AFTER_VOL% 이상 상승
if signal_i is not None:
move = (close - signal_price) / signal_price * 100
if move >= TREND_AFTER_VOL:
in_pos = True
buy_idx = i
buy_price = close
signal_i = None
signal_price = None
i += 1
return trades
# ── 결과 출력 ─────────────────────────────────────────
def summarize(label: str, trades: list) -> dict:
if not trades:
print(f" [{label}] 거래 없음")
return {"total": 0, "wins": 0, "wr": 0.0, "pnl": 0.0}
wins = sum(1 for t in trades if t[0])
total = len(trades)
pnl = sum(t[1] for t in trades)
wr = wins / total * 100
print(f" [{label}] {total}건 | 승률={wr:.0f}% ({wins}{total-wins}패) | 누적={pnl:+.2f}%")
for idx, (is_win, p, bdt, sdt, reason) in enumerate(trades, 1):
mark = "" if is_win else ""
print(f" #{idx}: {mark} {p:+.2f}% | {reason}"
f" ({bdt.strftime('%m-%d %H:%M')}{sdt.strftime('%m-%d %H:%M')})")
return {"total": total, "wins": wins, "wr": wr, "pnl": pnl}
def run_vol_lead_thresh(df: pd.DataFrame, thresh: float) -> list:
"""run_vol_lead의 TREND_AFTER_VOL 파라미터를 동적으로 받는 버전."""
trades = []
signal_i = None
signal_price = None
in_pos = False
buy_idx = buy_price = None
i = max(TREND_HOURS, LOCAL_VOL_HOURS + 2)
while i < len(df):
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(df, buy_idx, buy_price)
next_i = next((j for j in range(i, len(df)) if df.index[j] > sdt), len(df))
trades.append((is_win, pnl, df.index[buy_idx], sdt, reason))
in_pos = False
signal_i = None
signal_price = None
i = next_i
continue
close = df.iloc[i]["close"]
close_2h = df.iloc[i - 2]["close"]
quiet = abs(close - close_2h) / close_2h * 100 < PRICE_QUIET_PCT
vol_recent = df.iloc[i - 1]["volume"]
vol_avg = df.iloc[i - LOCAL_VOL_HOURS - 1:i - 1]["volume"].mean()
vol_spike = vol_avg > 0 and vol_recent >= vol_avg * VOL_MULT
if quiet and vol_spike:
if signal_i is None:
signal_i = i
signal_price = close
else:
if signal_i is not None and close < signal_price:
signal_i = None
signal_price = None
if signal_i is not None and (i - signal_i) > SIGNAL_TIMEOUT_H:
signal_i = None
signal_price = None
if signal_i is not None:
move = (close - signal_price) / signal_price * 100
if move >= thresh:
in_pos = True
buy_idx = i
buy_price = close
signal_i = None
signal_price = None
i += 1
return trades
def main() -> None:
print("데이터 로딩 중...")
data = load_data()
# ── A 현행 전략 (기준선) ─────────────────────────────
print(f"\n{'='*72}")
print(f"A(현행 12h+5%+거래량) 기준선 | {FROM_DATE[:10]} ~ 현재")
print(f"{'='*72}")
agg_a = {"total": 0, "wins": 0, "pnl": 0.0}
trend_results = {}
for ticker, df in data.items():
t = run_trend(df)
trend_results[ticker] = t
s = {"total": len(t), "wins": sum(1 for x in t if x[0]),
"pnl": sum(x[1] for x in t)}
agg_a["total"] += s["total"]
agg_a["wins"] += s["wins"]
agg_a["pnl"] += s["pnl"]
a_wr = agg_a["wins"] / agg_a["total"] * 100 if agg_a["total"] else 0
print(f"A 합계: {agg_a['total']}건 | 승률={a_wr:.0f}% | 누적={agg_a['pnl']:+.2f}%")
# ── B 전략: TREND_AFTER_VOL 파라미터 스윕 ───────────
THRESHOLDS = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
print(f"\n{'='*72}")
print(f"B(거래량→+N% 선진입) 파라미터 스윕")
print(f"{''*72}")
print(f"{'임계값':>6}{'거래':>5} {'승률':>6} {'누적PnL':>10} │ vs A PnL")
print(f"{''*72}")
best = None
for thresh in THRESHOLDS:
agg = {"total": 0, "wins": 0, "pnl": 0.0}
for ticker, df in data.items():
t = run_vol_lead_thresh(df, thresh)
agg["total"] += len(t)
agg["wins"] += sum(1 for x in t if x[0])
agg["pnl"] += sum(x[1] for x in t)
wr = agg["wins"] / agg["total"] * 100 if agg["total"] else 0
diff = agg["pnl"] - agg_a["pnl"]
marker = " ← best" if (best is None or agg["pnl"] > best["pnl"]) else ""
if marker:
best = {**agg, "thresh": thresh, "wr": wr}
print(f"+{thresh:>4.1f}% │ {agg['total']:>5}{wr:>5.0f}% {agg['pnl']:>+9.2f}% │ {diff:>+8.2f}%{marker}")
print(f"{''*72}")
print(f"\n★ 최적 임계값: +{best['thresh']}% → "
f"{best['total']}건 | 승률={best['wr']:.0f}% | 누적={best['pnl']:+.2f}%")
# ── 최적 임계값으로 종목별 상세 출력 ─────────────────
best_thresh = best["thresh"]
print(f"\n{'='*72}")
print(f"★ B(vol→+{best_thresh}%) vs A(12h+5%+vol) 종목별 비교")
print(f"{''*72}")
print(f"{'종목':<14}{'A 현행':^24}{'B +{:.1f}%'.format(best_thresh):^24}")
print(f"{'':14}{'거래':>4} {'승률':>5} {'누적':>9}{'거래':>4} {'승률':>5} {'누적':>9}")
print(f"{''*72}")
for ticker, df in data.items():
t_a = trend_results[ticker]
t_b = run_vol_lead_thresh(df, best_thresh)
wa = sum(1 for x in t_a if x[0])
wb = sum(1 for x in t_b if x[0])
pa = sum(x[1] for x in t_a)
pb = sum(x[1] for x in t_b)
wr_a = wa / len(t_a) * 100 if t_a else 0
wr_b = wb / len(t_b) * 100 if t_b else 0
print(f"{ticker:<14}{len(t_a):>4}{wr_a:>4.0f}% {pa:>+8.2f}% │"
f" {len(t_b):>4}{wr_b:>4.0f}% {pb:>+8.2f}%")
if __name__ == "__main__":
main()

143
tests/wf_cmp.py Normal file
View File

@@ -0,0 +1,143 @@
"""WF 윈도우 크기별 비교 시뮬레이션.
실제 42건 거래를 시간순으로 재생하며
WF_WINDOW 크기(2, 3, 5)에 따라 차단/허용 여부를 시뮬레이션.
차단된 거래 → P&L 0 (진입 안 함)
허용된 거래 → 실제 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
from dotenv import load_dotenv
load_dotenv()
import oracledb
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
def simulate_wf(trades, window, min_wr):
"""
trades: [(ticker, is_win, pnl_pct, krw_profit, traded_at), ...] 시간순
window: WF 윈도우 크기
min_wr: 최소 승률 임계값
Returns: 허용된 거래 목록, 차단된 거래 목록, 요약
"""
history = {} # ticker → [bool, ...]
allowed = []
blocked = []
for t in trades:
ticker, is_win, pnl, profit, dt = t
hist = history.get(ticker, [])
# WF 차단 여부 판단
is_blocked = False
if len(hist) >= window:
recent_wr = sum(hist[-window:]) / window
if recent_wr < min_wr:
is_blocked = True
if is_blocked:
blocked.append(t)
else:
allowed.append(t)
# 실제 결과를 이력에 추가
hist = hist + [bool(is_win)]
if len(hist) > window * 2:
hist = hist[-window:]
history[ticker] = hist
total = len(allowed)
wins = sum(1 for t in allowed if t[1])
pnl = sum(t[2] for t in allowed)
profit = sum(t[3] for t in allowed)
return allowed, blocked, {
'total': total, 'wins': wins,
'wr': wins/total*100 if total else 0,
'pnl': pnl, 'profit': profit,
'blocked_count': len(blocked),
}
def main():
conn = get_conn()
cur = conn.cursor()
# 전체 거래 시간순 로드
cur.execute("""
SELECT ticker, is_win, pnl_pct, NVL(krw_profit,0), traded_at
FROM trade_results
ORDER BY traded_at
""")
trades = cur.fetchall()
print(f"전체 거래: {len(trades)}\n")
configs = [
(2, 0.5, "WF=2 (2연패→차단, 1승→해제)"),
(3, 0.34, "WF=3 (3건중 1승 이상 필요)"),
(5, 0.40, "WF=5 (5건중 2승 이상, 현행)"),
]
results = []
for window, min_wr, label in configs:
allowed, blocked, stats = simulate_wf(trades, window, min_wr)
stats['label'] = label
stats['window'] = window
results.append((label, allowed, blocked, stats))
print(f"[{label}]")
print(f" 허용: {stats['total']}건 | 승률={stats['wr']:.1f}% | "
f"누적수익={stats['profit']:+,.0f}원 | 차단={stats['blocked_count']}")
# 차단된 거래 상세
if blocked:
print(f" 차단된 거래:")
blocked_by_ticker = {}
for t in blocked:
blocked_by_ticker.setdefault(t[0], []).append(t)
for ticker, ts in blocked_by_ticker.items():
pnls = [f"{t[2]:+.1f}%" for t in ts]
print(f" {ticker}: {len(ts)}{pnls}")
print()
# 상세 비교표: 거래별 허용/차단 여부
print("=" * 70)
print(f"{'날짜':>12} {'종목':>12} {'결과':>6} {'PnL':>8}"
f"{'WF=2':>6} {'WF=3':>6} {'WF=5':>6}")
print("" * 70)
# 각 설정별 허용 set 구성 (traded_at + ticker로 식별)
allowed_sets = []
for _, allowed, _, _ in results:
allowed_sets.append(set((t[0], t[4]) for t in allowed))
for t in trades:
ticker, is_win, pnl, profit, dt = t
win_mark = "" if is_win else ""
cols = []
for aset in allowed_sets:
if (ticker, dt) in aset:
cols.append("허용")
else:
cols.append("🔴차단")
print(f"{dt.strftime('%m-%d %H:%M'):>12} {ticker:>12} {win_mark:>4} {pnl:>+7.1f}% │ "
f"{cols[0]:>6} {cols[1]:>6} {cols[2]:>6}")
# 최종 요약
print("\n" + "=" * 70)
print(f"{'설정':<35} {'거래':>5} {'승률':>7} {'KRW수익':>12} {'차단':>5}")
print("" * 70)
for label, _, _, s in results:
print(f"{label:<35} {s['total']:>5}{s['wr']:>6.1f}% {s['profit']:>+12,.0f}{s['blocked_count']:>4}건 차단")
conn.close()
if __name__ == "__main__":
main()

214
tests/wf_cmp2.py Normal file
View File

@@ -0,0 +1,214 @@
"""WF 윈도우 비교 시뮬레이션 v2 - 실거래 + 이후 시뮬 거래 통합.
Phase 1: 실제 42건 거래를 WF 설정별로 허용/차단 재생
Phase 2: 마지막 실거래 이후 price_history 기반 신호로 추가 거래 시뮬
(추세 2h+5% + 15분 워치리스트, 모멘텀은 API 한계로 생략)
→ WF 상태는 Phase1에서 이어짐
비교 설정:
A: WF=2 (min_wr=0.0, 즉 2연패시만 차단 — last2=[L,L]이면 차단)
B: WF=3 (min_wr=0.34)
C: WF=5 현행 (min_wr=0.40)
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
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
import oracledb
STOP_LOSS_PCT = float(os.getenv("STOP_LOSS_PCT", "1.5")) / 100
TIME_STOP_HOURS = int(os.getenv("TIME_STOP_HOURS", "8"))
TIME_STOP_MIN_PCT = float(os.getenv("TIME_STOP_MIN_GAIN_PCT", "3")) / 100
TREND_MIN_PCT = 5.0
CONFIRM_MINUTES = 15
FEE = 0.0005
def get_conn():
return oracledb.connect(
user=os.getenv('ORACLE_USER'), password=os.getenv('ORACLE_PASSWORD'),
dsn=os.getenv('ORACLE_DSN'), config_dir=os.getenv('ORACLE_WALLET'))
# ── WF 판단 ───────────────────────────────────────────
def is_wf_blocked(hist, window, min_wr):
if window == 0: return False
if len(hist) < window: return False
wr = sum(hist[-window:]) / window
return wr < min_wr
# ── 추세 체크 (price_history 기반) ────────────────────
def check_trend(prices, idx):
lb = 12 # 2h = 12 * 10분봉
if idx < lb: return False
curr, past = prices[idx][0], prices[idx-lb][0]
return past > 0 and (curr-past)/past*100 >= TREND_MIN_PCT
# ── 포지션 시뮬 ───────────────────────────────────────
def simulate_pos(prices, buy_idx, buy_price):
buy_dt = prices[buy_idx][1]
peak = buy_price
for price, ts in prices[buy_idx+1:]:
if price > peak: peak = price
elapsed_h = (ts - buy_dt).total_seconds() / 3600
pnl = (price - buy_price) / buy_price
if (peak - price) / peak >= STOP_LOSS_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net>0, price, ts, f"트레일링({pnl*100:+.1f}%)", net
if elapsed_h >= TIME_STOP_HOURS and pnl < TIME_STOP_MIN_PCT:
net = (price*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net>0, price, ts, "타임스탑", net
lp, lt = prices[-1]
net = (lp*(1-FEE) - buy_price*(1+FEE)) / (buy_price*(1+FEE)) * 100
return net>0, lp, lt, "데이터종료", net
# ── Phase1: 실거래 재생 ───────────────────────────────
def phase1(real_trades, window, min_wr):
"""42건 실거래 재생. Returns (허용목록, 차단목록, history_per_ticker)"""
history = {}
allowed = []
blocked = []
for t in real_trades:
ticker, is_win, pnl, profit, dt = t
hist = history.get(ticker, [])
if is_wf_blocked(hist, window, min_wr):
blocked.append(('block', ticker, is_win, pnl, profit, dt))
else:
allowed.append(('real', ticker, is_win, pnl, profit, dt))
hist = hist + [bool(is_win)]
if window > 0 and len(hist) > window * 2:
hist = hist[-window:]
history[ticker] = hist
return allowed, blocked, history
# ── Phase2: price_history 신호 시뮬 ──────────────────
def phase2(cur, history, real_last_dt, window, min_wr):
"""실거래 종료 이후 price_history 기반 신호 시뮬레이션."""
# 스캔 대상: 실거래에 등장한 종목 전체
tickers = list(history.keys()) if history else []
# 실거래 후 WF 해제 가능한 종목만
# (차단됐어도 shadow 없이는 해제 불가 → 차단 상태 종목 제외)
active_tickers = []
for ticker in tickers:
hist = history.get(ticker, [])
if not is_wf_blocked(hist, window, min_wr):
active_tickers.append(ticker)
if not active_tickers:
return [], history
sim_trades = []
for ticker in active_tickers:
cur.execute("""
SELECT price, recorded_at FROM price_history
WHERE ticker=:t AND recorded_at > :dt
ORDER BY recorded_at
""", t=ticker, dt=real_last_dt)
prices = cur.fetchall()
if len(prices) < 13: continue
hist = list(history.get(ticker, []))
watchlist_dt = None
in_pos = False
buy_idx = buy_price = None
idx = 0
while idx < len(prices):
price, dt = prices[idx]
if in_pos:
is_win, sp, sdt, reason, pnl = simulate_pos(prices, buy_idx, buy_price)
next_idx = next((i for i,(_, ts) in enumerate(prices) if ts > sdt), len(prices))
profit = pnl * 3333333 / 100 # 포지션당 예산 기준 근사
sim_trades.append(('sim', ticker, is_win, pnl, profit, dt))
hist = hist + [bool(is_win)]
if window > 0 and len(hist) > window * 2:
hist = hist[-window:]
history[ticker] = hist
in_pos = False
watchlist_dt = None
idx = next_idx
continue
if is_wf_blocked(hist, window, min_wr):
idx += 1
continue
trend_ok = check_trend(prices, idx)
if trend_ok:
if watchlist_dt is None:
watchlist_dt = dt
elif (dt - watchlist_dt).total_seconds() >= CONFIRM_MINUTES * 60:
in_pos = True
buy_idx = idx
buy_price = price
watchlist_dt = None
else:
watchlist_dt = None
idx += 1
return sim_trades, history
# ── 요약 출력 ─────────────────────────────────────────
def print_summary(label, p1_allowed, p1_blocked, p2_trades):
all_trades = p1_allowed + p2_trades
total = len(all_trades)
wins = sum(1 for t in all_trades if t[2])
pnl = sum(t[4] for t in all_trades)
wr = wins/total*100 if total else 0
blk = len(p1_blocked)
p2_cnt = len(p2_trades)
p2_win = sum(1 for t in p2_trades if t[2])
print(f"\n[{label}]")
print(f" 실거래 허용: {len(p1_allowed)}건 | 차단: {blk}")
print(f" 추가 시뮬: {p2_cnt}건 ({p2_win}승)")
print(f" ─────────────────────────────────────")
print(f" 합계: {total}건 | 승률={wr:.1f}% | KRW={pnl:+,.0f}")
return {'label': label, 'total': total, 'wins': wins, 'wr': wr, 'pnl': pnl,
'blk': blk, 'p2': p2_cnt}
def main():
conn = get_conn()
cur = conn.cursor()
cur.execute("""
SELECT ticker, is_win, pnl_pct, NVL(krw_profit,0), traded_at
FROM trade_results ORDER BY traded_at
""")
real_trades = cur.fetchall()
real_last_dt = real_trades[-1][4]
print(f"실거래: {len(real_trades)}건 (마지막: {real_last_dt.strftime('%m-%d %H:%M')})")
cur.execute("SELECT MAX(recorded_at) FROM price_history")
ph_last = cur.fetchone()[0]
print(f"price_history 끝: {ph_last.strftime('%m-%d %H:%M')}\n")
configs = [
(2, 0.01, "WF=2 (2연패→차단)"),
(3, 0.34, "WF=3"),
(5, 0.40, "WF=5 현행"),
(0, 0.00, "WF없음"),
]
summary = []
for window, min_wr, label in configs:
p1_allowed, p1_blocked, history = phase1(real_trades, window, min_wr)
p2_trades, _ = phase2(cur, history, real_last_dt, window, min_wr)
s = print_summary(label, p1_allowed, p1_blocked, p2_trades)
summary.append(s)
print(f"\n{'='*62}")
print(f"{'설정':<22} {'허용':>5} {'차단':>5} {'추가시뮬':>8} {'승률':>7} {'KRW수익':>13}")
print(f"{''*62}")
for s in summary:
print(f"{s['label']:<22} {s['total']-s['p2']:>5}{s['blk']:>5}"
f"{s['p2']:>6}{s['wr']:>6.1f}% {s['pnl']:>+12,.0f}")
conn.close()
if __name__ == "__main__":
main()