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:
214
tests/atr_sweep.py
Normal file
214
tests/atr_sweep.py
Normal 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
1803
tests/backtest.py
Normal file
File diff suppressed because it is too large
Load Diff
250
tests/backtest_db.py
Normal file
250
tests/backtest_db.py
Normal 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
318
tests/fng_1y_backtest.py
Normal 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()
|
||||
320
tests/fng_adaptive_backtest.py
Normal file
320
tests/fng_adaptive_backtest.py
Normal 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
258
tests/fng_backtest.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""공포탐욕지수(F&G) 조건별 백테스트
|
||||
|
||||
질문: "F&G 수치에 따라 진입 조건을 제한하면 성과가 나아지는가?"
|
||||
|
||||
테스트 구간:
|
||||
A. 필터 없음 (baseline)
|
||||
B. F&G > 25 (Extreme Fear 구간 제외)
|
||||
C. F&G > 45 (Fear 이하 구간 제외, Neutral 이상)
|
||||
D. F&G > 55 (Greed 이상만 진입)
|
||||
E. F&G 20~45 (Fear 구간만 진입 – 역발상 매수)
|
||||
|
||||
진입 전략: 모멘텀 (거래량 급증 + 횡보 → +3% 상승 시 매수)
|
||||
청산: 트레일링 스탑 1.5% + 타임 스탑 24h/+3%
|
||||
"""
|
||||
|
||||
import os as _os, sys as _sys
|
||||
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json, time, sys, datetime, urllib.request
|
||||
import pyupbit
|
||||
import pandas as pd
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ── 종목 ──────────────────────────────────────────────────────
|
||||
TICKERS = [
|
||||
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
|
||||
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
|
||||
"KRW-SUI", "KRW-HBAR", "KRW-TRX",
|
||||
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
|
||||
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
|
||||
]
|
||||
|
||||
# ── 공통 파라미터 ─────────────────────────────────────────────
|
||||
VOL_MULT = 2.0
|
||||
QUIET_2H = 2.0
|
||||
SIG_TO_H = 8
|
||||
MOM_THR = 3.0
|
||||
SIG_CANCEL = 3.0
|
||||
TRAIL_STOP = 0.015 # 1.5% (앞 테스트에서 최적)
|
||||
TIME_H = 24
|
||||
TIME_MIN = 3.0
|
||||
|
||||
|
||||
# ── F&G 데이터 로드 ───────────────────────────────────────────
|
||||
def load_fng(days: int = 365) -> dict[str, int]:
|
||||
"""날짜(YYYY-MM-DD) → F&G 지수 딕셔너리 반환."""
|
||||
url = f"https://api.alternative.me/fng/?limit={days}&format=json"
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
result = {}
|
||||
for d in data["data"]:
|
||||
dt_str = datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d")
|
||||
result[dt_str] = int(d["value"])
|
||||
return result
|
||||
|
||||
|
||||
def fng_at(fng_map: dict, ts: pd.Timestamp) -> int:
|
||||
"""타임스탬프에 해당하는 F&G 값 반환. 없으면 50(Neutral) 반환."""
|
||||
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
|
||||
|
||||
|
||||
# ── F&G 필터 정의 ─────────────────────────────────────────────
|
||||
CONFIGS = [
|
||||
(None, None, "필터 없음 (baseline)"),
|
||||
(26, None, "F&G > 25 (Extreme Fear 제외)"),
|
||||
(46, None, "F&G > 45 (Neutral 이상)"),
|
||||
(56, None, "F&G > 55 (Greed 이상)"),
|
||||
(20, 45, "F&G 20~45 (Fear 역발상 매수)"),
|
||||
]
|
||||
|
||||
|
||||
# ── 시뮬레이션 ────────────────────────────────────────────────
|
||||
@dataclass
|
||||
class Trade:
|
||||
pnl: float
|
||||
h: int
|
||||
fng: int
|
||||
exit: str
|
||||
|
||||
|
||||
def simulate(df, fng_map: dict, fng_lo=None, fng_hi=None) -> list[Trade]:
|
||||
closes = df["close"].values
|
||||
vols = df["volume"].values
|
||||
idx = df.index
|
||||
trades: list[Trade] = []
|
||||
sig_px = sig_i = None
|
||||
pos_buy = pos_peak = pos_i = pos_fng = None
|
||||
|
||||
for i in range(7, len(closes) - max(TIME_H + 4, 10)):
|
||||
|
||||
# ── 포지션 관리 ──────────────────────────────────────
|
||||
if pos_buy is not None:
|
||||
cur = closes[i]
|
||||
if cur > pos_peak:
|
||||
pos_peak = cur
|
||||
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
|
||||
pnl = (cur - pos_buy) / pos_buy * 100
|
||||
trades.append(Trade(pnl, i - pos_i, pos_fng, "trail"))
|
||||
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
|
||||
continue
|
||||
if i - pos_i >= TIME_H:
|
||||
pnl = (cur - pos_buy) / pos_buy * 100
|
||||
if pnl < TIME_MIN:
|
||||
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
|
||||
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
|
||||
continue
|
||||
continue
|
||||
|
||||
# ── 신호 유효성 ──────────────────────────────────────
|
||||
if sig_px is not None:
|
||||
if i - sig_i > SIG_TO_H:
|
||||
sig_px = sig_i = None
|
||||
elif (closes[i] - sig_px) / sig_px * 100 < -SIG_CANCEL:
|
||||
sig_px = sig_i = None
|
||||
|
||||
# ── 축적 감지 ─────────────────────────────────────────
|
||||
if sig_px is None:
|
||||
vol_avg = vols[i - 6:i - 1].mean()
|
||||
if vol_avg <= 0:
|
||||
continue
|
||||
if vols[i - 1] / vol_avg >= VOL_MULT:
|
||||
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H:
|
||||
sig_px = closes[i]
|
||||
sig_i = i
|
||||
continue
|
||||
|
||||
# ── F&G 필터 체크 ─────────────────────────────────────
|
||||
fng_val = fng_at(fng_map, idx[i])
|
||||
if fng_lo is not None and fng_val < fng_lo:
|
||||
continue
|
||||
if fng_hi is not None and fng_val > fng_hi:
|
||||
continue
|
||||
|
||||
# ── 모멘텀 진입 ───────────────────────────────────────
|
||||
if (closes[i] - sig_px) / sig_px * 100 >= MOM_THR:
|
||||
pos_buy = pos_peak = closes[i]
|
||||
pos_i = i
|
||||
pos_fng = fng_val
|
||||
sig_px = sig_i = None
|
||||
|
||||
return trades
|
||||
|
||||
|
||||
def stats(trades: list[Trade]) -> dict:
|
||||
if not trades:
|
||||
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
|
||||
avg_win=0, avg_loss=0, avg_fng=0, max_dd=0)
|
||||
wins = [t for t in trades if t.pnl > 0]
|
||||
losses = [t for t in trades if t.pnl <= 0]
|
||||
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
|
||||
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
|
||||
rr = abs(aw / al) if al else 0
|
||||
|
||||
cum = 0.0; pk = 0.0; max_dd = 0.0
|
||||
for t in trades:
|
||||
cum += t.pnl
|
||||
if cum > pk: pk = cum
|
||||
if pk - cum > max_dd: max_dd = pk - cum
|
||||
|
||||
return dict(
|
||||
n = len(trades),
|
||||
wr = len(wins) / len(trades) * 100,
|
||||
avg_pnl = sum(t.pnl for t in trades) / len(trades),
|
||||
total_pnl= sum(t.pnl for t in trades),
|
||||
rr = rr,
|
||||
avg_win = aw,
|
||||
avg_loss = al,
|
||||
avg_fng = sum(t.fng for t in trades) / len(trades),
|
||||
max_dd = max_dd,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("F&G 데이터 로드 중...")
|
||||
fng_map = load_fng(365)
|
||||
print(f" {len(fng_map)}일치 F&G 데이터 로드 완료\n")
|
||||
|
||||
print(f"종목 데이터 수집 중 ({len(TICKERS)}개, 60일 1h 캔들)...")
|
||||
datasets: dict = {}
|
||||
for i, tk in enumerate(TICKERS):
|
||||
try:
|
||||
df = pyupbit.get_ohlcv(tk, interval="minute60", count=1440)
|
||||
if df is not None and len(df) > 50:
|
||||
datasets[tk] = df
|
||||
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ")
|
||||
time.sleep(0.08)
|
||||
except Exception:
|
||||
pass
|
||||
sys.stderr.write("\n")
|
||||
print(f" 완료: {len(datasets)}개 종목\n")
|
||||
|
||||
# ── 결과 집계 ──────────────────────────────────────────────
|
||||
print("=" * 78)
|
||||
print(" F&G 조건별 백테스트 결과 (60일 / 모멘텀 진입 / 스탑 1.5%)")
|
||||
print("=" * 78)
|
||||
print(f" {'조건':<30} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
|
||||
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} {'진입F&G':>7}")
|
||||
print(" " + "-" * 74)
|
||||
|
||||
all_results = {}
|
||||
for fng_lo, fng_hi, label in CONFIGS:
|
||||
all_trades: list[Trade] = []
|
||||
for df in datasets.values():
|
||||
all_trades.extend(simulate(df, fng_map, fng_lo, fng_hi))
|
||||
s = stats(all_trades)
|
||||
all_results[label] = (s, all_trades)
|
||||
marker = " ◀ 현행" if "없음" in label else ""
|
||||
if s["n"] == 0:
|
||||
print(f" {label:<30} 거래 없음")
|
||||
continue
|
||||
print(
|
||||
f" {label:<30} {s['n']:>5}건 {s['wr']:>5.1f}% "
|
||||
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
|
||||
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
|
||||
f"{s['avg_fng']:>6.1f}{marker}"
|
||||
)
|
||||
|
||||
# ── F&G 구간별 세부 성과 ──────────────────────────────────
|
||||
print()
|
||||
print(" F&G 수치 구간별 실제 거래 성과 (필터 없음 전체 거래 기준):")
|
||||
base_trades = all_results["필터 없음 (baseline)"][1]
|
||||
|
||||
zones = [
|
||||
(0, 25, "Extreme Fear (0~25) "),
|
||||
(26, 45, "Fear (26~45)"),
|
||||
(46, 55, "Neutral (46~55)"),
|
||||
(56, 75, "Greed (56~75)"),
|
||||
(76, 100, "Extreme Greed(76~100)"),
|
||||
]
|
||||
print(f" {'F&G 구간':<28} {'건수':>5} {'승률':>6} {'평균PnL':>9} {'손익비':>6}")
|
||||
print(" " + "-" * 60)
|
||||
for lo, hi, name in zones:
|
||||
subset = [t for t in base_trades if lo <= t.fng <= hi]
|
||||
if not subset:
|
||||
continue
|
||||
s = stats(subset)
|
||||
print(
|
||||
f" {name:<28} {s['n']:>5}건 {s['wr']:>5.1f}% "
|
||||
f"{s['avg_pnl']:>+8.3f}% {s['rr']:>5.2f}"
|
||||
)
|
||||
|
||||
# ── 월별 F&G 흐름과 성과 ─────────────────────────────────
|
||||
print()
|
||||
print(" F&G 추이와 진입 시점 분포:")
|
||||
sorted_fng = sorted(fng_map.items())[-70:] # 최근 70일
|
||||
for dt_str, val in sorted_fng[::7]: # 1주 간격
|
||||
bar_len = val // 5
|
||||
bar = "█" * bar_len
|
||||
zone = ("극공포" if val <= 25 else "공포" if val <= 45
|
||||
else "중립" if val <= 55 else "탐욕")
|
||||
print(f" {dt_str} {val:>3} {bar:<20} {zone}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
385
tests/fng_sim_comparison.py
Normal file
385
tests/fng_sim_comparison.py
Normal 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
240
tests/interval_sweep.py
Normal 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
177
tests/krw_sim.py
Normal 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
189
tests/momentum_cmp.py
Normal 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
216
tests/ohlcv_db.py
Normal 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
248
tests/pullback_backtest.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""눌림목 진입 vs 모멘텀 진입 백테스트 비교
|
||||
|
||||
전략 A — 모멘텀 (현행):
|
||||
거래량 급증 + 횡보 감지 → 신호가 기록
|
||||
→ 신호가 대비 +3% 상승 확인 시 즉시 매수
|
||||
|
||||
전략 B — 눌림목:
|
||||
거래량 급증 + 횡보 감지 → 신호가 기록
|
||||
→ 신호가 대비 -1.5% 이하로 눌림 확인
|
||||
→ 눌림 저점에서 +0.5% 회복 시 매수
|
||||
|
||||
공통 청산:
|
||||
트레일링 스탑 (최고가 대비 -2%) + 타임 스탑 (24h / +3% 미달 시)
|
||||
|
||||
데이터: 1h 캔들 60일 (최근)
|
||||
"""
|
||||
|
||||
import os as _os, sys as _sys
|
||||
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||
|
||||
from __future__ import annotations
|
||||
import pyupbit, time, sys
|
||||
from collections import defaultdict
|
||||
|
||||
# ─── 종목 (현재 전략이 스캔하는 상위권 종목) ───────────────────
|
||||
TICKERS = [
|
||||
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
|
||||
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
|
||||
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM", "KRW-SAND",
|
||||
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
|
||||
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
|
||||
]
|
||||
|
||||
# ─── 공통 파라미터 ──────────────────────────────────────────────
|
||||
VOL_MULT = 2.0 # 거래량 급증 배수 (직전 캔들 / 5h 평균)
|
||||
QUIET_2H = 2.0 # 2h 횡보 기준 (%)
|
||||
SIGNAL_TO_H = 8 # 신호 유효 시간 (h)
|
||||
TRAIL_STOP = 0.020 # 트레일링 스탑 2%
|
||||
TIME_STOP_H = 24 # 타임 스탑 기준 시간
|
||||
TIME_STOP_MIN = 3.0 # 타임 스탑 최소 수익 (%)
|
||||
SIGNAL_CANCEL = 3.0 # 신호가 대비 -3% 하락 시 신호 취소 (%)
|
||||
|
||||
# ─── 전략 A: 모멘텀 진입 ─────────────────────────────────────
|
||||
MOMENTUM_THR = 3.0 # 신호가 대비 +3% 이상 상승 시 매수
|
||||
|
||||
# ─── 전략 B: 눌림목 진입 ─────────────────────────────────────
|
||||
# 다양한 깊이 비교 (PULLBACK_DEPTH: 신호가 대비 몇 % 눌림 대기)
|
||||
PULLBACK_CONFIGS = [
|
||||
(0.5, 0.3, "눌림0.5%+회복0.3%"),
|
||||
(1.0, 0.5, "눌림1.0%+회복0.5%"),
|
||||
(1.5, 0.5, "눌림1.5%+회복0.5%"),
|
||||
(2.0, 0.5, "눌림2.0%+회복0.5%"),
|
||||
]
|
||||
|
||||
|
||||
def simulate(df, strategy: str, pb_depth: float = 1.5, pb_recovery: float = 0.5) -> dict:
|
||||
closes = df["close"].values
|
||||
vols = df["volume"].values
|
||||
|
||||
trades = []
|
||||
sig_px = sig_i = dip_px = None
|
||||
pos_buy = pos_peak = pos_i = None
|
||||
|
||||
vol_window = 5 # 5h 평균 (1h 캔들 기준)
|
||||
|
||||
for i in range(vol_window + 2, len(closes) - max(TIME_STOP_H + 4, 10)):
|
||||
|
||||
# ── 포지션 관리 ────────────────────────────────────────
|
||||
if pos_buy is not None:
|
||||
cur = closes[i]
|
||||
if cur > pos_peak:
|
||||
pos_peak = cur
|
||||
|
||||
# 트레일링 스탑
|
||||
if (pos_peak - cur) / pos_peak >= TRAIL_STOP:
|
||||
pnl = (cur - pos_buy) / pos_buy * 100
|
||||
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "trail"})
|
||||
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
|
||||
continue
|
||||
|
||||
# 타임 스탑
|
||||
if i - pos_i >= TIME_STOP_H:
|
||||
pnl = (cur - pos_buy) / pos_buy * 100
|
||||
if pnl < TIME_STOP_MIN:
|
||||
trades.append({"pnl": pnl, "h": i - pos_i, "exit": "time"})
|
||||
pos_buy = pos_peak = pos_i = sig_px = sig_i = dip_px = None
|
||||
continue
|
||||
continue
|
||||
|
||||
# ── 신호 유효성 체크 ──────────────────────────────────
|
||||
if sig_px is not None:
|
||||
# 시간 초과
|
||||
if i - sig_i > SIGNAL_TO_H:
|
||||
sig_px = sig_i = dip_px = None
|
||||
# 신호가 대비 큰 하락 → 축적 실패
|
||||
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
|
||||
sig_px = sig_i = dip_px = None
|
||||
|
||||
# ── 신호 없으면 축적 조건 탐색 ────────────────────────
|
||||
if sig_px is None:
|
||||
vol_avg = vols[i - vol_window - 1 : i - 1].mean()
|
||||
if vol_avg <= 0:
|
||||
continue
|
||||
vol_ratio = vols[i - 1] / vol_avg
|
||||
quiet = abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < QUIET_2H
|
||||
if vol_ratio >= VOL_MULT and quiet:
|
||||
sig_px = closes[i]
|
||||
sig_i = i
|
||||
dip_px = None
|
||||
continue
|
||||
|
||||
# ── 진입 로직 ─────────────────────────────────────────
|
||||
cur = closes[i]
|
||||
move = (cur - sig_px) / sig_px * 100
|
||||
|
||||
if strategy == "momentum":
|
||||
if move >= MOMENTUM_THR:
|
||||
pos_buy = pos_peak = cur
|
||||
pos_i = i
|
||||
sig_px = sig_i = None
|
||||
|
||||
else: # pullback
|
||||
if dip_px is None:
|
||||
if move <= -pb_depth:
|
||||
dip_px = cur
|
||||
else:
|
||||
if cur < dip_px:
|
||||
dip_px = cur # 저점 갱신
|
||||
recovery = (cur - dip_px) / dip_px * 100
|
||||
if recovery >= pb_recovery:
|
||||
pos_buy = pos_peak = cur
|
||||
pos_i = i
|
||||
sig_px = sig_i = dip_px = None
|
||||
|
||||
if not trades:
|
||||
return {"n": 0, "wins": 0, "wr": 0.0, "avg_pnl": 0.0, "total_pnl": 0.0, "avg_h": 0.0}
|
||||
|
||||
wins = [t for t in trades if t["pnl"] > 0]
|
||||
return {
|
||||
"n": len(trades),
|
||||
"wins": len(wins),
|
||||
"wr": len(wins) / len(trades) * 100,
|
||||
"avg_pnl": sum(t["pnl"] for t in trades) / len(trades),
|
||||
"total_pnl": sum(t["pnl"] for t in trades),
|
||||
"avg_h": sum(t["h"] for t in trades) / len(trades),
|
||||
}
|
||||
|
||||
|
||||
def agg(results: list[dict]) -> dict:
|
||||
"""종목별 결과 리스트를 합산."""
|
||||
if not results:
|
||||
return {"n": 0, "wins": 0, "wr": 0, "avg_pnl": 0, "total_pnl": 0, "avg_h": 0}
|
||||
total_n = sum(r["n"] for r in results)
|
||||
total_wins = sum(r["wins"] for r in results)
|
||||
total_pnl = sum(r["total_pnl"] for r in results)
|
||||
all_h = [r["avg_h"] for r in results if r["n"] > 0]
|
||||
return {
|
||||
"n": total_n,
|
||||
"wins": total_wins,
|
||||
"wr": total_wins / total_n * 100 if total_n else 0,
|
||||
"avg_pnl": total_pnl / total_n if total_n else 0,
|
||||
"total_pnl": total_pnl,
|
||||
"avg_h": sum(all_h) / len(all_h) if all_h else 0,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
|
||||
datasets: dict[str, object] = {}
|
||||
for i, ticker in enumerate(TICKERS):
|
||||
try:
|
||||
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
|
||||
if df is not None and len(df) > 50:
|
||||
datasets[ticker] = df
|
||||
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
|
||||
time.sleep(0.08)
|
||||
except Exception:
|
||||
pass
|
||||
sys.stderr.write("\n")
|
||||
print(f" 수집 완료: {len(datasets)}개 종목\n")
|
||||
|
||||
# 전략별 결과 수집
|
||||
strat_labels = ["모멘텀(현행)", "눌림0.5%", "눌림1.0%", "눌림1.5%", "눌림2.0%"]
|
||||
strat_results: dict[str, list[dict]] = {l: [] for l in strat_labels}
|
||||
|
||||
for ticker, df in datasets.items():
|
||||
r_mom = simulate(df, "momentum")
|
||||
strat_results["모멘텀(현행)"].append(r_mom)
|
||||
for depth, rec, label in PULLBACK_CONFIGS:
|
||||
lbl = f"눌림{depth:.1f}%"
|
||||
r_pb = simulate(df, "pullback", pb_depth=depth, pb_recovery=rec)
|
||||
strat_results[lbl].append(r_pb)
|
||||
|
||||
# ─── 결과 출력 ─────────────────────────────────────────────
|
||||
print("=" * 72)
|
||||
print(" 전략 비교 (60일 / 25개 종목 합산)")
|
||||
print("=" * 72)
|
||||
print(f" {'전략':<16} {'거래':>5} {'승률':>7} {'평균PnL':>9} {'총PnL(%)':>10} {'평균보유':>8}")
|
||||
print(" " + "-" * 62)
|
||||
|
||||
for label in strat_labels:
|
||||
r = agg(strat_results[label])
|
||||
if r["n"] == 0:
|
||||
print(f" {label:<16} 데이터 없음")
|
||||
continue
|
||||
marker = " ◀ 현행" if label == "모멘텀(현행)" else ""
|
||||
print(
|
||||
f" {label:<16} {r['n']:>5}건 {r['wr']:>6.1f}% "
|
||||
f"{r['avg_pnl']:>+8.3f}% {r['total_pnl']:>+9.2f}% "
|
||||
f"{r['avg_h']:>6.1f}h{marker}"
|
||||
)
|
||||
|
||||
# 승률/손익 상세 비교
|
||||
print()
|
||||
print(" 손익비 (avg_win / |avg_loss|) 비교:")
|
||||
for label in strat_labels:
|
||||
all_trades = []
|
||||
for r_list in [simulate(df, "momentum") if label == "모멘텀(현행)"
|
||||
else simulate(df, "pullback",
|
||||
pb_depth=float(label.replace("눌림","").replace("%","")),
|
||||
pb_recovery=0.5)
|
||||
for df in datasets.values()]:
|
||||
pass # need per-trade data
|
||||
|
||||
# 종목별 상세 (상위/하위)
|
||||
print()
|
||||
print(" 종목별 모멘텀 vs 눌림1.5% 비교:")
|
||||
print(f" {'종목':<14} {'모멘텀 WR':>9} {'모멘텀 PnL':>11} {'눌림1.5% WR':>11} {'눌림1.5% PnL':>12}")
|
||||
print(" " + "-" * 62)
|
||||
|
||||
ticker_list = list(datasets.keys())
|
||||
for j, ticker in enumerate(ticker_list):
|
||||
df = datasets[ticker]
|
||||
rm = strat_results["모멘텀(현행)"][j]
|
||||
rp = strat_results["눌림1.5%"][j]
|
||||
if rm["n"] == 0 and rp["n"] == 0:
|
||||
continue
|
||||
rm_wr = f"{rm['wr']:.0f}%" if rm["n"] else "-"
|
||||
rm_pnl = f"{rm['total_pnl']:+.2f}%" if rm["n"] else "-"
|
||||
rp_wr = f"{rp['wr']:.0f}%" if rp["n"] else "-"
|
||||
rp_pnl = f"{rp['total_pnl']:+.2f}%" if rp["n"] else "-"
|
||||
diff = (rp["total_pnl"] - rm["total_pnl"]) if (rm["n"] and rp["n"]) else 0
|
||||
mark = "↑" if diff > 0.5 else ("↓" if diff < -0.5 else "=")
|
||||
print(f" {ticker:<14} {rm_wr:>9} {rm_pnl:>11} {rp_wr:>11} {rp_pnl:>12} {mark}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
159
tests/shadow_sim.py
Normal file
159
tests/shadow_sim.py
Normal 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
158
tests/shadow_sim2.py
Normal 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
323
tests/shadow_sim3.py
Normal 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
308
tests/sim10m.py
Normal 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
229
tests/sim_365.py
Normal 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
296
tests/sim_45m40.py
Normal 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
260
tests/stop_sweep.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""트레일링 스탑 크기 스윕 백테스트
|
||||
|
||||
현행 모멘텀 진입 전략에서
|
||||
트레일링 스탑 크기(1% ~ 6%)를 바꿔가며 성과 비교.
|
||||
|
||||
핵심 질문: "2% 스탑이 너무 좁아서 정상 노이즈에 스탑이 걸리는 건 아닌가?"
|
||||
"""
|
||||
import os as _os, sys as _sys
|
||||
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pyupbit, time, sys
|
||||
from dataclasses import dataclass, field
|
||||
from collections import defaultdict
|
||||
|
||||
TICKERS = [
|
||||
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
|
||||
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
|
||||
"KRW-SUI", "KRW-HBAR", "KRW-TRX", "KRW-ATOM",
|
||||
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO", "KRW-ENSO",
|
||||
"KRW-KAVA", "KRW-KNC", "KRW-OM", "KRW-STEEM", "KRW-ORBS",
|
||||
]
|
||||
|
||||
# 공통
|
||||
VOL_MULT = 2.0
|
||||
QUIET_2H = 2.0
|
||||
SIGNAL_TO_H = 8
|
||||
MOMENTUM_THR = 3.0
|
||||
SIGNAL_CANCEL = 3.0
|
||||
TIME_STOP_H = 24
|
||||
TIME_STOP_MIN = 3.0 # 24h 후 +3% 미달 시 청산
|
||||
|
||||
# 테스트할 스탑 크기 목록
|
||||
STOP_SIZES = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
pnl: float
|
||||
h: int
|
||||
exit: str # trail | time
|
||||
|
||||
|
||||
def simulate(df, trail_stop_pct: float) -> list[Trade]:
|
||||
closes = df["close"].values
|
||||
vols = df["volume"].values
|
||||
trail = trail_stop_pct / 100.0
|
||||
|
||||
trades: list[Trade] = []
|
||||
sig_px = sig_i = None
|
||||
pos_buy = pos_peak = pos_i = None
|
||||
|
||||
for i in range(7, len(closes) - max(TIME_STOP_H + 4, 10)):
|
||||
|
||||
# ── 포지션 관리 ──────────────────────────────────────
|
||||
if pos_buy is not None:
|
||||
cur = closes[i]
|
||||
if cur > pos_peak:
|
||||
pos_peak = cur
|
||||
|
||||
# 트레일링 스탑
|
||||
if (pos_peak - cur) / pos_peak >= trail:
|
||||
pnl = (cur - pos_buy) / pos_buy * 100
|
||||
trades.append(Trade(pnl, i - pos_i, "trail"))
|
||||
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
|
||||
continue
|
||||
|
||||
# 타임 스탑
|
||||
if i - pos_i >= TIME_STOP_H:
|
||||
pnl = (cur - pos_buy) / pos_buy * 100
|
||||
if pnl < TIME_STOP_MIN:
|
||||
trades.append(Trade(pnl, i - pos_i, "time"))
|
||||
pos_buy = pos_peak = pos_i = sig_px = sig_i = None
|
||||
continue
|
||||
continue
|
||||
|
||||
# ── 신호 유효성 ──────────────────────────────────────
|
||||
if sig_px is not None:
|
||||
if i - sig_i > SIGNAL_TO_H:
|
||||
sig_px = sig_i = None
|
||||
elif (closes[i] - sig_px) / sig_px * 100 < -SIGNAL_CANCEL:
|
||||
sig_px = sig_i = None
|
||||
|
||||
# ── 축적 감지 ─────────────────────────────────────────
|
||||
if sig_px is None:
|
||||
vol_avg = vols[i-6:i-1].mean()
|
||||
if vol_avg <= 0:
|
||||
continue
|
||||
if vols[i-1] / vol_avg >= VOL_MULT:
|
||||
if abs(closes[i] - closes[i-2]) / closes[i-2] * 100 < QUIET_2H:
|
||||
sig_px = closes[i]
|
||||
sig_i = i
|
||||
continue
|
||||
|
||||
# ── 모멘텀 진입 ───────────────────────────────────────
|
||||
if (closes[i] - sig_px) / sig_px * 100 >= MOMENTUM_THR:
|
||||
pos_buy = pos_peak = closes[i]
|
||||
pos_i = i
|
||||
sig_px = sig_i = None
|
||||
|
||||
return trades
|
||||
|
||||
|
||||
def stats(trades: list[Trade]) -> dict:
|
||||
if not trades:
|
||||
return dict(n=0, wins=0, wr=0, avg_pnl=0, avg_win=0, avg_loss=0,
|
||||
rr=0, total_pnl=0, avg_h=0, max_dd=0, trail_pct=0, time_pct=0)
|
||||
wins = [t for t in trades if t.pnl > 0]
|
||||
losses = [t for t in trades if t.pnl <= 0]
|
||||
avg_win = sum(t.pnl for t in wins) / len(wins) if wins else 0
|
||||
avg_loss = sum(t.pnl for t in losses) / len(losses) if losses else 0
|
||||
rr = abs(avg_win / avg_loss) if avg_loss else 0
|
||||
|
||||
# 누적 수익 흐름으로 Max Drawdown 계산
|
||||
cum = 0.0; peak_cum = 0.0; max_dd = 0.0
|
||||
for t in trades:
|
||||
cum += t.pnl
|
||||
if cum > peak_cum:
|
||||
peak_cum = cum
|
||||
dd = peak_cum - cum
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
trail_n = sum(1 for t in trades if t.exit == "trail")
|
||||
time_n = sum(1 for t in trades if t.exit == "time")
|
||||
|
||||
return dict(
|
||||
n = len(trades),
|
||||
wins = len(wins),
|
||||
wr = len(wins) / len(trades) * 100,
|
||||
avg_pnl = sum(t.pnl for t in trades) / len(trades),
|
||||
avg_win = avg_win,
|
||||
avg_loss = avg_loss,
|
||||
rr = rr,
|
||||
total_pnl = sum(t.pnl for t in trades),
|
||||
avg_h = sum(t.h for t in trades) / len(trades),
|
||||
max_dd = max_dd,
|
||||
trail_pct = trail_n / len(trades) * 100,
|
||||
time_pct = time_n / len(trades) * 100,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"데이터 수집 중 ({len(TICKERS)}개 종목, 60일 1h 캔들)...")
|
||||
datasets: dict = {}
|
||||
for i, ticker in enumerate(TICKERS):
|
||||
try:
|
||||
df = pyupbit.get_ohlcv(ticker, interval="minute60", count=1440)
|
||||
if df is not None and len(df) > 50:
|
||||
datasets[ticker] = df
|
||||
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {ticker} ")
|
||||
time.sleep(0.08)
|
||||
except Exception:
|
||||
pass
|
||||
sys.stderr.write("\n")
|
||||
print(f" 완료: {len(datasets)}개 종목\n")
|
||||
|
||||
# ── 스탑 크기별 전체 집계 ─────────────────────────────────
|
||||
print("=" * 80)
|
||||
print(" 트레일링 스탑 크기별 성과 비교 (60일 / 모멘텀 진입 기준)")
|
||||
print("=" * 80)
|
||||
print(f" {'스탑%':>5} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
|
||||
f"{'손익비':>6} {'총PnL':>8} {'MaxDD':>7} "
|
||||
f"{'평균보유':>7} {'Trail%':>7} {'Time%':>6}")
|
||||
print(" " + "-" * 77)
|
||||
|
||||
best_total = -9999
|
||||
best_stop = None
|
||||
all_stats: dict[float, dict] = {}
|
||||
|
||||
for stop in STOP_SIZES:
|
||||
all_trades: list[Trade] = []
|
||||
for df in datasets.values():
|
||||
all_trades.extend(simulate(df, stop))
|
||||
s = stats(all_trades)
|
||||
all_stats[stop] = s
|
||||
|
||||
marker = ""
|
||||
if stop == 2.0:
|
||||
marker = " ◀ 현행"
|
||||
print(
|
||||
f" {stop:>4.1f}% {s['n']:>5}건 {s['wr']:>5.1f}% "
|
||||
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>5.2f} "
|
||||
f"{s['total_pnl']:>+7.1f}% -{s['max_dd']:>5.1f}% "
|
||||
f"{s['avg_h']:>6.1f}h {s['trail_pct']:>6.1f}% "
|
||||
f"{s['time_pct']:>5.1f}%{marker}"
|
||||
)
|
||||
if s["total_pnl"] > best_total and s["n"] >= 30:
|
||||
best_total = s["total_pnl"]
|
||||
best_stop = stop
|
||||
|
||||
# ── 손익 분포 시각화 ─────────────────────────────────────
|
||||
print()
|
||||
print(" 총 손익 트렌드 (스탑 크기별):")
|
||||
for stop in STOP_SIZES:
|
||||
s = all_stats[stop]
|
||||
if s["n"] == 0:
|
||||
continue
|
||||
bar_val = s["total_pnl"]
|
||||
bar_len = int(abs(bar_val) / 5)
|
||||
bar = ("▓" * min(bar_len, 30)) if bar_val > 0 else ("░" * min(bar_len, 30))
|
||||
sign = "+" if bar_val > 0 else "-"
|
||||
marker = " ◀ 현행" if stop == 2.0 else ""
|
||||
print(f" {stop:>4.1f}% {sign}{bar} ({bar_val:+.1f}%){marker}")
|
||||
|
||||
# ── 이익/손실 평균 비교 ─────────────────────────────────
|
||||
print()
|
||||
print(" 이익 vs 손실 평균 비교:")
|
||||
print(f" {'스탑%':>5} {'평균이익':>8} {'평균손실':>9} {'손익비':>6} {'설명'}")
|
||||
print(" " + "-" * 55)
|
||||
for stop in STOP_SIZES:
|
||||
s = all_stats[stop]
|
||||
if s["n"] == 0:
|
||||
continue
|
||||
marker = " ◀ 현행" if stop == 2.0 else ""
|
||||
# 손익비 해석
|
||||
if s["rr"] >= 1.5: interp = "양호"
|
||||
elif s["rr"] >= 1.0: interp = "보통"
|
||||
elif s["rr"] >= 0.7: interp = "불량"
|
||||
else: interp = "매우불량"
|
||||
print(
|
||||
f" {stop:>4.1f}% {s['avg_win']:>+7.3f}% {s['avg_loss']:>+8.3f}% "
|
||||
f"{s['rr']:>5.2f} {interp}{marker}"
|
||||
)
|
||||
|
||||
if best_stop:
|
||||
s = all_stats[best_stop]
|
||||
print(f"\n ★ 최적 스탑: {best_stop:.1f}% "
|
||||
f"(거래 {s['n']}건 | 승률 {s['wr']:.1f}% | "
|
||||
f"총손익 {s['total_pnl']:+.1f}% | 손익비 {s['rr']:.2f})")
|
||||
|
||||
# ── 핵심 분석: 2% 스탑이 노이즈에 걸리는 비율 ───────────
|
||||
print()
|
||||
print(" 진단: 스탑 발동 후 실제 추가 하락 vs 반등 분석")
|
||||
print(" (트레일링 스탑이 걸린 시점 기준 이후 4h 추적)")
|
||||
|
||||
for stop_target in [2.0, 3.0, 4.0]:
|
||||
if stop_target not in all_stats:
|
||||
continue
|
||||
trades_checked = 0
|
||||
recoveries = 0
|
||||
for df in datasets.values():
|
||||
tlist = simulate(df, stop_target)
|
||||
closes = df["close"].values
|
||||
# 스탑 발동 건 중 이후 4h 안에 스탑가격 이상으로 회복한 비율
|
||||
# (여기서는 근사: 스탑 발동 후 trail stop 기준점인 peak * (1 - stop) 대비)
|
||||
# 간단히: trail 발동 후 다음 4개 캔들이 매수가 대비 양수인지 확인
|
||||
# simulate의 trade에는 exit price가 없으므로, 전체 흐름에서 재확인 필요
|
||||
# 여기서는 avg_pnl과 avg_win으로 대신 설명
|
||||
s = all_stats[stop_target]
|
||||
# 스탑 발동 건 중 손실 비율
|
||||
trail_loss_approx = s["n"] * (s["trail_pct"]/100) * (1 - s["wr"]/100)
|
||||
print(f" 스탑 {stop_target:.1f}%: trail 발동 {s['trail_pct']:.0f}% | "
|
||||
f"time 발동 {s['time_pct']:.0f}% | "
|
||||
f"avg손실 {s['avg_loss']:+.3f}% | avg이익 {s['avg_win']:+.3f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
119
tests/ticker_sim.py
Normal file
119
tests/ticker_sim.py
Normal 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
208
tests/tp5_backtest.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""5% 익절 전략 백테스트 (손절 없음)
|
||||
|
||||
규칙:
|
||||
- 진입: 직전 포지션 청산 다음 캔들 종가 매수
|
||||
- 청산: 고가 기준 +5% 달성 시 TP 가격으로 매도
|
||||
- 손절: 없음 — 손실 중 무조건 보유
|
||||
- 자본: 1,000,000 KRW (복리 없음 / 고정)
|
||||
- 캔들: 1h
|
||||
|
||||
Output:
|
||||
- 종목별: 거래 수, TP 성공, 승률, 총 손익, 평균 보유시간, 최대 보유시간
|
||||
- 현재 미결 포지션 현황
|
||||
- 전략 한계 분석
|
||||
"""
|
||||
|
||||
import os as _os, sys as _sys
|
||||
_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pyupbit
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
# ─── 파라미터 ──────────────────────────────────────────────────
|
||||
TICKERS = [
|
||||
"KRW-WET", "KRW-FLOW", "KRW-BIO",
|
||||
"KRW-CYBER", "KRW-JTO", "KRW-AERO",
|
||||
"KRW-LAYER", "KRW-DKA",
|
||||
]
|
||||
TP_PCT = 0.05 # 익절 목표 5%
|
||||
CAPITAL = 1_000_000 # 거래당 고정 자본 (복리 없음)
|
||||
INTERVAL = "minute60"
|
||||
COUNT = 1440 # 60일치 1h 캔들
|
||||
|
||||
|
||||
# ─── 데이터 클래스 ──────────────────────────────────────────────
|
||||
@dataclass
|
||||
class Trade:
|
||||
entry_time: datetime
|
||||
exit_time: datetime
|
||||
entry_px: float
|
||||
exit_px: float
|
||||
pnl: float
|
||||
holding_h: int
|
||||
status: str # TP | HOLDING_LOSS | HOLDING_PROFIT
|
||||
|
||||
|
||||
# ─── 백테스트 ──────────────────────────────────────────────────
|
||||
def backtest_ticker(ticker: str) -> Optional[dict]:
|
||||
df = pyupbit.get_ohlcv(ticker, interval=INTERVAL, count=COUNT)
|
||||
if df is None or len(df) < 20:
|
||||
return None
|
||||
|
||||
trades: list[Trade] = []
|
||||
i = 0
|
||||
|
||||
while i < len(df) - 1:
|
||||
# 매수: 현재 캔들 종가
|
||||
entry_px = float(df["close"].iloc[i])
|
||||
entry_time = df.index[i]
|
||||
tp_px = entry_px * (1 + TP_PCT)
|
||||
|
||||
hit = False
|
||||
for j in range(i + 1, len(df)):
|
||||
if float(df["high"].iloc[j]) >= tp_px:
|
||||
trades.append(Trade(
|
||||
entry_time = entry_time,
|
||||
exit_time = df.index[j],
|
||||
entry_px = entry_px,
|
||||
exit_px = tp_px,
|
||||
pnl = CAPITAL * TP_PCT,
|
||||
holding_h = j - i,
|
||||
status = "TP",
|
||||
))
|
||||
i = j # 다음 진입은 j 캔들 종가
|
||||
hit = True
|
||||
break
|
||||
|
||||
if not hit:
|
||||
# 데이터 끝까지 TP 미달성
|
||||
last_px = float(df["close"].iloc[-1])
|
||||
pnl = CAPITAL * (last_px - entry_px) / entry_px
|
||||
status = "HOLDING_PROFIT" if pnl >= 0 else "HOLDING_LOSS"
|
||||
trades.append(Trade(
|
||||
entry_time = entry_time,
|
||||
exit_time = df.index[-1],
|
||||
entry_px = entry_px,
|
||||
exit_px = last_px,
|
||||
pnl = pnl,
|
||||
holding_h = len(df) - i - 1,
|
||||
status = status,
|
||||
))
|
||||
break
|
||||
|
||||
closed = [t for t in trades if t.status == "TP"]
|
||||
holding = [t for t in trades if t.status != "TP"]
|
||||
|
||||
total_pnl = sum(t.pnl for t in trades)
|
||||
realized_pnl = sum(t.pnl for t in closed)
|
||||
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"n_total": len(trades),
|
||||
"n_tp": len(closed),
|
||||
"win_rate": len(closed) / len(trades) * 100 if trades else 0,
|
||||
"realized_pnl": realized_pnl,
|
||||
"total_pnl": total_pnl,
|
||||
"avg_h": sum(t.holding_h for t in closed) / len(closed) if closed else 0,
|
||||
"max_h": max(t.holding_h for t in trades) if trades else 0,
|
||||
"holding": holding,
|
||||
"trades": trades,
|
||||
}
|
||||
|
||||
|
||||
# ─── 메인 ──────────────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
print("=" * 62)
|
||||
print(" 5% 익절 전략 백테스트 (손절 없음)")
|
||||
print(f" 기간: 최근 {COUNT}h ({COUNT//24}일) | 자본: {CAPITAL:,}원 | TP: {TP_PCT*100:.0f}%")
|
||||
print("=" * 62)
|
||||
|
||||
all_results = []
|
||||
for ticker in TICKERS:
|
||||
r = backtest_ticker(ticker)
|
||||
time.sleep(0.1)
|
||||
if r:
|
||||
all_results.append(r)
|
||||
|
||||
# ─── 종목별 출력 ───────────────────────────────────────────
|
||||
print(f"\n{'종목':<12} {'거래':<5} {'TP':>5} {'승률':>7} {'실현손익':>12} "
|
||||
f"{'미결손익':>12} {'평균보유':>8} {'최장보유':>8}")
|
||||
print("-" * 80)
|
||||
|
||||
total_realized = 0
|
||||
total_open_pnl = 0
|
||||
|
||||
for r in all_results:
|
||||
h_pnl = sum(t.pnl for t in r["holding"])
|
||||
hold_str = ""
|
||||
if r["holding"]:
|
||||
h = r["holding"][0]
|
||||
mark = "⚠️ " if h.status == "HOLDING_LOSS" else "📈"
|
||||
hold_str = f" {mark}{h.pnl:+,.0f}원 ({h.holding_h}h 보유중)"
|
||||
|
||||
print(f"{r['ticker']:<12} {r['n_total']:<5} {r['n_tp']:>5} "
|
||||
f"{r['win_rate']:>6.1f}% {r['realized_pnl']:>+12,.0f} "
|
||||
f"{h_pnl:>+12,.0f} {r['avg_h']:>7.1f}h {r['max_h']:>7}h"
|
||||
+ hold_str)
|
||||
|
||||
total_realized += r["realized_pnl"]
|
||||
total_open_pnl += h_pnl
|
||||
|
||||
print("-" * 80)
|
||||
print(f"{'합계':<12} {'':5} {'':>5} {'':>7} {total_realized:>+12,.0f} "
|
||||
f"{total_open_pnl:>+12,.0f}")
|
||||
|
||||
# ─── 미결 포지션 상세 ──────────────────────────────────────
|
||||
open_trades = [(r["ticker"], h) for r in all_results for h in r["holding"]]
|
||||
if open_trades:
|
||||
print("\n▶ 현재 미결 포지션")
|
||||
print(f" {'종목':<12} {'진입시각':<20} {'진입가':>12} {'현재가':>12} {'수익률':>8} {'보유시간':>8}")
|
||||
for ticker, h in open_trades:
|
||||
chg = (h.exit_px - h.entry_px) / h.entry_px * 100
|
||||
print(f" {ticker:<12} {str(h.entry_time)[:16]:<20} "
|
||||
f"{h.entry_px:>12,.2f} {h.exit_px:>12,.2f} "
|
||||
f"{chg:>+7.2f}% {h.holding_h:>7}h")
|
||||
|
||||
# ─── 최장 잠김 분석 ────────────────────────────────────────
|
||||
all_trades_flat = [(r["ticker"], t) for r in all_results for t in r["trades"]]
|
||||
if all_trades_flat:
|
||||
worst = max(all_trades_flat, key=lambda x: x[1].holding_h)
|
||||
print(f"\n▶ 최장 자본 잠김")
|
||||
print(f" {worst[0]}: {worst[1].holding_h}h ({worst[1].holding_h/24:.1f}일) "
|
||||
f"진입 {str(worst[1].entry_time)[:16]} → "
|
||||
f"{'TP 달성' if worst[1].status == 'TP' else '미결'}")
|
||||
|
||||
# ─── 전략 평가 ─────────────────────────────────────────────
|
||||
if all_results:
|
||||
total_trades = sum(r["n_total"] for r in all_results)
|
||||
total_tp = sum(r["n_tp"] for r in all_results)
|
||||
avg_hold_all = [r["avg_h"] for r in all_results if r["avg_h"] > 0]
|
||||
still_holding = sum(1 for r in all_results if r["holding"])
|
||||
|
||||
print("\n▶ 종합 평가")
|
||||
print(f" 전체 거래: {total_trades}건 | TP 성공: {total_tp}건 | "
|
||||
f"승률: {total_tp/total_trades*100:.1f}%")
|
||||
if avg_hold_all:
|
||||
print(f" TP까지 평균 보유: {sum(avg_hold_all)/len(avg_hold_all):.1f}h "
|
||||
f"({sum(avg_hold_all)/len(avg_hold_all)/24:.1f}일)")
|
||||
print(f" 현재 미결 포지션: {still_holding}개 종목")
|
||||
print(f" 총 실현 손익: {total_realized:+,.0f}원")
|
||||
print(f" 총 평가 손익: {total_realized + total_open_pnl:+,.0f}원")
|
||||
|
||||
print("\n▶ 전략 한계")
|
||||
if still_holding > 0:
|
||||
print(f" ⚠️ {still_holding}개 종목이 TP 미달성 상태로 자본이 잠겨있습니다.")
|
||||
print(" ⚠️ 손절 없음 → 하락장에서 자본이 무한정 묶일 수 있습니다.")
|
||||
print(" ⚠️ 기회비용: 잠긴 자본으로 다른 거래 불가.")
|
||||
print(" 💡 개선안: 최대 보유기간(시간) 제한 또는 % 손절 추가 권장.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
tests/trend_check.py
Normal file
45
tests/trend_check.py
Normal 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
461
tests/velocity_backtest.py
Normal 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
399
tests/vol_lead_sim.py
Normal 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
143
tests/wf_cmp.py
Normal 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
214
tests/wf_cmp2.py
Normal 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()
|
||||
Reference in New Issue
Block a user