From 324d69dde02e734f49612de0ecdf80667f60ff36 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 2 Mar 2026 01:46:03 +0900 Subject: [PATCH] feat: volume-lead strategy with compounding, WF filter, and DB-backed simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + STRATEGY.md | 158 ++++++++++++++++++++++++++++++++ core/notify.py | 10 +++ core/strategy.py | 12 +++ core/trader.py | 9 +- krw_sim.py | 177 ++++++++++++++++++++++++++++++++++++ ohlcv_db.py | 213 +++++++++++++++++++++++++++++++++++++++++++ sim_365.py | 229 +++++++++++++++++++++++++++++++++++++++++++++++ ticker_sim.py | 119 ++++++++++++++++++++++++ 9 files changed, 924 insertions(+), 4 deletions(-) create mode 100644 STRATEGY.md create mode 100644 krw_sim.py create mode 100644 ohlcv_db.py create mode 100644 sim_365.py create mode 100644 ticker_sim.py diff --git a/.gitignore b/.gitignore index 32196d0..b7d43d9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ *.pyc .venv/ *.log +*.pkl diff --git a/STRATEGY.md b/STRATEGY.md new file mode 100644 index 0000000..d79500c --- /dev/null +++ b/STRATEGY.md @@ -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 +``` diff --git a/core/notify.py b/core/notify.py index bef2068..0209040 100644 --- a/core/notify.py +++ b/core/notify.py @@ -62,6 +62,16 @@ def notify_sell( ) +def notify_signal(ticker: str, signal_price: float, vol_mult: float) -> None: + """거래량 축적 신호 감지 알림.""" + _send( + f"🔍 [축적감지] {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"⚠️ [오류]\n{message}") diff --git a/core/strategy.py b/core/strategy.py index 50470af..e7a48fa 100644 --- a/core/strategy.py +++ b/core/strategy.py @@ -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 # 신호 첫 발생 시는 진입 안 함 # ── 신호 있음: 상승 확인 → 진입 ───────────────────────── diff --git a/core/trader.py b/core/trader.py index 21d919c..a51633e 100644 --- a/core/trader.py +++ b/core/trader.py @@ -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( diff --git a/krw_sim.py b/krw_sim.py new file mode 100644 index 0000000..b04bdd4 --- /dev/null +++ b/krw_sim.py @@ -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() diff --git a/ohlcv_db.py b/ohlcv_db.py new file mode 100644 index 0000000..464c957 --- /dev/null +++ b/ohlcv_db.py @@ -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}") diff --git a/sim_365.py b/sim_365.py new file mode 100644 index 0000000..71b94a2 --- /dev/null +++ b/sim_365.py @@ -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() diff --git a/ticker_sim.py b/ticker_sim.py new file mode 100644 index 0000000..624b9be --- /dev/null +++ b/ticker_sim.py @@ -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()