feat: volume-lead strategy with compounding, WF filter, and DB-backed simulation

- core/strategy.py: replace trend strategy with volume-lead accumulation
  (vol spike + 2h quiet → signal, +4.8% rise → entry)
- core/trader.py: compound budget adjusts on both profit and loss (floor 30%)
- core/notify.py: add accumulation signal telegram notification
- ohlcv_db.py: Oracle ADB OHLCV cache (insert, load, incremental update)
- sim_365.py: 365-day compounding simulation loading from DB
- krw_sim.py: KRW-based simulation with MAX_POSITIONS constraint
- ticker_sim.py: ticker count expansion comparison
- STRATEGY.md: full strategy documentation
- .gitignore: exclude *.pkl cache files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-02 01:46:03 +09:00
parent 7c7fb08693
commit 324d69dde0
9 changed files with 924 additions and 4 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ __pycache__/
*.pyc
.venv/
*.log
*.pkl

158
STRATEGY.md Normal file
View File

@@ -0,0 +1,158 @@
# Volume Lead 전략 가이드
## 전략 개요
**거래량 선행(Volume Lead) 매집 전략** — 가격이 횡보하는 중 거래량 급증이 발생하면
매집 신호로 기록하고, 이후 일정 수준 이상 상승 시 진입하는 선진입 전략.
> 핵심 아이디어: 대형 매수자는 가격을 올리지 않고 조용히 매집한다.
> 거래량이 먼저 급증하고, 가격 상승은 그 뒤에 따라온다.
---
## 진입 조건 (2단계)
### 1단계: 매집 신호 감지
다음 두 조건 동시 충족 시 `signal_price` 기록:
| 조건 | 파라미터 | 기본값 |
|------|----------|--------|
| 2h 가격 변동 < N% (횡보) | `PRICE_QUIET_PCT` | 2.0% |
| 직전 1h 거래량 ≥ 로컬 5h 평균 × M배 | `VOLUME_MULTIPLIER` | 2.0x |
- 신호 발생 시 텔레그램 알림 발송
- `SIGNAL_TIMEOUT_H` 내 진입 조건 미달 시 신호 초기화 (기본: 8h)
- 신호가 이하 하락 시 즉시 초기화 (매집 실패 판단)
### 2단계: 추세 확인 후 진입
`signal_price` 대비 +`TREND_AFTER_VOL`% 이상 상승 확인 시 매수:
| 파라미터 | 기본값 | 설명 |
|----------|--------|------|
| `TREND_AFTER_VOL` | 4.8% | 신호가 대비 진입 임계값 |
---
## 청산 조건
### 트레일링 스탑 (ATR 기반)
- ATR 5봉 × 1.5 계수 → 동적 손절폭 산출
- 최소 1.0% / 최대 4.0% 범위 내 자동 조정
- 최고가 대비 손절폭 이하 하락 시 즉시 청산
### 타임 스탑
- 보유 `TIME_STOP_HOURS`h 경과 후 수익률 < `TIME_STOP_MIN_GAIN_PCT`% 이면 청산
- 기본값: 8시간 경과 / 수익률 3% 미만
---
## 리스크 관리
### Walk-Forward (WF) 필터
| 파라미터 | 기본값 | 설명 |
|----------|--------|------|
| `WF_WINDOW` | 2 | 이력 윈도우 (직전 N건) |
| `WF_MIN_WIN_RATE` | 0.01 | 최소 승률 임계값 (1%) |
| `WF_SHADOW_WINS` | 2 | 차단 해제 조건 (가상 N연승) |
- 직전 2건 모두 손실 → 해당 종목 진입 차단
- 차단 후 가상 추적으로 2연승 달성 시 자동 복귀
### 예산 관리 (복리)
- 수익 발생 시: `운용예산 = 초기예산 + 누적수익` (복리 증가)
- 손실 발생 시: `운용예산 = 초기예산 + 누적수익` (차감)
- 하한선: 초기예산의 30% (기본: 4,500,000원)
- 포지션당 크기: `운용예산 / MAX_POSITIONS`
---
## 시장 레짐 적응
| 레짐 | BTC 1h 변동 | 거래량 기준 |
|------|------------|------------|
| BULL | +5% 이상 | 1.5x |
| NEUTRAL | ±5% 이내 | 2.0x |
| BEAR | -5% 이하 | 진입 차단 |
- BEAR 레짐 감지 시 신규 진입 전면 차단
- 레짐별 `vol_mult` 조정으로 민감도 제어
---
## 운용 설정 (.env)
```env
# 핵심 전략
PRICE_QUIET_PCT=2.0 # 2h 횡보 기준 (%)
TREND_AFTER_VOL=4.8 # 진입 임계값 (신호가 대비 %)
SIGNAL_TIMEOUT_H=8.0 # 신호 유효 시간 (h)
VOLUME_MULTIPLIER=2.0 # 거래량 배수 기준
# 청산
STOP_LOSS_PCT=1.5 # ATR 트레일링 기본값 (동적 조정됨)
TIME_STOP_HOURS=8 # 타임스탑 보유 시간
TIME_STOP_MIN_GAIN_PCT=3 # 타임스탑 최소 수익률
# 포트폴리오
MAX_BUDGET=15000000 # 초기 운용 예산
MAX_POSITIONS=3 # 최대 동시 보유 종목
# WF 필터
WF_WINDOW=2
WF_MIN_WIN_RATE=0.01
WF_SHADOW_WINS=2
```
---
## 백테스트 결과 요약
### 365일 (2025-03-02 ~ 2026-03-02) — WF 적용
| 항목 | 값 |
|------|-----|
| 초기 예산 | 15,000,000원 |
| 최종 자산 | 29,996,109원 |
| 수익률 | **+100%** |
| 최대 낙폭 | -3.81% (-57만원) |
| 거래수 | 190건 (WF 183건 차단) |
| 승률 | 46% |
| 월평균 수익 | 약 115만원 |
### 45일 Walk-Forward 검증 (2026-01-15 ~ 2026-03-02)
| 기간 | 거래수 | 승률 | 수익률 |
|------|--------|------|--------|
| Train (77일) | 66건 | 42% | +22.5% |
| Test (45일) | 67건 | 61% | +49.9% |
Train/Test 모두 수익 → 오버피팅 아님.
---
## 주요 파일
| 파일 | 역할 |
|------|------|
| `core/strategy.py` | 진입 신호 로직 |
| `core/monitor.py` | ATR 트레일링 스탑 + 타임스탑 |
| `core/trader.py` | 주문 실행 + 복리 예산 관리 |
| `core/market_regime.py` | 시장 레짐 감지 |
| `ohlcv_db.py` | OHLCV 시계열 DB 캐시 관리 |
| `sim_365.py` | 365일 복리 시뮬레이션 |
| `vol_lead_sim.py` | 전략 파라미터 스윕 도구 |
---
## 시뮬레이션 실행
```bash
# 365일 복리 시뮬 (DB에서 로드)
python sim_365.py
# OHLCV DB 상태 확인
python ohlcv_db.py status
# 신규 봉 증분 업데이트
python ohlcv_db.py update
```

View File

@@ -62,6 +62,16 @@ def notify_sell(
)
def notify_signal(ticker: str, signal_price: float, vol_mult: float) -> None:
"""거래량 축적 신호 감지 알림."""
_send(
f"🔍 <b>[축적감지]</b> {ticker}\n"
f"신호가: {signal_price:,.2f}\n"
f"거래량: {vol_mult:.1f}x 급증 + 2h 횡보\n"
f"진입 목표: {signal_price * 1.048:,.2f}원 (+4.8%)"
)
def notify_error(message: str) -> None:
_send(f"⚠️ <b>[오류]</b>\n{message}")

View File

@@ -18,6 +18,7 @@ import pyupbit
from .market import get_current_price
from .market_regime import get_regime
from .notify import notify_signal
from .price_db import get_price_n_hours_ago
logger = logging.getLogger(__name__)
@@ -112,6 +113,17 @@ def should_buy(ticker: str) -> bool:
logger.info(
f"[축적감지] {ticker} 거래량 급증 + 2h 횡보 → 신호가={current:,.2f}"
)
# 거래량 비율 계산 후 알림 전송
try:
fetch_count = LOCAL_VOL_HOURS + 3
df_h = pyupbit.get_ohlcv(ticker, interval="minute60", count=fetch_count)
if df_h is not None and len(df_h) >= LOCAL_VOL_HOURS + 1:
recent_vol = df_h["volume"].iloc[-2]
local_avg = df_h["volume"].iloc[-(LOCAL_VOL_HOURS + 1):-2].mean()
ratio = recent_vol / local_avg if local_avg > 0 else 0
notify_signal(ticker, current, ratio)
except Exception:
notify_signal(ticker, current, 0.0)
return False # 신호 첫 발생 시는 진입 안 함
# ── 신호 있음: 상승 확인 → 진입 ─────────────────────────

View File

@@ -33,21 +33,22 @@ if SIMULATION_MODE:
INITIAL_BUDGET = int(os.getenv("MAX_BUDGET", "10000000")) # 초기 원금 (고정)
MAX_POSITIONS = int(os.getenv("MAX_POSITIONS", "3")) # 최대 동시 보유 종목 수
# 복리 적용 예산 (매도 후 재계산) — 수익 발생 시만 증가, 손실 시 원금 유지
# 복리 적용 예산 (매도 후 재계산) — 수익 시 복리 증가, 손실 시 차감 (하한 30%)
MIN_BUDGET = INITIAL_BUDGET * 3 // 10 # 최소 예산: 초기값의 30%
MAX_BUDGET = INITIAL_BUDGET
PER_POSITION = INITIAL_BUDGET // MAX_POSITIONS
def _recalc_compound_budget() -> None:
"""누적 수익을 반영해 MAX_BUDGET / PER_POSITION 재계산.
"""누적 수익/손실을 반영해 MAX_BUDGET / PER_POSITION 재계산.
수익이 발생한 만큼만 예산에 더함 (손실 시 원금 아래로 내려가지 않음).
수익 시 복리로 증가, 손실 시 차감 (최소 초기 예산의 30% 보장).
매도 완료 후 호출.
"""
global MAX_BUDGET, PER_POSITION
try:
cum_profit = get_cumulative_krw_profit()
effective = INITIAL_BUDGET + max(int(cum_profit), 0)
effective = max(INITIAL_BUDGET + int(cum_profit), MIN_BUDGET)
MAX_BUDGET = effective
PER_POSITION = effective // MAX_POSITIONS
logger.info(

177
krw_sim.py Normal file
View File

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

213
ohlcv_db.py Normal file
View File

@@ -0,0 +1,213 @@
"""OHLCV 시계열 캐시 — Oracle ADB ohlcv_hourly 테이블.
기능:
- 테이블 생성 (없으면)
- pkl → DB 최초 적재
- DB → DataFrame dict 로드 (시뮬용)
- 증분 업데이트 (신규 봉만 API 페치)
"""
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}")

229
sim_365.py Normal file
View File

@@ -0,0 +1,229 @@
"""365일 복리 KRW 시뮬레이션.
- 상위 20개 종목 × vol-lead +4.8% 전략
- MAX_POSITIONS=3, 복리 포지션 크기 (이득 시 증가 / 손실 시 차감)
- 최소 예산 = 초기 예산의 30%
- 데이터: Oracle ADB ohlcv_hourly 테이블
"""
import pickle
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / ".env")
sys.path.insert(0, str(Path(__file__).parent))
from ohlcv_db import load_from_db
from vol_lead_sim import run_vol_lead_thresh
# ── 파라미터 ───────────────────────────────────────────
BUDGET = 15_000_000
MIN_BUDGET = BUDGET * 3 // 10 # 하한 30% = 4,500,000원
MAX_POS = 3
THRESH = 4.8
FROM_DATE = "2025-03-02"
TOP30_FILE = Path("top30_tickers.pkl")
def load_data() -> dict:
top30 = pickle.load(open(TOP30_FILE, "rb"))
print(f"DB 로드 중... ({len(top30)}종목)")
data = load_from_db(top30, from_date=FROM_DATE)
valid = {t: df for t, df in data.items() if len(df) >= 500}
print(f"유효 종목: {len(valid)}개 로드 완료")
return valid
pickle.dump(data, open(CACHE_FILE, "wb"))
print(f"\n캐시 저장: {CACHE_FILE} ({len(data)}종목)\n")
return data
# ── WF 필터 (종목별 적용) ──────────────────────────────
WF_WINDOW = 2
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()

119
ticker_sim.py Normal file
View File

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