From 6e0c4508fad57749b6214f8b091bf1ff396624d2 Mon Sep 17 00:00:00 2001 From: joungmin Date: Fri, 6 Mar 2026 20:46:47 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20MVC=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20archive=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리: - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal) - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회) - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건 - type hints, Google docstring, 구체적 예외 타입 적용 - 50줄 초과 함수 분리 (process_signal, restore_positions) - 미사용 파일 58개 archive/ 폴더로 이동 - README.md 추가 Co-Authored-By: Claude Opus 4.6 --- README.md | 100 +++ {core => archive/core}/fng.py | 0 {core => archive/core}/market.py | 0 {core => archive/core}/market_regime.py | 0 {core => archive/core}/monitor.py | 0 {core => archive/core}/price_collector.py | 0 {core => archive/core}/price_db.py | 0 {core => archive/core}/strategy.py | 0 {core => archive/core}/trader.py | 0 {daemon => archive/daemon}/__init__.py | 0 {daemon => archive/daemon}/runner.py | 0 .../daemons}/fetch_1min_history.py | 0 {daemons => archive/daemons}/live_trader.py | 0 main.py => archive/main.py | 0 {tests => archive/tests}/atr_sweep.py | 0 {tests => archive/tests}/backtest.py | 0 {tests => archive/tests}/backtest_db.py | 0 archive/tests/check_recent_signals.py | 360 ++++++++++ {tests => archive/tests}/collect_1y_data.py | 0 archive/tests/compare_tp_vs_trail.py | 323 +++++++++ archive/tests/fetch_1y_minute1.py | 131 ++++ {tests => archive/tests}/fng_1y_backtest.py | 0 .../tests}/fng_adaptive_backtest.py | 0 {tests => archive/tests}/fng_backtest.py | 0 .../tests}/fng_sim_comparison.py | 0 {tests => archive/tests}/interval_sweep.py | 0 {tests => archive/tests}/krw_sim.py | 0 {tests => archive/tests}/momentum_cmp.py | 0 {tests => archive/tests}/ohlcv_db.py | 0 {tests => archive/tests}/pullback_backtest.py | 0 {tests => archive/tests}/refresh_cache.py | 0 {tests => archive/tests}/shadow_sim.py | 0 {tests => archive/tests}/shadow_sim2.py | 0 {tests => archive/tests}/shadow_sim3.py | 0 {tests => archive/tests}/sim10m.py | 0 {tests => archive/tests}/sim_10m_vol.py | 0 {tests => archive/tests}/sim_365.py | 0 archive/tests/sim_3bar.py | 314 +++++++++ {tests => archive/tests}/sim_45m40.py | 0 archive/tests/sim_cascade.py | 281 ++++++++ {tests => archive/tests}/sim_current.py | 0 archive/tests/sim_limit_exit.py | 265 +++++++ archive/tests/sim_peak_exit.py | 253 +++++++ archive/tests/sim_recent_db.py | 363 ++++++++++ {tests => archive/tests}/sim_regime_1y.py | 0 {tests => archive/tests}/sim_regime_sweep.py | 0 archive/tests/sim_tp_sl.py | 244 +++++++ {tests => archive/tests}/sim_vol_override.py | 0 {tests => archive/tests}/stop_sweep.py | 0 archive/tests/sweep_1min.py | 310 ++++++++ archive/tests/sweep_nbar.py | 357 ++++++++++ archive/tests/sweep_volaccel.py | 355 ++++++++++ archive/tests/test_llm_advisor.py | 187 +++++ archive/tests/test_tick_trader.py | 216 ++++++ {tests => archive/tests}/ticker_sim.py | 0 {tests => archive/tests}/tp5_backtest.py | 0 {tests => archive/tests}/trend_check.py | 0 {tests => archive/tests}/velocity_backtest.py | 0 {tests => archive/tests}/vol_lead_sim.py | 0 {tests => archive/tests}/wf_cmp.py | 0 {tests => archive/tests}/wf_cmp2.py | 0 core/llm_advisor.py | 17 +- core/order.py | 178 +++++ core/position_manager.py | 264 +++++++ core/signal.py | 139 ++++ daemons/state_sync.py | 184 +++++ daemons/tick_trader.py | 659 +++++------------- ecosystem.config.js | 11 + pyproject.toml | 2 + 69 files changed, 5018 insertions(+), 495 deletions(-) create mode 100644 README.md rename {core => archive/core}/fng.py (100%) rename {core => archive/core}/market.py (100%) rename {core => archive/core}/market_regime.py (100%) rename {core => archive/core}/monitor.py (100%) rename {core => archive/core}/price_collector.py (100%) rename {core => archive/core}/price_db.py (100%) rename {core => archive/core}/strategy.py (100%) rename {core => archive/core}/trader.py (100%) rename {daemon => archive/daemon}/__init__.py (100%) rename {daemon => archive/daemon}/runner.py (100%) rename {daemons => archive/daemons}/fetch_1min_history.py (100%) rename {daemons => archive/daemons}/live_trader.py (100%) rename main.py => archive/main.py (100%) rename {tests => archive/tests}/atr_sweep.py (100%) rename {tests => archive/tests}/backtest.py (100%) rename {tests => archive/tests}/backtest_db.py (100%) create mode 100644 archive/tests/check_recent_signals.py rename {tests => archive/tests}/collect_1y_data.py (100%) create mode 100644 archive/tests/compare_tp_vs_trail.py create mode 100644 archive/tests/fetch_1y_minute1.py rename {tests => archive/tests}/fng_1y_backtest.py (100%) rename {tests => archive/tests}/fng_adaptive_backtest.py (100%) rename {tests => archive/tests}/fng_backtest.py (100%) rename {tests => archive/tests}/fng_sim_comparison.py (100%) rename {tests => archive/tests}/interval_sweep.py (100%) rename {tests => archive/tests}/krw_sim.py (100%) rename {tests => archive/tests}/momentum_cmp.py (100%) rename {tests => archive/tests}/ohlcv_db.py (100%) rename {tests => archive/tests}/pullback_backtest.py (100%) rename {tests => archive/tests}/refresh_cache.py (100%) rename {tests => archive/tests}/shadow_sim.py (100%) rename {tests => archive/tests}/shadow_sim2.py (100%) rename {tests => archive/tests}/shadow_sim3.py (100%) rename {tests => archive/tests}/sim10m.py (100%) rename {tests => archive/tests}/sim_10m_vol.py (100%) rename {tests => archive/tests}/sim_365.py (100%) create mode 100644 archive/tests/sim_3bar.py rename {tests => archive/tests}/sim_45m40.py (100%) create mode 100644 archive/tests/sim_cascade.py rename {tests => archive/tests}/sim_current.py (100%) create mode 100644 archive/tests/sim_limit_exit.py create mode 100644 archive/tests/sim_peak_exit.py create mode 100644 archive/tests/sim_recent_db.py rename {tests => archive/tests}/sim_regime_1y.py (100%) rename {tests => archive/tests}/sim_regime_sweep.py (100%) create mode 100644 archive/tests/sim_tp_sl.py rename {tests => archive/tests}/sim_vol_override.py (100%) rename {tests => archive/tests}/stop_sweep.py (100%) create mode 100644 archive/tests/sweep_1min.py create mode 100644 archive/tests/sweep_nbar.py create mode 100644 archive/tests/sweep_volaccel.py create mode 100644 archive/tests/test_llm_advisor.py create mode 100644 archive/tests/test_tick_trader.py rename {tests => archive/tests}/ticker_sim.py (100%) rename {tests => archive/tests}/tp5_backtest.py (100%) rename {tests => archive/tests}/trend_check.py (100%) rename {tests => archive/tests}/velocity_backtest.py (100%) rename {tests => archive/tests}/vol_lead_sim.py (100%) rename {tests => archive/tests}/wf_cmp.py (100%) rename {tests => archive/tests}/wf_cmp2.py (100%) create mode 100644 core/order.py create mode 100644 core/position_manager.py create mode 100644 core/signal.py create mode 100644 daemons/state_sync.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..76a8ee5 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# upbit-trader + +Upbit WebSocket 기반 20초봉 자동매매 봇. LLM(Gemini 2.5 Flash) 매수 판단 + 트레일링 스탑 청산. + +## 프로젝트 구조 + +``` +upbit-trader/ + STRATEGY.md -- 전략 상세 문서 + ecosystem.config.js -- PM2 프로세스 설정 + backtest_march.py -- 3월 백테스트 시뮬레이션 + + core/ -- Model / Service 레이어 + signal.py -- 시그널 감지 (양봉 + VOL + 사전필터 3종) + order.py -- Upbit 주문 실행 (매수/매도/취소/조회) + position_manager.py -- 포지션 관리, 청산 조건, DB sync, 복구 + llm_advisor.py -- LLM 매수 어드바이저 (OpenRouter + tool calling) + notify.py -- 텔레그램 알림 + + daemons/ -- Controller / 데몬 + tick_trader.py -- 주력 트레이더 (WebSocket -> 봉 집계 -> 매매) + tick_collector.py -- price_tick + 1분봉 Oracle 수집 + context_collector.py -- 종목 컨텍스트 수집 (뉴스 + 가격 통계) + state_sync.py -- 포지션 상태 동기화 + + archive/ -- 미사용 파일 보관 +``` + +## 매매 전략 요약 + +### 진입 + +1. 20초봉 확정 시 시그널 감지 (양봉 + 거래량 5x + 거래대금 5M+) +2. 사전 필터: 횡보(15봉 변동 < 0.3%), 고점(30분 구간 90%+), 연속양봉(2봉+) +3. LLM(Gemini 2.5 Flash) 매수 판단 -> 현재가 지정가 매수 + +### 청산 + +| 조건 | 값 | +|------|------| +| 트레일링 스탑 | 고점 대비 -1.5% (최소 수익 +0.5%) | +| 손절 | -2% | +| 타임아웃 | 4시간 | + +### 운용 설정 + +| 항목 | 값 | +|------|------| +| 감시 종목 | 10개 (ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR) | +| 총 예산 | 1,000,000원 | +| 최대 포지션 | 5개 | +| 종목당 투자 | 200,000원 | + +## 기술 스택 + +| 구성 | 기술 | +|------|------| +| 거래소 | Upbit API (REST + WebSocket) | +| DB | Oracle ADB | +| LLM | Gemini 2.5 Flash via OpenRouter | +| 알림 | Telegram Bot API | +| 뉴스 | SearXNG (self-hosted) | +| 프로세스 | PM2 | + +## 실행 + +```bash +# 환경 설정 +cp .env.example .env +# .env에 API 키 입력 + +# PM2로 실행 +pm2 start ecosystem.config.js + +# 개별 실행 +.venv/bin/python3 daemons/tick_trader.py +``` + +## PM2 데몬 + +| 이름 | 파일 | 설명 | +|------|------|------| +| tick-trader | `daemons/tick_trader.py` | 주력 트레이더 | +| tick-collector | `daemons/tick_collector.py` | 가격 데이터 수집 | +| context-collector | `daemons/context_collector.py` | 종목 컨텍스트 수집 | +| state-sync | `daemons/state_sync.py` | 포지션 상태 동기화 | + +## 백테스트 결과 (2026-03-01~06) + +| 항목 | 값 | +|------|------| +| 거래 수 | 48건 | +| 승률 | 52.1% | +| 총 PNL | +17,868원 (+17.9%) | +| 평균 PNL | +1.22% | + +## 주의사항 + +- `.env` 변경 후 PM2 재시작 시 `pm2 restart --update-env` 필수 +- 로그: `/tmp/tick_trader.log` diff --git a/core/fng.py b/archive/core/fng.py similarity index 100% rename from core/fng.py rename to archive/core/fng.py diff --git a/core/market.py b/archive/core/market.py similarity index 100% rename from core/market.py rename to archive/core/market.py diff --git a/core/market_regime.py b/archive/core/market_regime.py similarity index 100% rename from core/market_regime.py rename to archive/core/market_regime.py diff --git a/core/monitor.py b/archive/core/monitor.py similarity index 100% rename from core/monitor.py rename to archive/core/monitor.py diff --git a/core/price_collector.py b/archive/core/price_collector.py similarity index 100% rename from core/price_collector.py rename to archive/core/price_collector.py diff --git a/core/price_db.py b/archive/core/price_db.py similarity index 100% rename from core/price_db.py rename to archive/core/price_db.py diff --git a/core/strategy.py b/archive/core/strategy.py similarity index 100% rename from core/strategy.py rename to archive/core/strategy.py diff --git a/core/trader.py b/archive/core/trader.py similarity index 100% rename from core/trader.py rename to archive/core/trader.py diff --git a/daemon/__init__.py b/archive/daemon/__init__.py similarity index 100% rename from daemon/__init__.py rename to archive/daemon/__init__.py diff --git a/daemon/runner.py b/archive/daemon/runner.py similarity index 100% rename from daemon/runner.py rename to archive/daemon/runner.py diff --git a/daemons/fetch_1min_history.py b/archive/daemons/fetch_1min_history.py similarity index 100% rename from daemons/fetch_1min_history.py rename to archive/daemons/fetch_1min_history.py diff --git a/daemons/live_trader.py b/archive/daemons/live_trader.py similarity index 100% rename from daemons/live_trader.py rename to archive/daemons/live_trader.py diff --git a/main.py b/archive/main.py similarity index 100% rename from main.py rename to archive/main.py diff --git a/tests/atr_sweep.py b/archive/tests/atr_sweep.py similarity index 100% rename from tests/atr_sweep.py rename to archive/tests/atr_sweep.py diff --git a/tests/backtest.py b/archive/tests/backtest.py similarity index 100% rename from tests/backtest.py rename to archive/tests/backtest.py diff --git a/tests/backtest_db.py b/archive/tests/backtest_db.py similarity index 100% rename from tests/backtest_db.py rename to archive/tests/backtest_db.py diff --git a/archive/tests/check_recent_signals.py b/archive/tests/check_recent_signals.py new file mode 100644 index 0000000..99a0502 --- /dev/null +++ b/archive/tests/check_recent_signals.py @@ -0,0 +1,360 @@ +"""최근 N일 볼륨 가속 시그널 확인 + ATR trail stop 시뮬레이션. + +4봉 연속 가격+볼륨 가속 시그널 발생 후 실제 trail stop 로직을 돌려 +진입가·청산가·PNL을 표시. + +최적 파라미터 (sweep_volaccel 기준): + N봉=4, VOL≥5.0x, ATR_MULT=1.5, ATR_MIN=1.5%, ATR_MAX=2.0% +""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import oracledb + +# ── 파라미터 ────────────────────────────────────────────────────────────────── +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +LOOKBACK_DAYS = 3 # 오늘 기준 며칠 전까지 시그널 탐색 +VOL_MIN_LIST = [8.0] +VOL_MIN = min(VOL_MIN_LIST) + +# ATR 비교 목록: (MULT, MIN%, MAX%) — 타이트→여유 순 +ATR_SCENARIOS = [ + (1.0, 0.010, 0.020), # 타이트: 1.0×ATR, 1.0~2.0% + (1.5, 0.015, 0.025), # 기존최적: 1.5×ATR, 1.5~2.5% + (1.0, 0.020, 0.030), # 고정 2.0~3.0% + (1.0, 0.030, 0.050), # 느슨: 3.0~5.0% +] +ATR_MULT = ATR_SCENARIOS[0][0] +ATR_MIN_R = ATR_SCENARIOS[0][1] +ATR_MAX_R = ATR_SCENARIOS[0][2] +MAX_TRAIL_BARS = 240 + +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + +# ── 시그널 SQL (4봉 가속 조건 전부 Oracle SQL에서 처리) ─────────────────────── +SIGNAL_SQL = f""" +WITH +base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1, + LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1, + LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1, + LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2, + LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2, + LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2, + LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3, + LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3, + LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3, + GREATEST( + high_p - low_p, + ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)) + ) tr + FROM backtest_ohlcv + WHERE interval_cd = 'minute1' + AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +indicators AS ( + SELECT ticker, ts, open_p, close_p, + volume_p / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr0, + prev_vol_1 / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr1, + prev_vol_2 / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr2, + prev_vol_3 / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr3, + prev_close_1, prev_open_1, + prev_close_2, prev_open_2, + prev_close_3, prev_open_3, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) + / NULLIF(prev_close_1, 0) atr_raw + FROM base +) +SELECT ticker, ts sig_ts, close_p sig_price, + vr0, vr1, vr2, vr3, atr_raw, + prev_close_3, prev_close_2, prev_close_1, close_p +FROM indicators +WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= :min_vol + -- 4봉 연속: 양봉 + AND close_p > open_p + AND prev_close_1 > prev_open_1 + AND prev_close_2 > prev_open_2 + AND prev_close_3 > prev_open_3 + -- 4봉 연속: 가격 가속 + AND close_p > prev_close_1 + AND prev_close_1 > prev_close_2 + AND prev_close_2 > prev_close_3 + -- 4봉 연속: 볼륨 가속 + AND vr0 > vr1 + AND vr1 > vr2 + AND vr2 > vr3 +ORDER BY ticker, ts +""" + + +def _get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +def simulate_trail(cur, ticker: str, entry_ts, entry_price: float, + atr_raw: float, + mult: float = None, min_r: float = None, max_r: float = None) -> dict: + """entry_ts 봉부터 trail stop 시뮬. 상세 결과 dict 반환.""" + m = mult if mult is not None else ATR_MULT + n = min_r if min_r is not None else ATR_MIN_R + x = max_r if max_r is not None else ATR_MAX_R + ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0 + atr_stop = max(n, min(x, ar * m)) if ar > 0 else x + + cur.execute( + """SELECT ts, close_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry + ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1} + ) + bars = cur.fetchall() + if not bars: + return dict(status="데이터없음", exit_ts=entry_ts, exit_price=entry_price, + peak=entry_price, peak_pct=0.0, pnl=0.0, atr_stop=atr_stop, + held_bars=0) + + running_peak = entry_price + for i, (ts, close_p) in enumerate(bars): + close_p = float(close_p) + running_peak = max(running_peak, close_p) + drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0 + if drop >= atr_stop: + pnl = (close_p - entry_price) / entry_price * 100 + peak_pct = (running_peak - entry_price) / entry_price * 100 + return dict(status="손절", exit_ts=ts, exit_price=close_p, + peak=running_peak, peak_pct=peak_pct, + pnl=pnl, atr_stop=atr_stop, held_bars=i + 1) + + last_ts, last_price = bars[-1][0], float(bars[-1][1]) + pnl = (last_price - entry_price) / entry_price * 100 + peak_pct = (running_peak - entry_price) / entry_price * 100 + status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중" + return dict(status=status, exit_ts=last_ts, exit_price=last_price, + peak=running_peak, peak_pct=peak_pct, + pnl=pnl, atr_stop=atr_stop, held_bars=len(bars)) + + +def apply_pos_limit(sim_results: list, vol_thr: float) -> tuple: + """VOL 필터 + MAX_POS 동시 포지션 제한 적용. (taken, skipped) 반환.""" + filtered = [r for r in sim_results if r['vr'][3] >= vol_thr] + open_positions = [] + taken, skipped = [], [] + for r in filtered: + open_positions = [ex for ex in open_positions if ex > r['entry_ts']] + if len(open_positions) < MAX_POS: + open_positions.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def print_detail(vol_thr: float, taken: list, skipped: list): + div = "─" * 120 + + print(f"\n{'━'*120}") + print(f" 4봉 VOL≥{vol_thr:.0f}x | ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%] " + f"| 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개") + print(f"{'━'*120}\n") + + total_krw, total_wins = 0.0, 0 + + for i, r in enumerate(taken, 1): + vr = r['vr'] + entry_str = str(r['entry_ts'])[:16] + exit_str = str(r['exit_ts'])[:16] + krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + total_krw += krw + won = r['pnl'] > 0 + if won: + total_wins += 1 + + pr = r['prices'] + # 봉별 가격 변화율 (봉1→2, 2→3, 3→4) + pchg = [ + (pr[1] - pr[0]) / pr[0] * 100 if pr[0] else 0, + (pr[2] - pr[1]) / pr[1] * 100 if pr[1] else 0, + (pr[3] - pr[2]) / pr[2] * 100 if pr[2] else 0, + ] + + sign = "▲" if won else "▼" + print(f" #{i:02d} {r['ticker']:12s} [{sign}]") + print(f" 가격흐름 {pr[0]:,.0f} → {pr[1]:,.0f} ({pchg[0]:+.2f}%)" + f" → {pr[2]:,.0f} ({pchg[1]:+.2f}%)" + f" → {pr[3]:,.0f} ({pchg[2]:+.2f}%)") + print(f" 볼륨흐름 {vr[0]:.1f}x → {vr[1]:.1f}x → {vr[2]:.1f}x → {vr[3]:.1f}x " + f"(ATR손절 {r['atr_stop']*100:.1f}%)") + print(f" 진입 {entry_str} @ {r['entry_price']:>13,.0f}원") + print(f" 고점 {r['peak']:>13,.0f}원 ({r['peak_pct']:>+.2f}%까지 상승)") + print(f" 청산 {exit_str} @ {r['exit_price']:>13,.0f}원" + f" ({r['status']}, {r['held_bars']}봉 보유)") + print(f" 손익 {r['pnl']:>+.2f}% → {krw:>+,.0f}원") + print() + + if skipped: + print(f" ── 스킵 {len(skipped)}건 (포지션 한도 초과) ──") + for r in skipped: + vr = r['vr'] + krw_if = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + print(f" SKIP {r['ticker']:12s} 진입 {str(r['entry_ts'])[:16]} " + f"vol {vr[3]:.1f}x → {r['pnl']:>+.2f}% ({krw_if:>+,.0f}원 기회손실)") + print() + + n = len(taken) + win_rate = total_wins / n * 100 if n else 0 + ret_rate = total_krw / BUDGET * 100 + print(div) + print(f" ◆ 실거래 {n}건 | 승률 {win_rate:.0f}% ({total_wins}/{n}) | " + f"합산 {total_krw:>+,.0f}원 | 수익률 {ret_rate:>+.2f}%") + print(div) + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + print(f"=== 4봉 볼륨 가속 시뮬 — VOL {VOL_MIN_LIST} 비교 ===") + print(f"ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%], " + f"자본 {BUDGET/10000:.0f}만원 / 포지션당 {PER_POS/10000:.0f}만원 / 동시 {MAX_POS}개") + print(f"조회 기간: {check_since[:10]} ~ 현재\n") + + conn = _get_conn() + cur = conn.cursor() + cur.arraysize = 10_000 + + # ── 1회 조회 (VOL_MIN = 가장 낮은 값) ──────────────────────────────────────── + cur.execute(SIGNAL_SQL, { + "warmup_since": warmup_since, + "check_since": check_since, + "min_vol": VOL_MIN, + }) + rows = cur.fetchall() + + if not rows: + print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음") + conn.close() + return + + # 진입봉 조회 + signals = [] + for row in rows: + ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \ + p3, p2, p1, p0 = row + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {"t": ticker, "sig": sig_ts} + ) + entry_row = cur.fetchone() + if not entry_row: + continue + entry_price, entry_ts = float(entry_row[0]), entry_row[1] + signals.append({ + 'ticker': ticker, + 'sig_ts': sig_ts, + 'entry_ts': entry_ts, + 'entry_price': entry_price, + 'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]], + 'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]], + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + }) + + if not signals: + print("진입봉을 찾을 수 없음") + conn.close() + return + + # ── trail stop 시뮬 — 시나리오별 ───────────────────────────────────────────── + signals.sort(key=lambda x: x['entry_ts']) + vol_thr = VOL_MIN_LIST[0] + + # 각 시나리오에 맞는 bars를 재사용하기 위해 기본(가장 느슨한) 시나리오로 bars 캐시 + # (atr_stop 달라도 bars 조회는 동일하므로 1회만 fetch) + print(f"시그널 {len(signals)}건 trail stop 시뮬 중 ({len(ATR_SCENARIOS)}가지 ATR 비교)...", flush=True) + + # 시나리오별 결과 수집 + scenario_results = [] + for mult, min_r, max_r in ATR_SCENARIOS: + sim = [] + for s in signals: + trail = simulate_trail(cur, s['ticker'], s['entry_ts'], s['entry_price'], + s['atr_raw'], mult=mult, min_r=min_r, max_r=max_r) + sim.append({**s, **trail}) + taken, skipped = apply_pos_limit(sim, vol_thr) + scenario_results.append((mult, min_r, max_r, taken, skipped)) + + # ── 시나리오 요약 비교표 ────────────────────────────────────────────────────── + print(f"\n{'━'*95}") + print(f" {'ATR 설정':22s} {'거래':>4s} {'승률':>5s} {'합산손익':>12s} {'수익률':>6s} {'평균보유(분)':>10s}") + print(f"{'━'*95}") + best_krw = max(sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken) + for _, _, _, taken, _ in scenario_results) + for mult, min_r, max_r, taken, skipped in scenario_results: + n = len(taken) + if n == 0: + continue + wins = sum(1 for r in taken if r['pnl'] > 0) + total = sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken) + avg_held = sum(r['held_bars'] for r in taken) / n + ret = total / BUDGET * 100 + label = f"{mult:.1f}×ATR [{min_r*100:.1f}~{max_r*100:.1f}%]" + star = " ★" if abs(total - best_krw) < 1 else "" + print(f" {label:22s} {n:>4d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 {ret:>+5.2f}%" + f" {avg_held:>6.0f}봉{star}") + print(f"{'━'*95}") + + # ── 최적 시나리오 건별 상세 출력 ───────────────────────────────────────────── + best_idx = max(range(len(scenario_results)), + key=lambda i: sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 + for r in scenario_results[i][3])) + bm, bn, bx, best_taken, best_skipped = scenario_results[best_idx] + print(f"\n[최적 시나리오 건별 상세: {bm:.1f}×ATR {bn*100:.1f}~{bx*100:.1f}%]") + print_detail(vol_thr, best_taken, best_skipped) + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/tests/collect_1y_data.py b/archive/tests/collect_1y_data.py similarity index 100% rename from tests/collect_1y_data.py rename to archive/tests/collect_1y_data.py diff --git a/archive/tests/compare_tp_vs_trail.py b/archive/tests/compare_tp_vs_trail.py new file mode 100644 index 0000000..dba16ef --- /dev/null +++ b/archive/tests/compare_tp_vs_trail.py @@ -0,0 +1,323 @@ +"""3% 고정 익절 vs 현재 trail stop 전략 비교 시뮬레이션. + +전략: + A. Trail Stop only : ATR 1.0×[3.0~5.0%] + B. TP 3% + Trail Stop 손절 : +3% 도달 시 익절, 못 도달하면 trail stop + C. TP 2% + Trail Stop 손절 : +2% 도달 시 익절 + D. TP 5% + Trail Stop 손절 : +5% 도달 시 익절 +""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import oracledb + +# ── 파라미터 ────────────────────────────────────────────────────────────────── +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +LOOKBACK_DAYS = 3 +VOL_MIN = 8.0 + +ATR_MULT = 1.0 +ATR_MIN_R = 0.030 +ATR_MAX_R = 0.050 +MAX_TRAIL_BARS = 240 + +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + +SIGNAL_SQL = f""" +WITH +base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1, + LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1, + LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1, + LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2, + LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2, + LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2, + LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3, + LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3, + LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3, + GREATEST( + high_p - low_p, + ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)) + ) tr + FROM backtest_ohlcv + WHERE interval_cd = 'minute1' + AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +indicators AS ( + SELECT ticker, ts, open_p, close_p, + volume_p / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr0, + prev_vol_1 / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr1, + prev_vol_2 / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr2, + prev_vol_3 / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vr3, + prev_close_1, prev_open_1, + prev_close_2, prev_open_2, + prev_close_3, prev_open_3, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) + / NULLIF(prev_close_1, 0) atr_raw + FROM base +) +SELECT ticker, ts sig_ts, close_p sig_price, + vr0, vr1, vr2, vr3, atr_raw, + prev_close_3, prev_close_2, prev_close_1, close_p +FROM indicators +WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= :min_vol + AND close_p > open_p + AND prev_close_1 > prev_open_1 + AND prev_close_2 > prev_open_2 + AND prev_close_3 > prev_open_3 + AND close_p > prev_close_1 + AND prev_close_1 > prev_close_2 + AND prev_close_2 > prev_close_3 + AND vr0 > vr1 AND vr1 > vr2 AND vr2 > vr3 +ORDER BY ticker, ts +""" + + +def _get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +def _fetch_bars(cur, ticker, entry_ts): + """진입 시점 이후 봉 데이터 조회 (캐시용).""" + cur.execute( + """SELECT ts, close_p, high_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry + ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1} + ) + return [(ts, float(cp), float(hp)) for ts, cp, hp in cur.fetchall()] + + +def simulate_trail_only(bars, entry_price, atr_raw): + """전략 A: Trail Stop만 사용.""" + ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0 + atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + + running_peak = entry_price + for i, (ts, close_p, _) in enumerate(bars): + running_peak = max(running_peak, close_p) + drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0 + if drop >= atr_stop: + pnl = (close_p - entry_price) / entry_price * 100 + peak_pct = (running_peak - entry_price) / entry_price * 100 + return dict(status="트레일손절", exit_ts=ts, exit_price=close_p, + peak=running_peak, peak_pct=peak_pct, + pnl=pnl, held_bars=i + 1) + + last_ts, last_price = bars[-1][0], bars[-1][1] + pnl = (last_price - entry_price) / entry_price * 100 + peak_pct = (running_peak - entry_price) / entry_price * 100 + status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중" + return dict(status=status, exit_ts=last_ts, exit_price=last_price, + peak=running_peak, peak_pct=peak_pct, + pnl=pnl, held_bars=len(bars)) + + +def simulate_tp_trail(bars, entry_price, atr_raw, tp_r): + """전략 B/C/D: 고정 익절 + Trail Stop 손절. + + 먼저 +tp_r% 도달하면 익절. + 못 도달하고 trail stop 걸리면 손절. + """ + ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0 + atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + tp_price = entry_price * (1 + tp_r) + + running_peak = entry_price + for i, (ts, close_p, high_p) in enumerate(bars): + # 고가로 익절 체크 (봉 내 고가가 익절가 도달하면 익절) + if high_p >= tp_price: + pnl = tp_r * 100 + peak_pct = (max(running_peak, high_p) - entry_price) / entry_price * 100 + return dict(status=f"익절+{tp_r*100:.0f}%", exit_ts=ts, exit_price=tp_price, + peak=max(running_peak, high_p), peak_pct=peak_pct, + pnl=pnl, held_bars=i + 1) + + running_peak = max(running_peak, close_p) + drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0 + if drop >= atr_stop: + pnl = (close_p - entry_price) / entry_price * 100 + peak_pct = (running_peak - entry_price) / entry_price * 100 + return dict(status="트레일손절", exit_ts=ts, exit_price=close_p, + peak=running_peak, peak_pct=peak_pct, + pnl=pnl, held_bars=i + 1) + + last_ts, last_price = bars[-1][0], bars[-1][1] + pnl = (last_price - entry_price) / entry_price * 100 + peak_pct = (running_peak - entry_price) / entry_price * 100 + status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중" + return dict(status=status, exit_ts=last_ts, exit_price=last_price, + peak=running_peak, peak_pct=peak_pct, + pnl=pnl, held_bars=len(bars)) + + +def apply_pos_limit(sim_results): + open_positions = [] + taken, skipped = [], [] + for r in sim_results: + open_positions = [ex for ex in open_positions if ex > r['entry_ts']] + if len(open_positions) < MAX_POS: + open_positions.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def print_summary(label, taken): + n = len(taken) + if n == 0: + print(f" {label:35s} 거래없음") + return + wins = sum(1 for r in taken if r['pnl'] > 0) + total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken) + avg_h = sum(r['held_bars'] for r in taken) / n + ret = total / BUDGET * 100 + print(f" {label:35s} {n:>3d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 {ret:>+5.2f}% {avg_h:>5.0f}봉") + + +def print_detail(label, taken): + print(f"\n{'━'*110}") + print(f" {label}") + print(f"{'━'*110}") + for i, r in enumerate(taken, 1): + krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + sign = "▲" if r['pnl'] > 0 else "▼" + print(f" #{i:02d} {r['ticker']:12s} [{sign}] 진입 {str(r['entry_ts'])[:16]} " + f"고점 {r['peak_pct']:>+.2f}% → {r['status']} {r['held_bars']}봉 " + f"PNL {r['pnl']:>+.2f}% ({krw:>+,.0f}원)") + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + print(f"=== 고정 익절 vs Trail Stop 비교 시뮬 ===") + print(f"기간: {check_since[:10]} ~ 현재 | 4봉 VOL≥{VOL_MIN}x | " + f"손절: ATR {ATR_MULT}×[{ATR_MIN_R*100:.0f}~{ATR_MAX_R*100:.0f}%]") + print(f"자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개\n") + + conn = _get_conn() + cur = conn.cursor() + cur.arraysize = 10_000 + + cur.execute(SIGNAL_SQL, { + "warmup_since": warmup_since, + "check_since": check_since, + "min_vol": VOL_MIN, + }) + rows = cur.fetchall() + if not rows: + print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음") + conn.close() + return + + # 진입봉 + bars 수집 + signals = [] + for row in rows: + ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \ + p3, p2, p1, p0 = row + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {"t": ticker, "sig": sig_ts} + ) + entry_row = cur.fetchone() + if not entry_row: + continue + entry_price, entry_ts = float(entry_row[0]), entry_row[1] + bars = _fetch_bars(cur, ticker, entry_ts) + if not bars: + continue + signals.append({ + 'ticker': ticker, + 'entry_ts': entry_ts, + 'entry_price': entry_price, + 'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]], + 'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]], + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + 'bars': bars, + }) + + signals.sort(key=lambda x: x['entry_ts']) + print(f"시그널 {len(signals)}건 → 전략별 시뮬 중...\n") + + # ── 전략별 시뮬 ─────────────────────────────────────────────────────────── + strategies = [ + ("A. Trail Stop [3~5%]", None), + ("B. 익절 2% + Trail Stop", 0.02), + ("C. 익절 3% + Trail Stop", 0.03), + ("D. 익절 5% + Trail Stop", 0.05), + ] + + results = {} + for label, tp_r in strategies: + sim = [] + for s in signals: + if tp_r is None: + r = simulate_trail_only(s['bars'], s['entry_price'], s['atr_raw']) + else: + r = simulate_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r) + sim.append({**s, **r}) + taken, _ = apply_pos_limit(sim) + results[label] = taken + + # ── 요약 비교표 ─────────────────────────────────────────────────────────── + print(f"{'━'*95}") + print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s}") + print(f"{'━'*95}") + for label, taken in results.items(): + print_summary(label, taken) + print(f"{'━'*95}") + + # ── 전략별 건별 상세 ────────────────────────────────────────────────────── + for label, taken in results.items(): + print_detail(label, taken) + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/archive/tests/fetch_1y_minute1.py b/archive/tests/fetch_1y_minute1.py new file mode 100644 index 0000000..ed30acd --- /dev/null +++ b/archive/tests/fetch_1y_minute1.py @@ -0,0 +1,131 @@ +"""1년치 1분봉 OHLCV → BACKTEST_OHLCV (수동 페이지네이션). + +pyupbit이 count>5000에서 실패하므로 to 파라미터로 직접 페이지네이션. +실행: .venv/bin/python3 tests/fetch_1y_minute1.py +예상 소요: 20종목 × ~15분 = ~5시간 (overnight 실행 권장) +""" +import sys, os, time +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import pyupbit +import oracledb +from datetime import datetime, timedelta +import pandas as pd + +ALL_TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', # 그룹 1 + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', # 그룹 2 + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', # 그룹 3 + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', # 그룹 4 +] +# 실행: python fetch_1y_minute1.py [그룹번호 1-4] +import argparse +parser = argparse.ArgumentParser() +parser.add_argument('group', nargs='?', type=int, default=0, + help='1-4: 해당 그룹만 실행, 0: 전체') +args, _ = parser.parse_known_args() +if args.group in (1,2,3,4): + TICKERS = ALL_TICKERS[(args.group-1)*5 : args.group*5] + print(f"그룹 {args.group} 실행: {TICKERS}") +else: + TICKERS = ALL_TICKERS +BATCH = 4000 # 한 번에 요청할 봉 수 (4000 = ~2.8일) +DELAY = 0.15 # API 딜레이 (초) +TARGET_DAYS = 365 # 목표 기간 + +def _get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +def insert_batch(conn, ticker, df) -> int: + """DB에 없는 행만 삽입. 반환: 신규 건수.""" + cur = conn.cursor() + min_ts = df.index.min().to_pydatetime() + max_ts = df.index.max().to_pydatetime() + cur.execute( + "SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd='minute1' " + "AND ts BETWEEN :s AND :e", + {"t": ticker, "s": min_ts, "e": max_ts} + ) + existing = {r[0] for r in cur.fetchall()} + new_rows = [ + (ticker, 'minute1', ts.to_pydatetime(), + float(r["open"]), float(r["high"]), float(r["low"]), + float(r["close"]), float(r["volume"])) + for ts, r in df.iterrows() + if ts.to_pydatetime() not in existing + ] + if not new_rows: + return 0 + cur.executemany( + "INSERT INTO backtest_ohlcv (ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) " + "VALUES (:1,:2,:3,:4,:5,:6,:7,:8)", + new_rows + ) + conn.commit() + return len(new_rows) + + +def fetch_ticker(conn, ticker) -> int: + """ticker 1년치 1분봉 fetch → DB 저장. 반환: 총 신규 건수.""" + cutoff = datetime.now() - timedelta(days=TARGET_DAYS) + to_dt = datetime.now() + total = 0 + batch_no = 0 + + while to_dt > cutoff: + to_str = to_dt.strftime('%Y-%m-%d %H:%M:%S') + try: + df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str) + time.sleep(DELAY) + except Exception as e: + print(f" API 오류: {e} → 재시도") + time.sleep(2.0) + continue + + if df is None or len(df) == 0: + break + + n = insert_batch(conn, ticker, df) + total += n + batch_no += 1 + oldest = df.index[0] + + print(f" 배치{batch_no:03d}: {oldest.date()} ~ {df.index[-1].strftime('%m-%d')} " + f"({len(df)}봉, 신규 {n}) | 누적 {total:,}", flush=True) + + # 다음 페이지: 이 배치에서 가장 오래된 봉 이전부터 + to_dt = oldest - timedelta(minutes=1) + + if oldest <= cutoff: + break + + return total + + +conn = _get_conn() +grand_total = 0 +start_time = time.time() + +for idx, tk in enumerate(TICKERS, 1): + t0 = time.time() + print(f"\n[{idx:02d}/{len(TICKERS)}] {tk} 시작...", flush=True) + try: + n = fetch_ticker(conn, tk) + elapsed = time.time() - t0 + grand_total += n + print(f" → 완료: 신규 {n:,}행 ({elapsed/60:.1f}분) | 전체 누적 {grand_total:,}행", flush=True) + except Exception as e: + print(f" → 오류: {e}") + +conn.close() +elapsed_total = time.time() - start_time +print(f"\n전체 완료: {grand_total:,}행 저장 ({elapsed_total/60:.0f}분 소요)") diff --git a/tests/fng_1y_backtest.py b/archive/tests/fng_1y_backtest.py similarity index 100% rename from tests/fng_1y_backtest.py rename to archive/tests/fng_1y_backtest.py diff --git a/tests/fng_adaptive_backtest.py b/archive/tests/fng_adaptive_backtest.py similarity index 100% rename from tests/fng_adaptive_backtest.py rename to archive/tests/fng_adaptive_backtest.py diff --git a/tests/fng_backtest.py b/archive/tests/fng_backtest.py similarity index 100% rename from tests/fng_backtest.py rename to archive/tests/fng_backtest.py diff --git a/tests/fng_sim_comparison.py b/archive/tests/fng_sim_comparison.py similarity index 100% rename from tests/fng_sim_comparison.py rename to archive/tests/fng_sim_comparison.py diff --git a/tests/interval_sweep.py b/archive/tests/interval_sweep.py similarity index 100% rename from tests/interval_sweep.py rename to archive/tests/interval_sweep.py diff --git a/tests/krw_sim.py b/archive/tests/krw_sim.py similarity index 100% rename from tests/krw_sim.py rename to archive/tests/krw_sim.py diff --git a/tests/momentum_cmp.py b/archive/tests/momentum_cmp.py similarity index 100% rename from tests/momentum_cmp.py rename to archive/tests/momentum_cmp.py diff --git a/tests/ohlcv_db.py b/archive/tests/ohlcv_db.py similarity index 100% rename from tests/ohlcv_db.py rename to archive/tests/ohlcv_db.py diff --git a/tests/pullback_backtest.py b/archive/tests/pullback_backtest.py similarity index 100% rename from tests/pullback_backtest.py rename to archive/tests/pullback_backtest.py diff --git a/tests/refresh_cache.py b/archive/tests/refresh_cache.py similarity index 100% rename from tests/refresh_cache.py rename to archive/tests/refresh_cache.py diff --git a/tests/shadow_sim.py b/archive/tests/shadow_sim.py similarity index 100% rename from tests/shadow_sim.py rename to archive/tests/shadow_sim.py diff --git a/tests/shadow_sim2.py b/archive/tests/shadow_sim2.py similarity index 100% rename from tests/shadow_sim2.py rename to archive/tests/shadow_sim2.py diff --git a/tests/shadow_sim3.py b/archive/tests/shadow_sim3.py similarity index 100% rename from tests/shadow_sim3.py rename to archive/tests/shadow_sim3.py diff --git a/tests/sim10m.py b/archive/tests/sim10m.py similarity index 100% rename from tests/sim10m.py rename to archive/tests/sim10m.py diff --git a/tests/sim_10m_vol.py b/archive/tests/sim_10m_vol.py similarity index 100% rename from tests/sim_10m_vol.py rename to archive/tests/sim_10m_vol.py diff --git a/tests/sim_365.py b/archive/tests/sim_365.py similarity index 100% rename from tests/sim_365.py rename to archive/tests/sim_365.py diff --git a/archive/tests/sim_3bar.py b/archive/tests/sim_3bar.py new file mode 100644 index 0000000..e0b881c --- /dev/null +++ b/archive/tests/sim_3bar.py @@ -0,0 +1,314 @@ +"""3봉 vol가속 시그널 + 다양한 청산 전략 비교 시뮬 (30일).""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) +import oracledb + +LOOKBACK_DAYS = 30 +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +VOL_MIN = 8.0 +ATR_MULT = 1.0 +ATR_MIN_R = 0.030 +ATR_MAX_R = 0.050 +MAX_TRAIL_BARS = 240 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + return oracledb.connect(**kwargs) + + +# ── 3봉 시그널 SQL ───────────────────────────────────────────────────────────── +SIGNAL_SQL_3BAR = f""" +WITH base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1, + LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1, + LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2, + LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2, + GREATEST(high_p-low_p, + ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr + FROM backtest_ohlcv + WHERE interval_cd='minute1' + AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +ind AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, + volume_p / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0, + LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1, + LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2, + pc1,po1,pc2,po2, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw + FROM base +) +SELECT ticker, ts, vr0, vr1, vr2, atr_raw +FROM ind +WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= {VOL_MIN} + -- 3봉 연속 양봉 + AND close_p>open_p AND pc1>po1 AND pc2>po2 + -- 3봉 연속 가격 가속 + AND close_p>pc1 AND pc1>pc2 + -- 3봉 연속 볼륨 가속 + AND vr0>vr1 AND vr1>vr2 +ORDER BY ticker, ts +""" + +# ── 4봉 시그널 SQL (비교용) ──────────────────────────────────────────────────── +SIGNAL_SQL_4BAR = f""" +WITH base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1, + LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1, + LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2, + LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2, + LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3, + LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3, + GREATEST(high_p-low_p, + ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr + FROM backtest_ohlcv + WHERE interval_cd='minute1' + AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +ind AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, + volume_p / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0, + LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1, + LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2, + LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3, + pc1,po1,pc2,po2,pc3,po3, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw + FROM base +) +SELECT ticker, ts, vr0, vr1, vr2, atr_raw +FROM ind +WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= {VOL_MIN} + AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3 + AND close_p>pc1 AND pc1>pc2 AND pc2>pc3 + AND vr0>vr1 AND vr1>vr2 AND vr2>vr3 +ORDER BY ticker, ts +""" + + +def fetch_signals(cur, sql, warmup_since, check_since): + cur.execute(sql, {'ws': warmup_since, 'cs': check_since}) + rows = cur.fetchall() + signals = [] + for row in rows: + ticker, sig_ts, vr0, vr1, vr2, atr_raw = row[:6] + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {'t': ticker, 'sig': sig_ts} + ) + er = cur.fetchone() + if not er: + continue + ep, ets = float(er[0]), er[1] + cur.execute( + """SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1} + ) + bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()] + if not bars: + continue + signals.append({ + 'ticker': ticker, 'entry_ts': ets, 'entry_price': ep, + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + 'bars': bars, + }) + signals.sort(key=lambda x: x['entry_ts']) + return signals + + +# ── 청산 전략 함수들 ─────────────────────────────────────────────────────────── +def sim_trail(bars, ep, ar): + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일손절', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_tp_trail(bars, ep, ar, tp_r): + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + tp = ep * (1 + tp_r) + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + if hp >= tp: + return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp, + pnl=tp_r * 100, held=i + 1) + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일손절', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_tp_sl(bars, ep, tp_r, sl_r): + tp = ep * (1 + tp_r) + sl = ep * (1 - sl_r) + for i, (ts, cp, hp, lp) in enumerate(bars): + if lp <= sl: + return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl, + pnl=-sl_r * 100, held=i + 1) + if hp >= tp: + return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp, + pnl=tp_r * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def pos_limit(sim): + opens, taken, skipped = [], [], [] + for r in sim: + opens = [ex for ex in opens if ex > r['entry_ts']] + if len(opens) < MAX_POS: + opens.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def run_strategies(signals, strategies): + results = {} + for label, mode, tp_r, sl_r in strategies: + sim = [] + for s in signals: + if mode == 'trail': + r = sim_trail(s['bars'], s['entry_price'], s['atr_raw']) + elif mode == 'tp_trail': + r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r) + else: + r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r) + sim.append({**s, **r}) + taken, _ = pos_limit(sim) + results[label] = taken + return results + + +def print_table(title, results, strategies): + print(f"\n{'━'*105}") + print(f" {title}") + print(f"{'━'*105}") + print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}") + print(f"{'─'*105}") + for label, _, _, _ in strategies: + taken = results.get(label, []) + n = len(taken) + if n == 0: + print(f" {label:32s} 거래없음") + continue + wins = sum(1 for r in taken if r['pnl'] > 0) + losses = sum(1 for r in taken if r['pnl'] < 0) + total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken) + avg_h = sum(r['held'] for r in taken) / n + ret = total / BUDGET * 100 + avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0 + avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1 + rr = avg_w / avg_l + print(f" {label:32s} {n:>3d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 " + f"{ret:>+5.2f}% {avg_h:>5.0f}봉 {rr:>4.1f}:1") + print(f"{'━'*105}") + + +def print_detail(label, taken): + print(f"\n[{label} 건별 상세]") + print(f"{'─'*105}") + for i, r in enumerate(taken, 1): + krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + sign = '▲' if r['pnl'] > 0 else '▼' + print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} " + f"→ {r['status']:14s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)") + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + conn = get_conn() + cur = conn.cursor() + cur.arraysize = 10000 + + print(f"=== 3봉 vs 4봉 진입 비교 시뮬 ===") + print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)") + print(f"VOL≥{VOL_MIN}x | 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개\n") + + # 시그널 수집 + print("3봉 시그널 수집 중...", flush=True) + sigs_3 = fetch_signals(cur, SIGNAL_SQL_3BAR, warmup_since, check_since) + print("4봉 시그널 수집 중...", flush=True) + sigs_4 = fetch_signals(cur, SIGNAL_SQL_4BAR, warmup_since, check_since) + print(f" → 3봉: {len(sigs_3)}건 / 4봉: {len(sigs_4)}건\n") + + strategies = [ + ('TP 3% + Trail Stop [3~5%]', 'tp_trail', 0.03, None ), + ('TP 2% + Trail Stop [3~5%]', 'tp_trail', 0.02, None ), + ('TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ), + ('TP 2% + SL 3%', 'tp_sl', 0.02, 0.03 ), + ('TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ), + ('TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ), + ] + + res_3 = run_strategies(sigs_3, strategies) + res_4 = run_strategies(sigs_4, strategies) + + print_table(f"【3봉 진입】 시그널 {len(sigs_3)}건", res_3, strategies) + print_table(f"【4봉 진입】 시그널 {len(sigs_4)}건", res_4, strategies) + + # 3봉에서 가장 나은 전략 상세 + best_label = max(res_3, key=lambda k: sum( + PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in res_3[k])) + print_detail(f"3봉 최적: {best_label}", res_3[best_label]) + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/tests/sim_45m40.py b/archive/tests/sim_45m40.py similarity index 100% rename from tests/sim_45m40.py rename to archive/tests/sim_45m40.py diff --git a/archive/tests/sim_cascade.py b/archive/tests/sim_cascade.py new file mode 100644 index 0000000..0c23c4c --- /dev/null +++ b/archive/tests/sim_cascade.py @@ -0,0 +1,281 @@ +"""캐스케이드 limit 주문 전략 시뮬 (30일). + +전략: + ① bars[0:2] → 2봉, +2% limit (trail 없음) + ② bars[2:5] → 3봉, +1% limit (trail 없음) + ③ bars[5:5+last_n] → last_n봉, +0.5% limit (trail 없음) + ④ bars[5+last_n:] → 기존전략 (TP2% + ATR Trail Stop) +""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) +import oracledb + +LOOKBACK_DAYS = 30 +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +VOL_MIN = 8.0 +ATR_MULT = 1.0 +ATR_MIN_R = 0.030 +ATR_MAX_R = 0.050 +MAX_TRAIL_BARS = 240 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + return oracledb.connect(**kwargs) + + +SIGNAL_SQL = f""" +WITH base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1, + LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1, + LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2, + LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2, + GREATEST(high_p-low_p, + ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr + FROM backtest_ohlcv + WHERE interval_cd='minute1' + AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +ind AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, + volume_p / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0, + LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1, + LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2, + pc1,po1,pc2,po2, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw + FROM base +) +SELECT ticker, ts, vr0, vr1, vr2, atr_raw +FROM ind +WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= {VOL_MIN} + AND close_p>open_p AND pc1>po1 AND pc2>po2 + AND close_p>pc1 AND pc1>pc2 + AND vr0>vr1 AND vr1>vr2 +ORDER BY ticker, ts +""" + + +def fetch_signals(cur, warmup_since, check_since): + cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since}) + rows = cur.fetchall() + signals = [] + for row in rows: + ticker, sig_ts, vr0, vr1, vr2, atr_raw = row + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {'t': ticker, 'sig': sig_ts} + ) + er = cur.fetchone() + if not er: + continue + ep, ets = float(er[0]), er[1] + cur.execute( + """SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1} + ) + bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()] + if not bars: + continue + signals.append({ + 'ticker': ticker, 'entry_ts': ets, 'entry_price': ep, + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + 'bars': bars, + }) + signals.sort(key=lambda x: x['entry_ts']) + return signals + + +def sim_tp_trail(bars, ep, ar, tp_r=0.02): + """기본 전략: TP + Trail Stop.""" + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + tp = ep * (1 + tp_r) + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + if hp >= tp: + return dict(status='TP2%', exit_ts=ts, exit_price=tp, + pnl=tp_r * 100, held=i + 1) + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일손절', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_cascade(bars, ep, ar, last_n): + """ + ① bars[0:2] → 2봉, +2% limit + ② bars[2:5] → 3봉, +1% limit + ③ bars[5:5+last_n] → last_n봉, +0.5% limit + ④ bars[5+last_n:] → 기존전략 (TP2% + Trail Stop) + """ + stages = [ + (0, 2, 0.020, f'①2봉2%'), + (2, 5, 0.010, f'②3봉1%'), + (5, 5 + last_n, 0.005, f'③{last_n}봉0.5%'), + ] + for start, end, lr, tag in stages: + lp = ep * (1 + lr) + for i, (ts, cp, hp, _) in enumerate(bars[start:end]): + if hp >= lp: + return dict(status=tag, exit_ts=ts, exit_price=lp, + pnl=lr * 100, held=start + i + 1) + + offset = 5 + last_n + fb = sim_tp_trail(bars[offset:] or bars[-1:], ep, ar) + fb['held'] += offset + fb['status'] = '④기존→' + fb['status'] + return fb + + +def sim_limit_then_trail(bars, ep, ar, n_bars=2, limit_r=0.005, tp_r=0.02): + """단순 limit: N봉 내 체결 안되면 TP/Trail.""" + lp = ep * (1 + limit_r) + for i, (ts, cp, hp, _) in enumerate(bars[:n_bars]): + if hp >= lp: + return dict(status=f'limit{limit_r*100:.1f}%', exit_ts=ts, + exit_price=lp, pnl=limit_r * 100, held=i + 1) + fb = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r) + fb['held'] += n_bars + fb['status'] = '미체결→' + fb['status'] + return fb + + +def pos_limit(sim): + opens, taken, skipped = [], [], [] + for r in sim: + opens = [ex for ex in opens if ex > r['entry_ts']] + if len(opens) < MAX_POS: + opens.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def krw(r): + return PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + + +def print_cascade_detail(taken, last_n, label): + stage_tags = ['①2봉2%', '②3봉1%', f'③{last_n}봉0.5%'] + stage_lr = [0.020, 0.010, 0.005] + + print(f"\n{'━'*70}") + print(f" {label}") + print(f" 총 {len(taken)}건 승률 {sum(1 for r in taken if r['pnl']>0)/len(taken)*100:.0f}% " + f"합산 {sum(krw(r) for r in taken):+,.0f}원") + print(f"{'━'*70}") + + for tag, lr in zip(stage_tags, stage_lr): + grp = [r for r in taken if r['status'] == tag] + if not grp: + continue + total = sum(krw(r) for r in grp) + avg = total / len(grp) + print(f" ┌─ {tag}: {len(grp):3d}건 avg {avg:+,.0f}원/건 소계 {total:+,.0f}원") + + # ④ 기존전략 하위 분류 + fb_grp = [r for r in taken if r['status'].startswith('④기존→')] + if fb_grp: + print(f" └─ ④기존전략 (미체결 후): {len(fb_grp)}건") + for sub in ['TP2%', '트레일손절', '타임아웃', '진행중']: + sub_grp = [r for r in fb_grp if r['status'].endswith(sub)] + if not sub_grp: + continue + total = sum(krw(r) for r in sub_grp) + avg = total / len(sub_grp) + print(f" {'▲' if total>0 else '▼'} {sub:8s}: {len(sub_grp):3d}건 " + f"avg {avg:+,.0f}원/건 소계 {total:+,.0f}원") + + print() + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + conn = get_conn() + cur = conn.cursor() + cur.arraysize = 10000 + + print(f"=== 캐스케이드 limit 전략 시뮬 ===") + print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n") + + signals = fetch_signals(cur, warmup_since, check_since) + print(f"시그널 {len(signals)}건\n") + + # ── 기준선: 현재전략 ───────────────────────────────────────────────────── + base_sim = [] + for s in signals: + r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw']) + base_sim.append({**s, **r}) + base_taken, _ = pos_limit(base_sim) + base_total = sum(krw(r) for r in base_taken) + base_wr = sum(1 for r in base_taken if r['pnl'] > 0) / len(base_taken) * 100 + + print(f"{'━'*70}") + print(f" [기준] 현재전략 TP2%+Trail: {len(base_taken)}건 " + f"승률 {base_wr:.0f}% 합산 {base_total:+,.0f}원") + + # ── 비교: limit 0.5%/2봉 → TP/Trail ───────────────────────────────────── + lim_sim = [] + for s in signals: + r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw']) + lim_sim.append({**s, **r}) + lim_taken, _ = pos_limit(lim_sim) + lim_total = sum(krw(r) for r in lim_taken) + lim_wr = sum(1 for r in lim_taken if r['pnl'] > 0) / len(lim_taken) * 100 + print(f" [비교] limit 0.5%/2봉→TP/Trail: {len(lim_taken)}건 " + f"승률 {lim_wr:.0f}% 합산 {lim_total:+,.0f}원") + print(f"{'━'*70}\n") + + # ── 캐스케이드 (15봉 / 30봉) ───────────────────────────────────────────── + for last_n in [15, 30]: + label = f"cascade ①2봉+2% → ②3봉+1% → ③{last_n}봉+0.5% → ④기존전략" + csim = [] + for s in signals: + r = sim_cascade(s['bars'], s['entry_price'], s['atr_raw'], last_n) + csim.append({**s, **r}) + taken, _ = pos_limit(csim) + print_cascade_detail(taken, last_n, label) + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/tests/sim_current.py b/archive/tests/sim_current.py similarity index 100% rename from tests/sim_current.py rename to archive/tests/sim_current.py diff --git a/archive/tests/sim_limit_exit.py b/archive/tests/sim_limit_exit.py new file mode 100644 index 0000000..842e3c3 --- /dev/null +++ b/archive/tests/sim_limit_exit.py @@ -0,0 +1,265 @@ +"""진입 즉시 limit 매도 주문 → N봉 내 미체결 시 TP/Trail 전환 시뮬 (30일). + +전략: + 1. 3봉 vol가속 시그널 → 진입 + 2. 즉시 limit_price = entry_price × (1 + limit_r) 에 limit 매도 주문 + 3. N봉 안에 high_p >= limit_price → 체결 (limit_price에 청산) + 4. N봉 안에 미체결 → TP 2% + Trail Stop 으로 전환 +""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) +import oracledb + +LOOKBACK_DAYS = 30 +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +VOL_MIN = 8.0 +ATR_MULT = 1.0 +ATR_MIN_R = 0.030 +ATR_MAX_R = 0.050 +MAX_TRAIL_BARS = 240 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + return oracledb.connect(**kwargs) + + +SIGNAL_SQL = f""" +WITH base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1, + LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1, + LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2, + LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2, + GREATEST(high_p-low_p, + ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr + FROM backtest_ohlcv + WHERE interval_cd='minute1' + AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +ind AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, + volume_p / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0, + LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1, + LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2, + pc1,po1,pc2,po2, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw + FROM base +) +SELECT ticker, ts, vr0, vr1, vr2, atr_raw +FROM ind +WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= {VOL_MIN} + AND close_p>open_p AND pc1>po1 AND pc2>po2 + AND close_p>pc1 AND pc1>pc2 + AND vr0>vr1 AND vr1>vr2 +ORDER BY ticker, ts +""" + + +def fetch_signals(cur, warmup_since, check_since): + cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since}) + rows = cur.fetchall() + signals = [] + for row in rows: + ticker, sig_ts, vr0, vr1, vr2, atr_raw = row + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {'t': ticker, 'sig': sig_ts} + ) + er = cur.fetchone() + if not er: + continue + ep, ets = float(er[0]), er[1] + cur.execute( + """SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1} + ) + bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()] + if not bars: + continue + signals.append({ + 'ticker': ticker, 'entry_ts': ets, 'entry_price': ep, + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + 'bars': bars, + }) + signals.sort(key=lambda x: x['entry_ts']) + return signals + + +# ── 청산 전략 ───────────────────────────────────────────────────────────────── + +def sim_tp_trail(bars, ep, ar, tp_r=0.02): + """기본 전략: TP + Trail Stop.""" + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + tp = ep * (1 + tp_r) + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + if hp >= tp: + return dict(status=f'TP{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp, + pnl=tp_r * 100, held=i + 1) + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_limit_then_trail(bars, ep, ar, n_bars, limit_r, tp_r=0.02): + """진입 즉시 limit_price에 매도 주문 → N봉 내 체결 안되면 TP/Trail 전환. + + 체결 조건: high_p >= limit_price → limit_price에 청산 (실현 가능한 가격) + """ + limit_price = ep * (1 + limit_r) + window = bars[:n_bars] + + for i, (ts, cp, hp, lp) in enumerate(window): + if hp >= limit_price: + pnl = (limit_price - ep) / ep * 100 + return dict(status=f'limit체결({n_bars}봉)', exit_ts=ts, + exit_price=limit_price, pnl=pnl, held=i + 1) + + # N봉 내 미체결 → TP/Trail 전환 + fallback = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r) + fallback['status'] = f'미체결→{fallback["status"]}' + fallback['held'] += n_bars + return fallback + + +def pos_limit(sim): + opens, taken, skipped = [], [], [] + for r in sim: + opens = [ex for ex in opens if ex > r['entry_ts']] + if len(opens) < MAX_POS: + opens.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def stats(taken): + n = len(taken) + if n == 0: + return None + wins = sum(1 for r in taken if r['pnl'] > 0) + losses = sum(1 for r in taken if r['pnl'] < 0) + total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken) + avg_h = sum(r['held'] for r in taken) / n + ret = total / BUDGET * 100 + avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0 + avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1 + fill_n = sum(1 for r in taken if 'limit체결' in r['status']) + return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h, + avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0, + fill_r=fill_n/n*100) + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + conn = get_conn() + cur = conn.cursor() + cur.arraysize = 10000 + + print(f"=== Limit 주문 전략 시뮬 (3봉 진입) ===") + print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n") + + signals = fetch_signals(cur, warmup_since, check_since) + print(f"시그널 {len(signals)}건\n") + + # 비교 전략 목록: (label, limit_r, n_bars) + strategies = [ + ('현재전략: TP 2% + Trail', None, None, None), + ('limit 0.5% / 2봉, 미체결→TP/Trail', 0.005, 2, 0.02), + ('limit 0.5% / 3봉, 미체결→TP/Trail', 0.005, 3, 0.02), + ('limit 0.5% / 5봉, 미체결→TP/Trail', 0.005, 5, 0.02), + ('limit 1.0% / 2봉, 미체결→TP/Trail', 0.010, 2, 0.02), + ('limit 1.0% / 3봉, 미체결→TP/Trail', 0.010, 3, 0.02), + ('limit 1.0% / 5봉, 미체결→TP/Trail', 0.010, 5, 0.02), + ('limit 1.5% / 2봉, 미체결→TP/Trail', 0.015, 2, 0.02), + ('limit 1.5% / 3봉, 미체결→TP/Trail', 0.015, 3, 0.02), + ('limit 1.5% / 5봉, 미체결→TP/Trail', 0.015, 5, 0.02), + ('limit 2.0% / 3봉, 미체결→TP/Trail', 0.020, 3, 0.02), + ('limit 2.0% / 5봉, 미체결→TP/Trail', 0.020, 5, 0.02), + ] + + results = {} + for label, limit_r, n_bars, tp_r in strategies: + sim = [] + for s in signals: + if limit_r is None: + r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw']) + else: + r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'], + n_bars, limit_r, tp_r) + sim.append({**s, **r}) + taken, _ = pos_limit(sim) + results[label] = taken + + # ── 요약표 ────────────────────────────────────────────────────────────────── + print(f"{'━'*120}") + print(f" {'전략':40s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} " + f"{'평균보유':>5s} {'체결률':>5s} {'평균수익':>6s} {'평균손실':>6s}") + print(f"{'━'*120}") + for label, limit_r, n_bars, tp_r in strategies: + taken = results[label] + s = stats(taken) + if not s: + continue + print(f" {label:40s} {s['n']:>3d}건 {s['wr']:>4.0f}% {s['total']:>+12,.0f}원 " + f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}봉 {s['fill_r']:>4.0f}% " + f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%") + print(f"{'━'*120}") + + # ── 최고 전략 상세 ────────────────────────────────────────────────────────── + best_label = max(results, key=lambda k: sum(PER_POS * (r['pnl']/100) - PER_POS*FEE*2 + for r in results[k])) + print(f"\n[최고 전략: {best_label} 건별 상세]") + print(f"{'─'*110}") + for i, r in enumerate(results[best_label], 1): + krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + sign = '▲' if r['pnl'] > 0 else '▼' + print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} " + f"진입 {r['entry_price']:>10,.0f}원 " + f"→ {r['status']:22s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)") + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/archive/tests/sim_peak_exit.py b/archive/tests/sim_peak_exit.py new file mode 100644 index 0000000..2b47675 --- /dev/null +++ b/archive/tests/sim_peak_exit.py @@ -0,0 +1,253 @@ +"""진입 후 N봉 내 최고가 청산 전략 시뮬 (30일). + +전략: 3봉 vol가속 진입 → N봉 내 최고 고가에서 매도 +비교: 현재 전략 (TP 2% + Trail Stop) +""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) +import oracledb + +LOOKBACK_DAYS = 30 +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +VOL_MIN = 8.0 +ATR_MULT = 1.0 +ATR_MIN_R = 0.030 +ATR_MAX_R = 0.050 +MAX_TRAIL_BARS = 240 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + return oracledb.connect(**kwargs) + + +SIGNAL_SQL = f""" +WITH base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1, + LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1, + LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2, + LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2, + GREATEST(high_p-low_p, + ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr + FROM backtest_ohlcv + WHERE interval_cd='minute1' + AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +ind AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, + volume_p / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0, + LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1, + LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2, + pc1,po1,pc2,po2, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw + FROM base +) +SELECT ticker, ts, vr0, vr1, vr2, atr_raw +FROM ind +WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= {VOL_MIN} + AND close_p>open_p AND pc1>po1 AND pc2>po2 + AND close_p>pc1 AND pc1>pc2 + AND vr0>vr1 AND vr1>vr2 +ORDER BY ticker, ts +""" + + +def fetch_signals(cur, warmup_since, check_since): + cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since}) + rows = cur.fetchall() + signals = [] + for row in rows: + ticker, sig_ts, vr0, vr1, vr2, atr_raw = row + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {'t': ticker, 'sig': sig_ts} + ) + er = cur.fetchone() + if not er: + continue + ep, ets = float(er[0]), er[1] + # 최대 10봉만 필요 (peak exit용) + trail stop용 241봉 + cur.execute( + """SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1} + ) + bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()] + if not bars: + continue + signals.append({ + 'ticker': ticker, 'entry_ts': ets, 'entry_price': ep, + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + 'bars': bars, + }) + signals.sort(key=lambda x: x['entry_ts']) + return signals + + +# ── 청산 전략 ───────────────────────────────────────────────────────────────── + +def sim_tp_trail(bars, ep, ar, tp_r=0.02): + """기본 전략: TP 2% + Trail Stop.""" + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + tp = ep * (1 + tp_r) + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + if hp >= tp: + return dict(status='익절+2%', exit_ts=ts, exit_price=tp, pnl=tp_r * 100, held=i + 1) + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일손절', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_peak_then_trail(bars, ep, ar, n_bars, tp_r=0.02): + """진입 후 n_bars 봉 내 이익 구간이 나오면 고가 청산. + 이익 없으면 TP 2% + Trail Stop으로 계속 운영. + + 이익 판단: n_bars 내 어느 봉이든 high_p > entry_price 이면 + 그 구간의 최고 고가에서 청산. + """ + window = bars[:n_bars] + + # n봉 내 이익 구간 탐색 + best_high = max((hp for _, _, hp, _ in window), default=ep) + + if best_high > ep: + # 이익 나는 최고 고가에서 청산 + best_ts = next(ts for ts, cp, hp, lp in window if hp == best_high) + held = next(i + 1 for i, (ts, cp, hp, lp) in enumerate(window) if hp == best_high) + pnl = (best_high - ep) / ep * 100 + return dict(status=f'피크청산({n_bars}봉)', exit_ts=best_ts, exit_price=best_high, + pnl=pnl, held=held) + + # n봉 내 이익 없음 → TP 2% + Trail Stop으로 전환 + return sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r) + + +def pos_limit(sim): + opens, taken, skipped = [], [], [] + for r in sim: + opens = [ex for ex in opens if ex > r['entry_ts']] + if len(opens) < MAX_POS: + opens.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def stats(taken): + n = len(taken) + if n == 0: + return None + wins = sum(1 for r in taken if r['pnl'] > 0) + losses = sum(1 for r in taken if r['pnl'] < 0) + total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken) + avg_h = sum(r['held'] for r in taken) / n + ret = total / BUDGET * 100 + avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0 + avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1 + return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h, + avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0) + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + conn = get_conn() + cur = conn.cursor() + cur.arraysize = 10000 + + print(f"=== 3봉 진입 후 N봉 피크청산 전략 시뮬 ===") + print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n") + + signals = fetch_signals(cur, warmup_since, check_since) + print(f"시그널 {len(signals)}건\n") + + strategies = [ + ('현재전략: TP 2% + Trail Stop', 'tp_trail', None), + ('2봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 2 ), + ('3봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 3 ), + ('5봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 5 ), + ] + + results = {} + for label, mode, param in strategies: + sim = [] + for s in signals: + if mode == 'tp_trail': + r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw']) + else: + r = sim_peak_then_trail(s['bars'], s['entry_price'], s['atr_raw'], param) + sim.append({**s, **r}) + taken, _ = pos_limit(sim) + results[label] = taken + + # ── 요약표 ────────────────────────────────────────────────────────────────── + print(f"{'━'*105}") + print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} " + f"{'평균보유':>5s} {'평균수익':>6s} {'평균손실':>6s}") + print(f"{'━'*105}") + for label, mode, param in strategies: + taken = results[label] + s = stats(taken) + if not s: + continue + print(f" {label:35s} {s['n']:>3d}건 {s['wr']:>4.0f}% {s['total']:>+12,.0f}원 " + f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}봉 " + f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%") + print(f"{'━'*105}") + + # ── 3봉 피크청산 상세 ──────────────────────────────────────────────────────── + label_3 = '3봉 이익시 피크청산, 아니면 TP/Trail' + print(f"\n[{label_3} 건별 상세]") + print(f"{'─'*100}") + for i, r in enumerate(results[label_3], 1): + krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + sign = '▲' if r['pnl'] > 0 else '▼' + print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} " + f"진입 {r['entry_price']:>10,.0f}원 " + f"고가청산 {r['exit_price']:>10,.0f}원 " + f"{r['pnl']:>+.2f}% ({krw:>+,.0f}원)") + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/archive/tests/sim_recent_db.py b/archive/tests/sim_recent_db.py new file mode 100644 index 0000000..635bf98 --- /dev/null +++ b/archive/tests/sim_recent_db.py @@ -0,0 +1,363 @@ +"""DB 기반 하이브리드 시뮬레이션 (신호=10분봉 / 스탑=1분봉). + +실행 흐름: + 1. pyupbit에서 10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert + 2. BACKTEST_OHLCV에서 데이터 로드 + 3. 하이브리드 시뮬 실행 + - 10분봉 타임인덱스로 신호 감지 / 진입 + - 각 10분봉 구간 내 1분봉으로 트레일링스탑 체크 + 4. 결과 출력 +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import time +import pyupbit +import pandas as pd +import numpy as np +import oracledb + +# ── Oracle 연결 ───────────────────────────────────────────────────────────── +def _get_conn(): + kwargs = dict( + user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"], + ) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +# ── 전략 파라미터 ──────────────────────────────────────────────────────────── +LOCAL_VOL_N = 28 +QUIET_N = 12 +QUIET_PCT = 2.0 +THRESH = 4.8 +SIGNAL_TO = 48 +VOL_THRESH = 6.0 +FNG = 14 +ATR_N = 28 +ATR_MULT = 1.5 +ATR_MIN = 0.010 +ATR_MAX = 0.020 +TS_N = 48 +TIME_STOP_PCT = 3.0 +BUDGET = 15_000_000 +MAX_POS = 3 +FEE = 0.0005 +PER_POS = BUDGET // MAX_POS + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] + +SIM_START = '2026-03-03 00:00:00' +SIM_END = '2026-03-04 13:30:00' + + +# ── BACKTEST_OHLCV 공통 함수 ───────────────────────────────────────────────── + +def upsert_ohlcv(conn, ticker: str, interval_cd: str, df: pd.DataFrame) -> int: + """DataFrame을 BACKTEST_OHLCV에 저장 (기존 TS 스킵). 반환: 신규 삽입 건수.""" + cur = conn.cursor() + min_ts = df.index.min().to_pydatetime() + cur.execute( + "SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd=:iv AND ts >= :since", + {"t": ticker, "iv": interval_cd, "since": min_ts} + ) + existing = {r[0] for r in cur.fetchall()} + + new_rows = [ + (ticker, interval_cd, ts_idx.to_pydatetime(), + float(row["open"]), float(row["high"]), float(row["low"]), + float(row["close"]), float(row["volume"])) + for ts_idx, row in df.iterrows() + if ts_idx.to_pydatetime() not in existing + ] + if not new_rows: + return 0 + + cur.executemany( + "INSERT INTO backtest_ohlcv (ticker, interval_cd, ts, open_p, high_p, low_p, close_p, volume_p) " + "VALUES (:1, :2, :3, :4, :5, :6, :7, :8)", + new_rows + ) + conn.commit() + return len(new_rows) + + +def load_from_db(conn, ticker: str, interval_cd: str, since: str): + """BACKTEST_OHLCV에서 ticker의 OHLCV 로드.""" + cur = conn.cursor() + cur.execute( + "SELECT ts, open_p, high_p, low_p, close_p, volume_p " + "FROM backtest_ohlcv " + "WHERE ticker=:ticker AND interval_cd=:iv " + "AND ts >= TO_TIMESTAMP(:since, 'YYYY-MM-DD HH24:MI:SS') " + "ORDER BY ts", + {"ticker": ticker, "iv": interval_cd, "since": since} + ) + rows = cur.fetchall() + if not rows: + return None + df = pd.DataFrame(rows, columns=["ts","open","high","low","close","volume"]) + df["ts"] = pd.to_datetime(df["ts"]) + df.set_index("ts", inplace=True) + return df.astype(float) + + +# ── 1단계: DB 업데이트 ─────────────────────────────────────────────────────── + +def refresh_db(conn) -> None: + """10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert.""" + print("BACKTEST_OHLCV 업데이트 중...", flush=True) + total10, total1 = 0, 0 + for tk in TICKERS: + try: + # 10분봉 + df10 = pyupbit.get_ohlcv(tk, interval='minute10', count=300) + if df10 is not None and len(df10) >= 10: + total10 += upsert_ohlcv(conn, tk, 'minute10', df10) + time.sleep(0.12) + + # 1분봉 (2일치 ≈ 2880봉, 내부 자동 페이지네이션) + df1 = pyupbit.get_ohlcv(tk, interval='minute1', count=2880) + if df1 is not None and len(df1) >= 60: + total1 += upsert_ohlcv(conn, tk, 'minute1', df1) + time.sleep(0.15) + + print(f" {tk}: 10m={len(df10) if df10 is not None else 0}봉 / " + f"1m={len(df1) if df1 is not None else 0}봉", flush=True) + except Exception as e: + print(f" {tk} 오류: {e}") + + print(f"DB 업데이트 완료: 10분봉 신규 {total10}행 / 1분봉 신규 {total1}행\n", flush=True) + + +# ── ATR 계산 ──────────────────────────────────────────────────────────────── + +def calc_atr(df, i, n=28): + start = max(0, i - n) + sub = df.iloc[start:i] + if len(sub) < 5: + return ATR_MIN + hi = sub['high'].values + lo = sub['low'].values + cl = sub['close'].values + tr = [max(hi[j]-lo[j], abs(hi[j]-cl[j-1]) if j>0 else 0, + abs(lo[j]-cl[j-1]) if j>0 else 0) for j in range(len(sub))] + atr_pct = np.mean(tr) / cl[-1] if cl[-1] > 0 else ATR_MIN + return max(ATR_MIN, min(ATR_MAX, atr_pct * ATR_MULT)) + + +# ── 하이브리드 시뮬 ────────────────────────────────────────────────────────── + +def run_sim(raw10: dict, raw1: dict) -> None: + """신호=10분봉 / 스탑=1분봉 하이브리드 시뮬.""" + + # 10분봉 공통 타임인덱스 + all_idx10 = None + for df in raw10.values(): + all_idx10 = df.index if all_idx10 is None else all_idx10.union(df.index) + all_idx10 = all_idx10.sort_values() + mask = (all_idx10 >= SIM_START) & (all_idx10 <= SIM_END) + sim_idx10 = all_idx10[mask] + + if len(sim_idx10) == 0: + print("시뮬 구간 데이터 없음") + return + + print(f"시뮬 구간(10분봉): {sim_idx10[0]} ~ {sim_idx10[-1]} ({len(sim_idx10)}봉)") + # 1분봉 커버리지 확인 + n1m = sum( + len(df[(df.index >= SIM_START) & (df.index <= SIM_END)]) + for df in raw1.values() if len(raw1) > 0 + ) + print(f"1분봉 총 {n1m}봉 (스탑 체크용)\n") + + positions = {} # ticker → {buy_price, peak, entry_i, invested, atr_stop} + signals = {} # ticker → {price, vol_r, sig_i} + trades = [] + + for i, ts10 in enumerate(sim_idx10): + ts10_prev = sim_idx10[i - 1] if i > 0 else ts10 + + # ── 1) 1분봉으로 스탑 체크 ──────────────────────────────────────── + for tk in list(positions.keys()): + pos = positions[tk] + # 진입 캔들 종료 전엔 체크 금지 + # 10분봉 ts=X 는 [X, X+10min) 구간이므로 실제 진입은 X+9:59 + # → X+10min 이후 1분봉부터 체크 + entry_candle_end = sim_idx10[pos['entry_i']] + pd.Timedelta(minutes=10) + + if tk not in raw1: + # 1분봉 없으면 10분봉 종가로 fallback (단, 진입 다음 봉부터) + if ts10 <= sim_idx10[pos['entry_i']]: + continue + if tk not in raw10 or ts10 not in raw10[tk].index: + continue + current = float(raw10[tk].loc[ts10, 'close']) + _check_stop(pos, tk, current, ts10, i, sim_idx10, trades, positions) + continue + + df1 = raw1[tk] + # 진입 캔들 종료(entry_candle_end) 이후 + 현재 10분봉 구간 이내 + mask1 = (df1.index >= entry_candle_end) & (df1.index > ts10_prev) & (df1.index <= ts10) + sub1 = df1[mask1] + + for ts1m, row1m in sub1.iterrows(): + current = float(row1m['close']) + if _check_stop(pos, tk, current, ts1m, i, sim_idx10, trades, positions): + break # 이미 청산됨 + + # ── 2) 신호 만료 ──────────────────────────────────────────────── + for tk in list(signals.keys()): + if i - signals[tk]['sig_i'] > SIGNAL_TO: + del signals[tk] + + # ── 3) 신호 감지 + 진입 (10분봉 기준) ────────────────────────── + for tk in TICKERS: + if tk in positions: continue + if len(positions) >= MAX_POS: break + if tk not in raw10: continue + df10 = raw10[tk] + if ts10 not in df10.index: continue + loc = df10.index.get_loc(ts10) + if loc < LOCAL_VOL_N + QUIET_N + 2: continue + + vol_prev = float(df10['volume'].iloc[loc - 1]) + vol_avg = float(df10['volume'].iloc[loc - LOCAL_VOL_N - 1:loc - 1].mean()) + vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0 + current = float(df10['close'].iloc[loc]) + close_qn = float(df10['close'].iloc[loc - QUIET_N]) + chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0 + + if vol_r >= VOL_THRESH and chg < QUIET_PCT: + if tk not in signals or vol_r > signals[tk]['vol_r']: + signals[tk] = {'price': current, 'vol_r': vol_r, 'sig_i': i} + + if tk in signals: + sig_p = signals[tk]['price'] + move = (current - sig_p) / sig_p * 100 + if move >= THRESH: + atr_stop = calc_atr(df10, loc, ATR_N) + positions[tk] = { + 'buy_price': current, 'peak': current, + 'entry_i': i, 'invested': PER_POS, + 'atr_stop': atr_stop, + } + del signals[tk] + + # ── 미청산 강제 청산 ───────────────────────────────────────────────────── + last_ts10 = sim_idx10[-1] + for tk, pos in list(positions.items()): + if tk not in raw10: continue + df10 = raw10[tk] + current = float(df10.loc[last_ts10, 'close']) if last_ts10 in df10.index else pos['buy_price'] + pnl = (current - pos['buy_price']) / pos['buy_price'] * 100 + fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE + krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee + trades.append({ + 'date': last_ts10.date(), 'ticker': tk, + 'buy_price': pos['buy_price'], 'sell_price': current, + 'pnl_pct': pnl, 'krw': krw, 'reason': '미청산(현재가)', + 'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': last_ts10, + }) + + # ── 결과 출력 ──────────────────────────────────────────────────────────── + if not trades: + print("거래 없음") + return + + import collections + by_date = collections.defaultdict(list) + for t in trades: + by_date[t['date']].append(t) + + total_krw, total_wins = 0, 0 + for date in sorted(by_date.keys()): + day_trades = by_date[date] + day_krw = sum(t['krw'] for t in day_trades) + day_wins = sum(1 for t in day_trades if t['pnl_pct'] > 0) + total_krw += day_krw + total_wins += day_wins + print(f"\n{'='*62}") + print(f"【{date}】 {len(day_trades)}건 | 승률={day_wins/len(day_trades)*100:.0f}% | 일손익={day_krw:+,.0f}원") + print(f"{'='*62}") + for t in sorted(day_trades, key=lambda x: x['entry_ts']): + e = '✅' if t['pnl_pct'] > 0 else '❌' + print(f" {e} {t['ticker']:12s} " + f"매수={t['buy_price']:,.0f} @ {str(t['entry_ts'])[5:16]} → " + f"매도={t['sell_price']:,.0f} @ {str(t['exit_ts'])[5:16]} " + f"| {t['pnl_pct']:+.1f}% ({t['krw']:+,.0f}원) [{t['reason']}]") + + print(f"\n{'='*62}") + print(f"【2일 합계】 {len(trades)}건 | " + f"승률={total_wins/len(trades)*100:.0f}% | 총손익={total_krw:+,.0f}원") + print(f" [1분봉 스탑 시뮬] VOL≥{VOL_THRESH}x + 횡보<{QUIET_PCT}% → +{THRESH}% 진입 (F&G={FNG})") + + +def _check_stop(pos, tk, current, ts, i, sim_idx10, trades, positions) -> bool: + """스탑 체크. 청산 시 True 반환.""" + if current > pos['peak']: + pos['peak'] = current + age = i - pos['entry_i'] + pnl = (current - pos['buy_price']) / pos['buy_price'] * 100 + peak_drop = (pos['peak'] - current) / pos['peak'] * 100 + atr_stop = pos['atr_stop'] + reason = None + if peak_drop >= atr_stop * 100: + reason = f"트레일링스탑({atr_stop*100:.1f}%)" + elif age >= TS_N and pnl < TIME_STOP_PCT: + reason = f"타임스탑(+{pnl:.1f}%<{TIME_STOP_PCT}%)" + if reason: + fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE + krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee + trades.append({ + 'date': ts.date() if hasattr(ts, 'date') else ts, + 'ticker': tk, + 'buy_price': pos['buy_price'], 'sell_price': current, + 'pnl_pct': pnl, 'krw': krw, 'reason': reason, + 'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': ts, + }) + del positions[tk] + return True + return False + + +# ── main ───────────────────────────────────────────────────────────────────── + +conn = _get_conn() + +# 1) DB 업데이트 +refresh_db(conn) + +# 2) DB에서 데이터 로드 +print("DB에서 OHLCV 로드 중...", flush=True) +LOAD_SINCE = '2026-03-01 00:00:00' +raw10, raw1 = {}, {} +for tk in TICKERS: + df10 = load_from_db(conn, tk, 'minute10', LOAD_SINCE) + if df10 is not None and len(df10) > LOCAL_VOL_N + QUIET_N: + raw10[tk] = df10 + + df1 = load_from_db(conn, tk, 'minute1', LOAD_SINCE) + if df1 is not None and len(df1) > 60: + raw1[tk] = df1 + +print(f"로드 완료: 10분봉 {len(raw10)}종목 / 1분봉 {len(raw1)}종목\n") +conn.close() + +# 3) 시뮬 실행 +print("="*62) +print(f"하이브리드 시뮬 (신호=10분봉 / 스탑=1분봉) | 2026-03-03 ~ 03-04") +print("="*62) +run_sim(raw10, raw1) diff --git a/tests/sim_regime_1y.py b/archive/tests/sim_regime_1y.py similarity index 100% rename from tests/sim_regime_1y.py rename to archive/tests/sim_regime_1y.py diff --git a/tests/sim_regime_sweep.py b/archive/tests/sim_regime_sweep.py similarity index 100% rename from tests/sim_regime_sweep.py rename to archive/tests/sim_regime_sweep.py diff --git a/archive/tests/sim_tp_sl.py b/archive/tests/sim_tp_sl.py new file mode 100644 index 0000000..a72d8d9 --- /dev/null +++ b/archive/tests/sim_tp_sl.py @@ -0,0 +1,244 @@ +"""TP + 고정 손절 비율 비교 시뮬 (30일).""" +import sys, os +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) +import oracledb + +LOOKBACK_DAYS = 30 +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +VOL_MIN = 8.0 +ATR_MULT = 1.0 +ATR_MIN_R = 0.030 +ATR_MAX_R = 0.050 +MAX_TRAIL_BARS = 240 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS +FEE = 0.0005 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + return oracledb.connect(**kwargs) + + +SIGNAL_SQL = f""" +WITH base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1, + LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1, + LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2, + LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2, + LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3, + LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3, + GREATEST(high_p-low_p, + ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr + FROM backtest_ohlcv + WHERE interval_cd='minute1' + AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +ind AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, + volume_p / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0, + LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1, + LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2, + LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER ( + PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3, + pc1,po1,pc2,po2,pc3,po3, + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw + FROM base +) +SELECT ticker, ts, vr0,vr1,vr2,vr3, atr_raw +FROM ind +WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS') + AND vr0 >= {VOL_MIN} + AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3 + AND close_p>pc1 AND pc1>pc2 AND pc2>pc3 + AND vr0>vr1 AND vr1>vr2 AND vr2>vr3 +ORDER BY ticker, ts +""" + + +def sim_trail(bars, ep, ar): + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일손절', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_tp_trail(bars, ep, ar, tp_r): + stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R + tp = ep * (1 + tp_r) + peak = ep + for i, (ts, cp, hp, lp) in enumerate(bars): + if hp >= tp: + return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp, + pnl=tp_r * 100, held=i + 1) + peak = max(peak, cp) + if (peak - cp) / peak >= stop: + return dict(status='트레일손절', exit_ts=ts, exit_price=cp, + pnl=(cp - ep) / ep * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def sim_tp_sl(bars, ep, tp_r, sl_r): + """고정 익절 + 고정 손절. 같은 봉에서 둘 다 터치하면 손절 우선.""" + tp = ep * (1 + tp_r) + sl = ep * (1 - sl_r) + for i, (ts, cp, hp, lp) in enumerate(bars): + hit_sl = lp <= sl + hit_tp = hp >= tp + if hit_sl: + return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl, + pnl=-sl_r * 100, held=i + 1) + if hit_tp: + return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp, + pnl=tp_r * 100, held=i + 1) + lts, lcp = bars[-1][0], bars[-1][1] + return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중', + exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars)) + + +def pos_limit(sim): + opens, taken, skipped = [], [], [] + for r in sim: + opens = [ex for ex in opens if ex > r['entry_ts']] + if len(opens) < MAX_POS: + opens.append(r['exit_ts']) + taken.append(r) + else: + skipped.append(r) + return taken, skipped + + +def main(): + now = datetime.now() + check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00') + warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00') + + conn = get_conn() + cur = conn.cursor() + cur.arraysize = 10000 + + cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since}) + rows = cur.fetchall() + + signals = [] + for row in rows: + ticker, sig_ts, vr0, vr1, vr2, vr3, atr_raw = row + cur.execute( + """SELECT close_p, ts FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE + ORDER BY ts FETCH FIRST 1 ROWS ONLY""", + {'t': ticker, 'sig': sig_ts} + ) + er = cur.fetchone() + if not er: + continue + ep, ets = float(er[0]), er[1] + cur.execute( + """SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv + WHERE ticker=:t AND interval_cd='minute1' + AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""", + {'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1} + ) + bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()] + if not bars: + continue + signals.append({ + 'ticker': ticker, 'entry_ts': ets, 'entry_price': ep, + 'atr_raw': float(atr_raw) if atr_raw else 0.0, + 'bars': bars, + }) + + signals.sort(key=lambda x: x['entry_ts']) + print(f"=== TP / 손절 비율 비교 시뮬 ===") + print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} | 시그널 {len(signals)}건\n") + + strategies = [ + ('A. Trail Stop [3~5%]', 'trail', None, None ), + ('B. TP 3% + Trail Stop', 'tp_trail', 0.03, None ), + ('C. TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ), + ('D. TP 2% + SL 1.5%', 'tp_sl', 0.02, 0.015), + ('E. TP 2% + SL 1%', 'tp_sl', 0.02, 0.010), + ('F. TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ), + ('G. TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ), + ] + + print(f"{'━'*105}") + print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}") + print(f"{'━'*105}") + + all_results = {} + for label, mode, tp_r, sl_r in strategies: + sim = [] + for s in signals: + if mode == 'trail': + r = sim_trail(s['bars'], s['entry_price'], s['atr_raw']) + elif mode == 'tp_trail': + r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r) + else: + r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r) + sim.append({**s, **r}) + taken, _ = pos_limit(sim) + all_results[label] = taken + n = len(taken) + if n == 0: + print(f" {label:32s} 거래없음") + continue + wins = sum(1 for r in taken if r['pnl'] > 0) + losses = sum(1 for r in taken if r['pnl'] < 0) + total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken) + avg_h = sum(r['held'] for r in taken) / n + ret = total / BUDGET * 100 + avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0 + avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1 + rr = avg_w / avg_l + print(f" {label:32s} {n:>3d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 " + f"{ret:>+5.2f}% {avg_h:>5.0f}봉 {rr:>4.1f}:1") + print(f"{'━'*105}") + + # ── C 상세 (TP2%+SL2%) ──────────────────────────────────────────────────── + label_c = 'C. TP 2% + SL 2%' + print(f"\n[{label_c} 건별 상세]") + print(f"{'─'*100}") + for i, r in enumerate(all_results[label_c], 1): + krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 + sign = '▲' if r['pnl'] > 0 else '▼' + print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} " + f"→ {r['status']:14s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)") + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/tests/sim_vol_override.py b/archive/tests/sim_vol_override.py similarity index 100% rename from tests/sim_vol_override.py rename to archive/tests/sim_vol_override.py diff --git a/tests/stop_sweep.py b/archive/tests/stop_sweep.py similarity index 100% rename from tests/stop_sweep.py rename to archive/tests/stop_sweep.py diff --git a/archive/tests/sweep_1min.py b/archive/tests/sweep_1min.py new file mode 100644 index 0000000..ddb9ae7 --- /dev/null +++ b/archive/tests/sweep_1min.py @@ -0,0 +1,310 @@ +"""1분봉 연속 상승 vol spike 전략 파라미터 스윕. + +시그널 조건: + 봉[n-1]: vol_ratio >= VOL_MIN, 양봉 (close > open) + 봉[n] : vol_ratio >= VOL_MIN, 양봉, close > 봉[n-1].close (연속 상승) +진입: 봉[n] 다음 봉 (봉[n+1]) close에서 즉시 +추적: 1분봉 trail stop (ATR 기반) + time stop +DB 계산: 지표·시그널·진입·running_peak 모두 Oracle SQL / 월별 배치 +""" +import sys, os, itertools +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import pandas as pd +import oracledb +import time as _time + +# ── 고정 파라미터 ───────────────────────────────────────────────────────────── +VOL_LOOKBACK = 61 # vol_ratio 기준: 이전 60봉 평균 +ATR_LOOKBACK = 28 # ATR 계산 봉 수 +TS_N = 240 # 타임스탑 봉수 (240분 = 4시간) +TIME_STOP_PCT = 0.0 / 100 +FEE = 0.0005 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS + +# ── 스윕 파라미터 ───────────────────────────────────────────────────────────── +SWEEP = { + "VOL": [15.0, 20.0, 25.0, 30.0, 40.0, 50.0], # 시그널 거래량 배율 (상위 범위 탐색) + "ATR_MULT": [1.5, 2.0, 2.5, 3.0], + "ATR_MIN": [0.005, 0.010, 0.015], + "ATR_MAX": [0.020, 0.025, 0.030], +} +VOL_MIN = min(SWEEP["VOL"]) # SQL pre-filter (15x 이상만 로드) + +# ── 시뮬 구간 ───────────────────────────────────────────────────────────────── +SIM_START = datetime(2025, 8, 1) +SIM_END = datetime(2026, 3, 4) +WARMUP_MINS = 120 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def _months(start: datetime, end: datetime): + m = start.replace(day=1) + while m < end: + nxt = (m + timedelta(days=32)).replace(day=1) + if nxt > end: + nxt = end + yield m, nxt + m = nxt + + +def _get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +# ── 핵심 SQL ────────────────────────────────────────────────────────────────── +# 연속 2봉 vol spike + 상승 확인 후 다음 봉 즉시 진입 +TRADE_SQL = f""" +WITH +-- 1) 1분봉 + TR + 이전 봉 정보 +base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, + LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close, + LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open, + LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_volume, + GREATEST( + high_p - low_p, + ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)) + ) tr + FROM backtest_ohlcv + WHERE interval_cd = 'minute1' + AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS') + AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +-- 2) 지표: vol_ratio (현재봉), prev_vol_ratio (이전봉), atr_raw +indicators AS ( + SELECT ticker, ts, open_p, close_p, prev_close, prev_open, + -- 현재 봉 vol_ratio + volume_p / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vol_ratio, + -- 이전 봉 vol_ratio (LAG로 한 봉 앞) + prev_volume / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) prev_vol_ratio, + -- ATR + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) + / NULLIF(prev_close, 0) atr_raw + FROM base +), +-- 3) 연속 2봉 조건: +-- 봉[n-1]: prev_vol_ratio >= min_vol, 이전 봉 양봉 +-- 봉[n] : vol_ratio >= min_vol, 양봉, close > prev_close (상승 지속) +signals AS ( + SELECT ticker, ts sig_ts, close_p sig_price, vol_ratio, atr_raw + FROM indicators + WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS') + AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') + -- 현재 봉 조건 + AND vol_ratio >= :min_vol + AND close_p > open_p + -- 이전 봉 조건 + AND prev_vol_ratio >= :min_vol + AND prev_close > prev_open + -- 연속 상승 + AND close_p > prev_close +), +-- 4) 진입: 시그널 다음 1분봉 즉시 +entry_cands AS ( + SELECT + s.ticker, s.sig_ts, s.sig_price, s.vol_ratio, s.atr_raw, + e.ts entry_ts, + e.close_p entry_price, + ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn + FROM signals s + JOIN backtest_ohlcv e + ON e.ticker = s.ticker + AND e.interval_cd = 'minute1' + AND e.ts > s.sig_ts + AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE +), +-- 5) 첫 봉만 +entries AS ( + SELECT ticker, sig_ts, sig_price, vol_ratio, atr_raw, + entry_ts, entry_price + FROM entry_cands WHERE rn = 1 +), +-- 6) 진입 후 TS_N분 1분봉 + 롤링 피크 +post_entry AS ( + SELECT + e.ticker, e.sig_ts, e.entry_ts, e.entry_price, + e.vol_ratio, e.atr_raw, + b.close_p bar_price, + ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n, + MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts + ORDER BY b.ts + ROWS UNBOUNDED PRECEDING) running_peak + FROM entries e + JOIN backtest_ohlcv b + ON b.ticker = e.ticker + AND b.interval_cd = 'minute1' + AND b.ts >= e.entry_ts + AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE +) +SELECT ticker, sig_ts, entry_ts, entry_price, + vol_ratio, atr_raw, + bar_n, bar_price, running_peak +FROM post_entry +WHERE bar_n <= :ts_n + 1 +ORDER BY ticker, entry_ts, bar_n +""" + + +# ── 월별 데이터 로드 ────────────────────────────────────────────────────────── +print(f"연속 2봉 vol spike 전략 (VOL>={VOL_MIN}x, 연속 상승 후 즉시 진입)\n", flush=True) +print("월별 DB 로드...\n", flush=True) + +conn = _get_conn() +cur = conn.cursor() +cur.arraysize = 100_000 + +ENTRIES: dict = {} +t_load = _time.time() + +for m_start, m_end in _months(SIM_START, SIM_END): + load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S') + sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S') + sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S') + + t0 = _time.time() + cur.execute(TRADE_SQL, { + "load_since": load_since, + "sim_start": sim_start, + "sim_end": sim_end, + "min_vol": VOL_MIN, + "ts_n": TS_N, + }) + rows = cur.fetchall() + t1 = _time.time() + + n_new = 0 + for row in rows: + (ticker, sig_ts, entry_ts, entry_price, + vol_ratio, atr_raw, + bar_n, bar_price, running_peak) = row + + key = (ticker, entry_ts) + if key not in ENTRIES: + ENTRIES[key] = { + 'entry_price': float(entry_price), + 'vol_ratio': float(vol_ratio), + 'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'), + 'bars': [], + } + n_new += 1 + ENTRIES[key]['bars'].append((float(bar_price), float(running_peak))) + + print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True) + +conn.close() +print(f"\n총 진입 이벤트: {len(ENTRIES):,}건 | 로드 {_time.time()-t_load:.1f}s\n", flush=True) + + +# ── 출구 탐색 ───────────────────────────────────────────────────────────────── +def find_exit(entry_price: float, atr_stop: float, bars: list) -> float: + for n, (bp, pk) in enumerate(bars): + drop = (pk - bp) / pk if pk > 0 else 0.0 + pnl = (bp - entry_price) / entry_price + if drop >= atr_stop: + return pnl * 100 + if n + 1 >= TS_N and pnl < TIME_STOP_PCT: + return pnl * 100 + return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0 + + +# ── 스윕 ────────────────────────────────────────────────────────────────────── +ENTRY_LIST = list(ENTRIES.values()) +keys = list(SWEEP.keys()) +combos = list(itertools.product(*SWEEP.values())) +print(f"총 {len(combos)}가지 조합 스윕...\n", flush=True) + +t_sweep = _time.time() +results = [] + +for combo in combos: + params = dict(zip(keys, combo)) + if params['ATR_MIN'] >= params['ATR_MAX']: + continue + + vol_thr = params['VOL'] + atr_mult = params['ATR_MULT'] + atr_min = params['ATR_MIN'] + atr_max = params['ATR_MAX'] + + trades = [] + for e in ENTRY_LIST: + if e['vol_ratio'] < vol_thr: + continue + ar = e['atr_raw'] + atr_s = (atr_min if (ar != ar) + else max(atr_min, min(atr_max, ar * atr_mult))) + pnl_pct = find_exit(e['entry_price'], atr_s, e['bars']) + krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2 + trades.append((pnl_pct, krw)) + + if not trades: + results.append({**params, 'trades': 0, 'wins': 0, + 'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0}) + continue + + wins = sum(1 for p, _ in trades if p > 0) + results.append({ + **params, + 'trades': len(trades), + 'wins': wins, + 'win_rate': wins / len(trades) * 100, + 'avg_pnl': sum(p for p, _ in trades) / len(trades), + 'total_krw': sum(k for _, k in trades), + }) + +print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n") + +# ── 결과 출력 ───────────────────────────────────────────────────────────────── +df_r = pd.DataFrame(results) +df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False) + +print("=" * 100) +print(f"{'순위':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} " + f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}") +print("=" * 100) +for rank, (_, row) in enumerate(df_r.head(20).iterrows(), 1): + print(f"{rank:>4} {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} " + f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% " + f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% " + f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원") + +# VOL별 최상위 요약 +print("\n" + "─" * 75) +print(f" {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)") +print("─" * 75) +for vol in SWEEP["VOL"]: + sub = df_r[df_r['VOL'] == vol] + if sub.empty: + continue + best = sub.iloc[0] + print(f" {vol:>4.0f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% " + f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 " + f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)") diff --git a/archive/tests/sweep_nbar.py b/archive/tests/sweep_nbar.py new file mode 100644 index 0000000..6f5c0a4 --- /dev/null +++ b/archive/tests/sweep_nbar.py @@ -0,0 +1,357 @@ +"""1분봉 N봉 연속 상승 vol spike 전략 파라미터 스윕. + +N=2/3/4 연속 조건 (양봉 + 상승 + vol spike) 모두 Oracle SQL에서 처리. +Python은 ATR 파라미터 + VOL 임계값 스윕만 담당. + +시그널 조건 (N_BARS=N): + 봉[n-(N-1)]~봉[n]: 모두 vol_ratio >= VOL_MIN, 양봉, 연속 상승 +진입: 봉[n+1] close 즉시 +추적: 1분봉 trail stop (ATR) + time stop / 월별 배치 +""" +import sys, os, itertools +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import pandas as pd +import oracledb +import time as _time + +# ── 고정 파라미터 ───────────────────────────────────────────────────────────── +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +TS_N = 240 +TIME_STOP_PCT = 0.0 / 100 +FEE = 0.0005 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS + +# ── 스윕 파라미터 ───────────────────────────────────────────────────────────── +VOL_SWEEP = [1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 25.0] +ATR_SWEEP = { + "ATR_MULT": [1.5, 2.0, 2.5, 3.0], + "ATR_MIN": [0.005, 0.010, 0.015], + "ATR_MAX": [0.020, 0.025, 0.030], +} +N_BARS_LIST = [2, 3, 4] +VOL_MIN = min(VOL_SWEEP) # SQL pre-filter (모든 봉 >= 1.5x) (모든 봉에 적용) + +# ── 시뮬 구간 ───────────────────────────────────────────────────────────────── +SIM_START = datetime(2025, 8, 1) +SIM_END = datetime(2026, 3, 4) +WARMUP_MINS = 120 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def _months(start: datetime, end: datetime): + m = start.replace(day=1) + while m < end: + nxt = (m + timedelta(days=32)).replace(day=1) + if nxt > end: + nxt = end + yield m, nxt + m = nxt + + +def _get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +# ── N별 SQL 생성 ────────────────────────────────────────────────────────────── +# 공통 CTE: base(TR + LAG N개), indicators(vol_ratio N개 + ATR) +# signals: N봉 연속 조건 모두 SQL에서 처리 +# vol_ratio 컬럼은 Python VOL 스윕용으로 모두 반환 + +def build_sql(n: int) -> str: + """n봉 연속 조건을 SQL로 구현. 반환 컬럼에 vol_ratio 0~n-1 포함.""" + + # LAG 컬럼 정의 (base CTE) + lag_cols = "\n".join( + f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n" + f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n" + f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i}," + for i in range(1, n) + ) + + # indicators CTE: vol_ratio 0 ~ n-1 + vr_cols = [] + vr_cols.append(f""" volume_p / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vol_ratio_0,""") + for i in range(1, n): + vr_cols.append(f""" prev_vol_{i} / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vol_ratio_{i},""") + vr_cols_str = "\n".join(vr_cols) + + # pass-through LAG 컬럼 from base → indicators + lag_passthrough = "\n".join( + f" prev_close_{i}, prev_open_{i}," + for i in range(1, n) + ) + + # signals WHERE 조건: 현재봉 + 이전 n-1봉 all 양봉 + 연속 상승 + vol + cond_lines = [ + " AND vol_ratio_0 >= :min_vol", + " AND close_p > open_p", # 현재봉 양봉 + ] + for i in range(1, n): + cond_lines.append(f" AND vol_ratio_{i} >= :min_vol") + cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 양봉 + if i == 1: + cond_lines.append(f" AND close_p > prev_close_{i}") # 현재봉 > 1봉전 + else: + cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 상승 연속 + cond_str = "\n".join(cond_lines) + + # SELECT: vol_ratio 0~n-1 반환 (Python VOL 스윕용) + vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n)) + + return f""" +WITH +base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, +{lag_cols} + GREATEST( + high_p - low_p, + ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)) + ) tr + FROM backtest_ohlcv + WHERE interval_cd = 'minute1' + AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS') + AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +indicators AS ( + SELECT ticker, ts, open_p, close_p, +{lag_passthrough} +{vr_cols_str} + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) + / NULLIF(prev_close_1, 0) atr_raw + FROM base +), +signals AS ( + SELECT ticker, ts sig_ts, close_p sig_price, + {vr_select}, atr_raw + FROM indicators + WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS') + AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') +{cond_str} +), +entry_cands AS ( + SELECT s.ticker, s.sig_ts, {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw, + e.ts entry_ts, e.close_p entry_price, + ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn + FROM signals s + JOIN backtest_ohlcv e + ON e.ticker = s.ticker + AND e.interval_cd = 'minute1' + AND e.ts > s.sig_ts + AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE +), +entries AS ( + SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price + FROM entry_cands WHERE rn = 1 +), +post_entry AS ( + SELECT + e.ticker, e.sig_ts, e.entry_ts, e.entry_price, + {vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw, + b.close_p bar_price, + ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n, + MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts + ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak + FROM entries e + JOIN backtest_ohlcv b + ON b.ticker = e.ticker + AND b.interval_cd = 'minute1' + AND b.ts >= e.entry_ts + AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE +) +SELECT ticker, sig_ts, entry_ts, entry_price, + {vr_select}, atr_raw, + bar_n, bar_price, running_peak +FROM post_entry +WHERE bar_n <= :ts_n + 1 +ORDER BY ticker, entry_ts, bar_n +""" + + +# ── 월별 데이터 로드 ────────────────────────────────────────────────────────── +print(f"N봉 연속 vol spike 전략 (VOL>={VOL_MIN}x, N={N_BARS_LIST})\n", flush=True) + +conn = _get_conn() +cur = conn.cursor() +cur.arraysize = 100_000 + +# N별로 별도 딕셔너리 +ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST} +t_load = _time.time() + +for n in N_BARS_LIST: + sql = build_sql(n) + print(f"── {n}봉 로드 중... ──────────────────────────", flush=True) + n_total = 0 + + for m_start, m_end in _months(SIM_START, SIM_END): + load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S') + sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S') + sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S') + + t0 = _time.time() + cur.execute(sql, { + "load_since": load_since, + "sim_start": sim_start, + "sim_end": sim_end, + "min_vol": VOL_MIN, + "ts_n": TS_N, + }) + rows = cur.fetchall() + t1 = _time.time() + + n_new = 0 + for row in rows: + # ticker, sig_ts, entry_ts, entry_price, vr0..vr(n-1), atr_raw, bar_n, bar_price, peak + ticker = row[0] + entry_ts = row[2] + entry_price= float(row[3]) + vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)] + atr_raw = row[4 + n] + bar_price = float(row[4 + n + 2]) # bar_n is at 4+n+1 + running_peak = float(row[4 + n + 3]) + + key = (ticker, entry_ts) + if key not in ALL_ENTRIES[n]: + ALL_ENTRIES[n][key] = { + 'entry_price': entry_price, + 'vr': vr_vals, + 'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'), + 'bars': [], + } + n_new += 1 + ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak)) + + n_total += n_new + print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True) + + print(f" → {n}봉 합계: {n_total}건\n", flush=True) + +conn.close() +print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True) + + +# ── 출구 탐색 ───────────────────────────────────────────────────────────────── +def find_exit(entry_price: float, atr_stop: float, bars: list) -> float: + for i, (bp, pk) in enumerate(bars): + drop = (pk - bp) / pk if pk > 0 else 0.0 + pnl = (bp - entry_price) / entry_price + if drop >= atr_stop: + return pnl * 100 + if i + 1 >= TS_N and pnl < TIME_STOP_PCT: + return pnl * 100 + return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0 + + +# ── 스윕 ────────────────────────────────────────────────────────────────────── +atr_keys = list(ATR_SWEEP.keys()) +atr_combos = list(itertools.product(*ATR_SWEEP.values())) +total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos) +print(f"총 {total_combos}가지 조합 스윕...\n", flush=True) + +t_sweep = _time.time() +results = [] + +for n in N_BARS_LIST: + entry_list = list(ALL_ENTRIES[n].values()) + for vol in VOL_SWEEP: + for atr_combo in atr_combos: + atr_params = dict(zip(atr_keys, atr_combo)) + if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']: + continue + + atr_mult = atr_params['ATR_MULT'] + atr_min = atr_params['ATR_MIN'] + atr_max = atr_params['ATR_MAX'] + + trades = [] + for e in entry_list: + # Python: vol 임계값 최종 필터 (모든 봉 vol >= vol) + if min(e['vr']) < vol: + continue + + ar = e['atr_raw'] + atr_s = (atr_min if (ar != ar) + else max(atr_min, min(atr_max, ar * atr_mult))) + pnl_pct = find_exit(e['entry_price'], atr_s, e['bars']) + krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2 + trades.append((pnl_pct, krw)) + + if not trades: + results.append({'N_BARS': n, 'VOL': vol, **atr_params, + 'trades': 0, 'wins': 0, + 'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0}) + continue + + wins = sum(1 for p, _ in trades if p > 0) + results.append({ + 'N_BARS': n, + 'VOL': vol, + **atr_params, + 'trades': len(trades), + 'wins': wins, + 'win_rate': wins / len(trades) * 100, + 'avg_pnl': sum(p for p, _ in trades) / len(trades), + 'total_krw': sum(k for _, k in trades), + }) + +print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n") + +# ── 결과 출력 ───────────────────────────────────────────────────────────────── +df_r = pd.DataFrame(results) +df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False) + +print("=" * 105) +print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} " + f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}") +print("=" * 105) +for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1): + print(f"{rank:>4} {int(row['N_BARS']):>2}봉 {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} " + f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% " + f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% " + f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원") + +# N봉 × VOL별 최상위 요약 +print("\n" + "─" * 85) +print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)") +print("─" * 85) +for n in N_BARS_LIST: + for vol in VOL_SWEEP: + sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)] + if sub.empty: + continue + best = sub.iloc[0] + if best['trades'] == 0: + continue + print(f" {int(n):>2}봉 {vol:>4.0f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% " + f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 " + f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)") + print() diff --git a/archive/tests/sweep_volaccel.py b/archive/tests/sweep_volaccel.py new file mode 100644 index 0000000..266cdca --- /dev/null +++ b/archive/tests/sweep_volaccel.py @@ -0,0 +1,355 @@ +"""1분봉 볼륨 가속 전략 파라미터 스윕. + +시그널 조건: N봉 연속으로 가격 AND 거래량이 함께 증가 + 봉[n-(N-1)] < 봉[n-(N-2)] < ... < 봉[n] + - 각 봉: 양봉 (close > open) + - 가격 연속 상승: close[k] > close[k-1] + - 볼륨 연속 증가: vol_ratio[k] > vol_ratio[k-1] + - 현재봉(가장 강한 봉) vol_ratio >= VOL_MIN (pre-filter) + +진입: 봉[n+1] close 즉시 +추적: 1분봉 trail stop (ATR) + time stop / 월별 배치 +""" +import sys, os, itertools +from datetime import datetime, timedelta +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import pandas as pd +import oracledb +import time as _time + +# ── 고정 파라미터 ───────────────────────────────────────────────────────────── +VOL_LOOKBACK = 61 +ATR_LOOKBACK = 28 +TS_N = 240 +TIME_STOP_PCT = 0.0 / 100 +FEE = 0.0005 +BUDGET = 15_000_000 +MAX_POS = 3 +PER_POS = BUDGET // MAX_POS + +# ── 스윕 파라미터 ───────────────────────────────────────────────────────────── +# 현재봉(가장 강한 봉)의 vol_ratio 최솟값 — 가속의 "끝점" 기준 +VOL_SWEEP = [2.0, 3.0, 4.0, 5.0, 8.0, 10.0, 15.0] +ATR_SWEEP = { + "ATR_MULT": [1.5, 2.0, 2.5, 3.0], + "ATR_MIN": [0.005, 0.010, 0.015], + "ATR_MAX": [0.020, 0.025, 0.030], +} +N_BARS_LIST = [2, 3, 4] +VOL_MIN = min(VOL_SWEEP) # SQL pre-filter + +# ── 시뮬 구간 ───────────────────────────────────────────────────────────────── +SIM_START = datetime(2025, 8, 1) +SIM_END = datetime(2026, 3, 4) +WARMUP_MINS = 120 + +TICKERS = [ + 'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', + 'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', + 'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', + 'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', +] +_TK = ",".join(f"'{t}'" for t in TICKERS) + + +def _months(start: datetime, end: datetime): + m = start.replace(day=1) + while m < end: + nxt = (m + timedelta(days=32)).replace(day=1) + if nxt > end: + nxt = end + yield m, nxt + m = nxt + + +def _get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + wallet = os.environ.get("ORACLE_WALLET") + if wallet: + kwargs["config_dir"] = wallet + return oracledb.connect(**kwargs) + + +# ── N별 SQL 생성 ────────────────────────────────────────────────────────────── +# 조건: 가격 연속 상승 + 볼륨 연속 증가 (+ 양봉) +# vol_ratio_0 > vol_ratio_1 > ... > vol_ratio_(n-1) +# close_0 > close_1 > ... > close_(n-1) +# 모두 Oracle SQL에서 처리 + +def build_sql(n: int) -> str: + lag_cols = "\n".join( + f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n" + f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n" + f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i}," + for i in range(1, n) + ) + + vr_cols = [] + vr_cols.append(f""" volume_p / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vol_ratio_0,""") + for i in range(1, n): + vr_cols.append(f""" prev_vol_{i} / NULLIF( + AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0 + ) vol_ratio_{i},""") + vr_cols_str = "\n".join(vr_cols) + + lag_passthrough = "\n".join( + f" prev_close_{i}, prev_open_{i}," + for i in range(1, n) + ) + + # 조건: 양봉 + 가격 연속 상승 + 볼륨 연속 증가 + cond_lines = [ + " AND vol_ratio_0 >= :min_vol", # pre-filter: 현재봉(최강봉) + " AND close_p > open_p", # 현재봉 양봉 + ] + for i in range(1, n): + cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 이전봉 양봉 + if i == 1: + cond_lines.append(f" AND close_p > prev_close_{i}") # 가격 상승 + cond_lines.append(f" AND vol_ratio_0 > vol_ratio_{i}") # 볼륨 증가 + else: + cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 가격 상승 + cond_lines.append(f" AND vol_ratio_{i-1} > vol_ratio_{i}") # 볼륨 증가 + cond_str = "\n".join(cond_lines) + + vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n)) + + return f""" +WITH +base AS ( + SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p, +{lag_cols} + GREATEST( + high_p - low_p, + ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)), + ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)) + ) tr + FROM backtest_ohlcv + WHERE interval_cd = 'minute1' + AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS') + AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') + AND ticker IN ({_TK}) +), +indicators AS ( + SELECT ticker, ts, open_p, close_p, +{lag_passthrough} +{vr_cols_str} + AVG(tr) OVER (PARTITION BY ticker ORDER BY ts + ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) + / NULLIF(prev_close_1, 0) atr_raw + FROM base +), +signals AS ( + SELECT ticker, ts sig_ts, close_p sig_price, + {vr_select}, atr_raw + FROM indicators + WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS') + AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS') +{cond_str} +), +entry_cands AS ( + SELECT s.ticker, s.sig_ts, + {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw, + e.ts entry_ts, e.close_p entry_price, + ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn + FROM signals s + JOIN backtest_ohlcv e + ON e.ticker = s.ticker + AND e.interval_cd = 'minute1' + AND e.ts > s.sig_ts + AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE +), +entries AS ( + SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price + FROM entry_cands WHERE rn = 1 +), +post_entry AS ( + SELECT + e.ticker, e.sig_ts, e.entry_ts, e.entry_price, + {vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw, + b.close_p bar_price, + ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n, + MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts + ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak + FROM entries e + JOIN backtest_ohlcv b + ON b.ticker = e.ticker + AND b.interval_cd = 'minute1' + AND b.ts >= e.entry_ts + AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE +) +SELECT ticker, sig_ts, entry_ts, entry_price, + {vr_select}, atr_raw, + bar_n, bar_price, running_peak +FROM post_entry +WHERE bar_n <= :ts_n + 1 +ORDER BY ticker, entry_ts, bar_n +""" + + +# ── 월별 데이터 로드 ────────────────────────────────────────────────────────── +print(f"볼륨 가속 전략 (현재봉 VOL>={VOL_MIN}x, N={N_BARS_LIST}, 가격·볼륨 동시 가속)\n", flush=True) + +conn = _get_conn() +cur = conn.cursor() +cur.arraysize = 100_000 + +ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST} +t_load = _time.time() + +for n in N_BARS_LIST: + sql = build_sql(n) + print(f"── {n}봉 로드 중... ──────────────────────────", flush=True) + n_total = 0 + + for m_start, m_end in _months(SIM_START, SIM_END): + load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S') + sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S') + sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S') + + t0 = _time.time() + cur.execute(sql, { + "load_since": load_since, + "sim_start": sim_start, + "sim_end": sim_end, + "min_vol": VOL_MIN, + "ts_n": TS_N, + }) + rows = cur.fetchall() + t1 = _time.time() + + n_new = 0 + for row in rows: + ticker = row[0] + entry_ts = row[2] + entry_price = float(row[3]) + vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)] + atr_raw = row[4 + n] + bar_price = float(row[4 + n + 2]) + running_peak= float(row[4 + n + 3]) + + key = (ticker, entry_ts) + if key not in ALL_ENTRIES[n]: + ALL_ENTRIES[n][key] = { + 'entry_price': entry_price, + 'vr0': vr_vals[0], # 현재봉 vol (가장 강한 봉) + 'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'), + 'bars': [], + } + n_new += 1 + ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak)) + + n_total += n_new + print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True) + + print(f" → {n}봉 합계: {n_total}건\n", flush=True) + +conn.close() +print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True) + + +# ── 출구 탐색 ───────────────────────────────────────────────────────────────── +def find_exit(entry_price: float, atr_stop: float, bars: list) -> float: + for i, (bp, pk) in enumerate(bars): + drop = (pk - bp) / pk if pk > 0 else 0.0 + pnl = (bp - entry_price) / entry_price + if drop >= atr_stop: + return pnl * 100 + if i + 1 >= TS_N and pnl < TIME_STOP_PCT: + return pnl * 100 + return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0 + + +# ── 스윕 ────────────────────────────────────────────────────────────────────── +atr_keys = list(ATR_SWEEP.keys()) +atr_combos = list(itertools.product(*ATR_SWEEP.values())) +total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos) +print(f"총 {total_combos}가지 조합 스윕...\n", flush=True) + +t_sweep = _time.time() +results = [] + +for n in N_BARS_LIST: + entry_list = list(ALL_ENTRIES[n].values()) + for vol in VOL_SWEEP: + for atr_combo in atr_combos: + atr_params = dict(zip(atr_keys, atr_combo)) + if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']: + continue + + atr_mult = atr_params['ATR_MULT'] + atr_min = atr_params['ATR_MIN'] + atr_max = atr_params['ATR_MAX'] + + trades = [] + for e in entry_list: + # 현재봉(최강봉) vol 기준으로 필터 + if e['vr0'] < vol: + continue + + ar = e['atr_raw'] + atr_s = (atr_min if (ar != ar) + else max(atr_min, min(atr_max, ar * atr_mult))) + pnl_pct = find_exit(e['entry_price'], atr_s, e['bars']) + krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2 + trades.append((pnl_pct, krw)) + + if not trades: + results.append({'N_BARS': n, 'VOL': vol, **atr_params, + 'trades': 0, 'wins': 0, + 'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0}) + continue + + wins = sum(1 for p, _ in trades if p > 0) + results.append({ + 'N_BARS': n, + 'VOL': vol, + **atr_params, + 'trades': len(trades), + 'wins': wins, + 'win_rate': wins / len(trades) * 100, + 'avg_pnl': sum(p for p, _ in trades) / len(trades), + 'total_krw': sum(k for _, k in trades), + }) + +print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n") + +# ── 결과 출력 ───────────────────────────────────────────────────────────────── +df_r = pd.DataFrame(results) +df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False) + +print("=" * 105) +print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} " + f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}") +print("=" * 105) +for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1): + print(f"{rank:>4} {int(row['N_BARS']):>2}봉 {row['VOL']:>4.1f}x {row['ATR_MULT']:>6.1f} " + f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% " + f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% " + f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원") + +# N봉 × VOL별 최상위 요약 +print("\n" + "─" * 85) +print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)") +print("─" * 85) +for n in N_BARS_LIST: + for vol in VOL_SWEEP: + sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)] + if sub.empty: + continue + best = sub.iloc[0] + if best['trades'] == 0: + continue + print(f" {int(n):>2}봉 {vol:>4.1f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% " + f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 " + f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)") + print() diff --git a/archive/tests/test_llm_advisor.py b/archive/tests/test_llm_advisor.py new file mode 100644 index 0000000..2afecf1 --- /dev/null +++ b/archive/tests/test_llm_advisor.py @@ -0,0 +1,187 @@ +"""core/llm_advisor 단위 테스트. + +실행: + .venv/bin/python3 -m pytest tests/test_llm_advisor.py -v +""" +import json +import sys +import os +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +os.environ.setdefault('ORACLE_USER', 'x') +os.environ.setdefault('ORACLE_PASSWORD', 'x') +os.environ.setdefault('ORACLE_DSN', 'x') +os.environ.setdefault('ANTHROPIC_API_KEY', 'test-key') + +from core.llm_advisor import ( + get_exit_price, + _describe_bars, + _build_prompt, + _execute_tool, +) + +TICKER = 'KRW-XRP' + + +def _make_bars(n: int = 20, base: float = 2100.0) -> list[dict]: + now = datetime.now() + bars = [] + for i in range(n): + p = base + i * 0.5 + bars.append({ + 'open': p, + 'high': p + 2, + 'low': p - 2, + 'close': p + 1, + 'volume': 100.0, + 'ts': now - timedelta(seconds=(n - i) * 20), + }) + return bars + + +def _make_pos(entry: float = 2084.0, seconds_ago: int = 120, + sell_price: float = 2126.0) -> dict: + return { + 'entry_price': entry, + 'entry_ts': datetime.now() - timedelta(seconds=seconds_ago), + 'sell_price': sell_price, + 'sell_uuid': 'uuid-1', + 'qty': 2399.0, + 'stage': 0, + 'llm_last_ts': None, + } + + +def _mock_response(action: str, price: float = 0): + """Anthropic API 응답 Mock.""" + if action == 'hold': + text = '{"action": "hold"}' + else: + text = json.dumps({'action': 'sell', 'price': price}) + + content_block = MagicMock() + content_block.type = 'text' + content_block.text = text + + response = MagicMock() + response.content = [content_block] + response.stop_reason = 'end_turn' + return response + + +# ── _describe_bars ──────────────────────────────────────────────────────────── + +class TestDescribeBars: + + def test_returns_string(self): + bars = _make_bars(20, base=2100.0) + desc = _describe_bars(bars, current_price=2110.0) + assert isinstance(desc, str) + assert '패턴 요약' in desc + + def test_empty_bars_returns_fallback(self): + desc = _describe_bars([], current_price=2100.0) + assert desc == '봉 데이터 없음' + + def test_trend_direction_detected(self): + bars = _make_bars(20, base=2100.0) # 상승 bars (base + i*0.5) + desc = _describe_bars(bars, current_price=2110.0) + assert '상승▲' in desc + + +# ── get_exit_price: hold ────────────────────────────────────────────────────── + +class TestGetExitPriceHold: + + def test_returns_none_on_hold(self): + pos = _make_pos() + bar_list = _make_bars() + with patch('anthropic.Anthropic') as MockClient: + MockClient.return_value.messages.create.return_value = _mock_response('hold') + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result is None + + def test_returns_none_when_no_api_key(self): + pos = _make_pos() + bar_list = _make_bars() + with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}): + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result is None + + +# ── get_exit_price: sell ───────────────────────────────────────────────────── + +class TestGetExitPriceSell: + + def test_returns_llm_suggested_price(self): + pos = _make_pos(entry=2084.0, sell_price=2126.0) + bar_list = _make_bars() + with patch('anthropic.Anthropic') as MockClient: + MockClient.return_value.messages.create.return_value = _mock_response('sell', 2112.0) + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result == 2112.0 + + def test_llm_can_suggest_below_current_price(self): + """LLM의 판단을 신뢰 — 현재가 이하 제안도 그대로 반환.""" + pos = _make_pos(entry=2084.0, sell_price=2126.0) + bar_list = _make_bars() + with patch('anthropic.Anthropic') as MockClient: + MockClient.return_value.messages.create.return_value = _mock_response('sell', 2080.0) + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result == 2080.0 # 가드 없음 — LLM 신뢰 + + def test_llm_can_suggest_high_price(self): + """LLM의 판단을 신뢰 — 진입가 대비 10% 높은 제안도 그대로 반환.""" + pos = _make_pos(entry=2084.0, sell_price=2126.0) + bar_list = _make_bars() + with patch('anthropic.Anthropic') as MockClient: + MockClient.return_value.messages.create.return_value = _mock_response('sell', 2300.0) + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result == 2300.0 # 상한 가드 없음 — LLM 신뢰 + + +# ── get_exit_price: 오류 처리 ───────────────────────────────────────────────── + +class TestGetExitPriceErrors: + + def test_returns_none_on_json_error(self): + """JSON 파싱 실패 → None (cascade fallback).""" + pos = _make_pos() + bar_list = _make_bars() + bad_resp = MagicMock() + bad_resp.content = [MagicMock(type='text', text='not json')] + bad_resp.stop_reason = 'end_turn' + with patch('anthropic.Anthropic') as MockClient: + MockClient.return_value.messages.create.return_value = bad_resp + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result is None + + def test_returns_none_on_api_exception(self): + """API 오류 → None (cascade fallback).""" + pos = _make_pos() + bar_list = _make_bars() + with patch('anthropic.Anthropic') as MockClient: + MockClient.return_value.messages.create.side_effect = Exception('API Error') + result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0) + assert result is None + + +# ── tool 실행 ───────────────────────────────────────────────────────────────── + +class TestExecuteTool: + + def test_unknown_tool_returns_error_string(self): + result = _execute_tool('unknown_tool', {'ticker': TICKER}) + assert '알 수 없는 tool' in result + + def test_get_price_ticks_db_error_returns_string(self): + """DB 연결 실패 시 오류 문자열 반환 (예외 아님).""" + result = _execute_tool('get_price_ticks', {'ticker': TICKER, 'minutes': 5}) + assert isinstance(result, str) + + def test_get_ohlcv_db_error_returns_string(self): + result = _execute_tool('get_ohlcv', {'ticker': TICKER, 'limit': 10}) + assert isinstance(result, str) diff --git a/archive/tests/test_tick_trader.py b/archive/tests/test_tick_trader.py new file mode 100644 index 0000000..1f2d7f5 --- /dev/null +++ b/archive/tests/test_tick_trader.py @@ -0,0 +1,216 @@ +"""tick_trader 핵심 로직 단위 테스트. + +실행: + .venv/bin/python3 -m pytest tests/test_tick_trader.py -v + +테스트 대상: + - update_positions: Trail Stop 발동 시점 / peak 초기화 + - _advance_stage: cascade 단계 전환 / trail 전환 + - check_filled_positions: 체결 확인 / 단계 시간 초과 + - enter_position: sell_uuid=None(주문실패)일 때 즉시 Trail Stop 방지 +""" +import sys, os +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 환경변수 Mock — 실제 API 키 불필요 +os.environ.setdefault('ACCESS_KEY', 'test') +os.environ.setdefault('SECRET_KEY', 'test') +os.environ.setdefault('SIMULATION_MODE', 'true') +os.environ.setdefault('MAX_POSITIONS', '3') +os.environ.setdefault('MAX_BUDGET', '15000000') +os.environ.setdefault('ORACLE_USER', 'x') +os.environ.setdefault('ORACLE_PASSWORD', 'x') +os.environ.setdefault('ORACLE_DSN', 'x') +os.environ.setdefault('TELEGRAM_TRADE_TOKEN', 'x') +os.environ.setdefault('TELEGRAM_CHAT_ID', '0') + +# pyupbit 임포트 전 Mock 처리 +with patch('pyupbit.Upbit'), patch('pyupbit.WebSocketManager'): + import importlib + import daemons.tick_trader as tt + +TICKER = 'KRW-TEST' + + +def _make_pos(entry_price=1000.0, seconds_ago=0, sell_uuid='uuid-1', stage=0): + """테스트용 포지션 딕셔너리 생성.""" + return { + 'entry_price': entry_price, + 'entry_ts': datetime.now() - timedelta(seconds=seconds_ago), + 'running_peak': entry_price, + 'qty': 100.0, + 'stage': stage, + 'sell_uuid': sell_uuid, + 'sell_price': entry_price * 1.02, + 'trail_peak_set': False, + } + + +# ── update_positions ────────────────────────────────────────────────────────── + +class TestUpdatePositions: + + def setup_method(self): + tt.positions.clear() + + def test_trail_stop_does_not_fire_before_stage3_end(self): + """③ 종료(300s) 이전에는 sell_uuid=None이어도 Trail Stop 발동 안 함.""" + tt.positions[TICKER] = _make_pos( + entry_price=1000, seconds_ago=10, sell_uuid=None, stage=4 + ) + with patch.object(tt, 'do_sell_market') as mock_sell: + tt.update_positions({TICKER: 900.0}) # -10% 하락이어도 + mock_sell.assert_not_called() + + def test_trail_stop_fires_after_stage3_end(self): + """300s 경과 후 peak 대비 0.8% 이상 하락 시 Trail Stop 발동.""" + tt.positions[TICKER] = _make_pos( + entry_price=1000, seconds_ago=310, sell_uuid=None, stage=4 + ) + # 300s 이후 peak 설정 — 첫 틱에 running_peak = 1100 초기화 + tt.positions[TICKER]['trail_peak_set'] = True + tt.positions[TICKER]['running_peak'] = 1100.0 + + with patch.object(tt, 'do_sell_market', return_value=1091.0) as mock_sell, \ + patch.object(tt, '_record_exit') as mock_exit: + tt.update_positions({TICKER: 1091.0}) # 1100→1091 = -0.82% + mock_sell.assert_called_once() + mock_exit.assert_called_once_with(TICKER, 1091.0, 'trail') + + def test_trail_stop_not_fire_with_sell_uuid_set(self): + """sell_uuid가 있으면(지정가 대기 중) Trail Stop 발동 안 함.""" + tt.positions[TICKER] = _make_pos( + entry_price=1000, seconds_ago=400, sell_uuid='uuid-1', stage=3 + ) + tt.positions[TICKER]['trail_peak_set'] = True + tt.positions[TICKER]['running_peak'] = 1100.0 + + with patch.object(tt, 'do_sell_market') as mock_sell: + tt.update_positions({TICKER: 900.0}) + mock_sell.assert_not_called() + + def test_peak_initialized_to_current_price_at_300s(self): + """300s 첫 틱에서 running_peak이 진입가 아닌 현재가로 초기화된다.""" + pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4) + pos['trail_peak_set'] = False + tt.positions[TICKER] = pos + + with patch.object(tt, 'do_sell_market'): + tt.update_positions({TICKER: 950.0}) # 진입가보다 낮은 현재가 + + assert tt.positions[TICKER]['running_peak'] == 950.0, \ + "running_peak이 현재가(950)로 초기화되어야 함" + assert tt.positions[TICKER]['trail_peak_set'] is True + + def test_trail_stop_does_not_fire_on_peak_init_tick(self): + """peak 초기화 첫 틱에서는 drop=0이므로 Trail Stop 발동 안 함.""" + pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4) + pos['trail_peak_set'] = False + tt.positions[TICKER] = pos + + with patch.object(tt, 'do_sell_market') as mock_sell: + tt.update_positions({TICKER: 850.0}) # 진입가 대비 -15% + mock_sell.assert_not_called() # 초기화 틱이므로 발동 안 함 + + def test_submit_fail_sell_uuid_none_no_trail_before_300s(self): + """ + [회귀] HOLO 버그 재현: ① 지정가 제출 실패(sell_uuid=None) + 진입 2초 후 + → Trail Stop이 발동하면 안 됨. + """ + tt.positions[TICKER] = _make_pos( + entry_price=97, seconds_ago=2, sell_uuid=None, stage=0 + ) + with patch.object(tt, 'do_sell_market') as mock_sell: + tt.update_positions({TICKER: 95.0}) # -2.06% 하락 + mock_sell.assert_not_called() # 300s 이전이므로 발동 안 함 + + +# ── _advance_stage ──────────────────────────────────────────────────────────── + +class TestAdvanceStage: + + def setup_method(self): + tt.positions.clear() + + def test_advance_from_stage0_to_stage1(self): + """① → ② 단계 전환 시 sell_uuid 갱신.""" + tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='old-uuid') + with patch.object(tt, 'cancel_order_safe'), \ + patch.object(tt, 'submit_limit_sell', return_value='new-uuid'): + tt._advance_stage(TICKER) + + pos = tt.positions[TICKER] + assert pos['stage'] == 1 + assert pos['sell_uuid'] == 'new-uuid' + assert abs(pos['sell_price'] - 1000 * 1.01) < 0.01 + + def test_advance_to_trail_stage(self): + """마지막 cascade 단계 → ⑤ Trail 전환 시 sell_uuid=None.""" + tt.positions[TICKER] = _make_pos(stage=len(tt.CASCADE_STAGES) - 1) + with patch.object(tt, 'cancel_order_safe'), \ + patch.object(tt, 'submit_limit_sell'): + tt._advance_stage(TICKER) + + pos = tt.positions[TICKER] + assert pos['stage'] == len(tt.CASCADE_STAGES) + assert pos['sell_uuid'] is None + + def test_advance_submit_fail_sell_uuid_none(self): + """지정가 재주문 실패(submit_limit_sell=None) 시 sell_uuid=None — Trail 비활성 확인.""" + tt.positions[TICKER] = _make_pos(stage=0, seconds_ago=50) + with patch.object(tt, 'cancel_order_safe'), \ + patch.object(tt, 'submit_limit_sell', return_value=None): + tt._advance_stage(TICKER) + + pos = tt.positions[TICKER] + assert pos['sell_uuid'] is None + # Trail Stop은 300s 미경과이므로 update_positions에서 발동 안 해야 함 + with patch.object(tt, 'do_sell_market') as mock_sell: + tt.update_positions({TICKER: 500.0}) # -50% 하락이어도 + mock_sell.assert_not_called() + + +# ── check_filled_positions ──────────────────────────────────────────────────── + +class TestCheckFilledPositions: + + def setup_method(self): + tt.positions.clear() + + def test_done_order_records_exit(self): + """체결 완료(done) 시 _record_exit 호출 (실거래 모드).""" + tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', seconds_ago=10) + with patch.object(tt, 'check_order_state', return_value=('done', 1020.0)), \ + patch.object(tt, '_record_exit') as mock_exit, \ + patch.object(tt, 'SIM_MODE', False): + tt.check_filled_positions() + mock_exit.assert_called_once_with(TICKER, 1020.0, '①') + + def test_timeout_advances_stage(self): + """단계 시간 초과 시 _advance_stage 호출.""" + stage_end = tt.CASCADE_STAGES[0][1] # 40s + tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', + seconds_ago=stage_end + 5) + with patch.object(tt, 'check_order_state', return_value=('wait', None)), \ + patch.object(tt, '_advance_stage') as mock_advance: + tt.check_filled_positions() + mock_advance.assert_called_once_with(TICKER) + + def test_cancelled_order_resubmits(self): + """주문 취소(cancel) 감지 시 _advance_stage 호출 (실거래 모드).""" + tt.positions[TICKER] = _make_pos(stage=1, sell_uuid='u1', seconds_ago=50) + with patch.object(tt, 'check_order_state', return_value=('cancel', None)), \ + patch.object(tt, '_advance_stage') as mock_advance, \ + patch.object(tt, 'SIM_MODE', False): + tt.check_filled_positions() + mock_advance.assert_called_once_with(TICKER) + + def test_trail_stage_skipped(self): + """Trail 단계(sell_uuid=None)는 check_filled_positions에서 스킵.""" + tt.positions[TICKER] = _make_pos(stage=4, sell_uuid=None, seconds_ago=4000) + with patch.object(tt, 'check_order_state') as mock_state: + tt.check_filled_positions() + mock_state.assert_not_called() diff --git a/tests/ticker_sim.py b/archive/tests/ticker_sim.py similarity index 100% rename from tests/ticker_sim.py rename to archive/tests/ticker_sim.py diff --git a/tests/tp5_backtest.py b/archive/tests/tp5_backtest.py similarity index 100% rename from tests/tp5_backtest.py rename to archive/tests/tp5_backtest.py diff --git a/tests/trend_check.py b/archive/tests/trend_check.py similarity index 100% rename from tests/trend_check.py rename to archive/tests/trend_check.py diff --git a/tests/velocity_backtest.py b/archive/tests/velocity_backtest.py similarity index 100% rename from tests/velocity_backtest.py rename to archive/tests/velocity_backtest.py diff --git a/tests/vol_lead_sim.py b/archive/tests/vol_lead_sim.py similarity index 100% rename from tests/vol_lead_sim.py rename to archive/tests/vol_lead_sim.py diff --git a/tests/wf_cmp.py b/archive/tests/wf_cmp.py similarity index 100% rename from tests/wf_cmp.py rename to archive/tests/wf_cmp.py diff --git a/tests/wf_cmp2.py b/archive/tests/wf_cmp2.py similarity index 100% rename from tests/wf_cmp2.py rename to archive/tests/wf_cmp2.py diff --git a/core/llm_advisor.py b/core/llm_advisor.py index d3a6cac..9bbb6be 100644 --- a/core/llm_advisor.py +++ b/core/llm_advisor.py @@ -1,14 +1,13 @@ -"""OpenRouter LLM 기반 매매 어드바이저. +"""OpenRouter LLM 기반 매수 어드바이저. -매수: 시그널 감지 후 LLM이 매수 여부 + 지정가 결정 -매도: 1분 주기로 LLM이 매도 목표가 결정 (cascade fallback) +시그널 감지 후 LLM이 매수 여부를 판단한다. +매도는 트레일링 스탑으로 대체되어 LLM을 사용하지 않는다. -LLM에게 제공하는 DB Tool (OpenAI function calling): - - get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱) - - get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용) - - get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스) - - get_trade_history(ticker): 최근 거래 이력 (승패, 손익) - - get_btc_trend(): BTC 최근 동향 (알트 매수 판단용) +DB Tool (OpenAI function calling): + - get_price_ticks: Oracle price_tick (최근 N분 가격 틱) + - get_ohlcv: Oracle backtest_ohlcv 1분봉 + - get_ticker_context: 종목 평판 (가격 변동, 뉴스) + - get_btc_trend: BTC 최근 동향 """ from __future__ import annotations diff --git a/core/order.py b/core/order.py new file mode 100644 index 0000000..73589b3 --- /dev/null +++ b/core/order.py @@ -0,0 +1,178 @@ +"""Upbit 주문 실행 모듈. + +주문 제출, 취소, 체결 조회, 시장가 매도 등 +Upbit REST API와 직접 통신하는 로직을 담당한다. +""" +from __future__ import annotations + +import logging +import math +import time +from typing import Optional, Tuple + +import pyupbit + +log = logging.getLogger(__name__) + + +def round_price(price: float) -> float: + """Upbit 호가 단위로 내림 처리. + + Args: + price: 원본 가격. + + Returns: + 호가 단위에 맞춰 내림된 가격. + """ + if price >= 2_000_000: unit = 1000 + elif price >= 1_000_000: unit = 500 + elif price >= 100_000: unit = 100 + elif price >= 10_000: unit = 10 + elif price >= 1_000: unit = 5 + elif price >= 100: unit = 1 + elif price >= 10: unit = 0.1 + else: unit = 0.01 + return math.floor(price / unit) * unit + + +def submit_limit_buy( + client: pyupbit.Upbit, + ticker: str, + price: float, + qty: float, + sim_mode: bool = False, +) -> Optional[str]: + """지정가 매수 주문 제출. + + Returns: + 주문 UUID. 실패 시 None. + """ + price = round_price(price) + if sim_mode: + return f"sim-buy-{ticker}" + try: + order = client.buy_limit_order(ticker, price, qty) + if not order or 'error' in str(order): + log.error(f"지정가 매수 실패 {ticker}: {order}") + return None + return order.get('uuid') + except (ConnectionError, TimeoutError, ValueError) as e: + log.error(f"지정가 매수 오류 {ticker}: {e}") + return None + + +def submit_limit_sell( + client: pyupbit.Upbit, + ticker: str, + qty: float, + price: float, + sim_mode: bool = False, +) -> Optional[str]: + """지정가 매도 주문 제출. + + Returns: + 주문 UUID. 실패 시 None. + """ + price = round_price(price) + if sim_mode: + return f"sim-{ticker}" + try: + order = client.sell_limit_order(ticker, price, qty) + if not order or 'error' in str(order): + log.error(f"지정가 매도 실패 {ticker}: price={price} qty={qty} -> {order}") + return None + return order.get('uuid') + except (ConnectionError, TimeoutError, ValueError) as e: + log.error(f"지정가 매도 오류 {ticker}: {e}") + return None + + +def cancel_order( + client: pyupbit.Upbit, + uuid: Optional[str], + sim_mode: bool = False, +) -> None: + """주문 취소. sim_mode이거나 uuid가 없으면 무시.""" + if sim_mode or not uuid or uuid.startswith('sim-'): + return + try: + client.cancel_order(uuid) + except (ConnectionError, TimeoutError, ValueError) as e: + log.warning(f"주문 취소 실패 {uuid}: {e}") + + +def check_order_state( + client: pyupbit.Upbit, + uuid: str, +) -> Tuple[Optional[str], Optional[float]]: + """주문 상태 조회. + + Returns: + (state, avg_price) 튜플. state: 'done'|'wait'|'cancel'|None. + """ + try: + detail = client.get_order(uuid) + if not detail: + return None, None + state = detail.get('state') + avg_price = float(detail.get('avg_price') or 0) or None + return state, avg_price + except (ConnectionError, TimeoutError, ValueError) as e: + log.warning(f"주문 조회 실패 {uuid}: {e}") + return None, None + + +def _avg_price_from_order( + client: pyupbit.Upbit, + uuid: str, +) -> Optional[float]: + """체결 내역에서 가중평균 체결가를 계산.""" + try: + detail = client.get_order(uuid) + if not detail: + return None + trades = detail.get('trades', []) + if trades: + total_funds = sum(float(t['funds']) for t in trades) + total_vol = sum(float(t['volume']) for t in trades) + return total_funds / total_vol if total_vol > 0 else None + avg = detail.get('avg_price') + return float(avg) if avg else None + except (ConnectionError, TimeoutError, ValueError) as e: + log.warning(f"체결가 조회 실패 {uuid}: {e}") + return None + + +def sell_market( + client: pyupbit.Upbit, + ticker: str, + qty: float, + sim_mode: bool = False, +) -> Optional[float]: + """시장가 매도. 체결가를 반환. + + Args: + client: Upbit 클라이언트. + ticker: 종목 코드. + qty: 매도 수량. + sim_mode: 시뮬레이션 모드. + + Returns: + 체결 평균가. 실패 시 None. + """ + if sim_mode: + price = pyupbit.get_current_price(ticker) + log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}") + return price + try: + order = client.sell_market_order(ticker, qty) + if not order or 'error' in str(order): + log.error(f"시장가 매도 실패: {order}") + return None + uuid = order.get('uuid') + time.sleep(1.5) + avg_price = _avg_price_from_order(client, uuid) if uuid else None + return avg_price or pyupbit.get_current_price(ticker) + except (ConnectionError, TimeoutError, ValueError) as e: + log.error(f"시장가 매도 오류 {ticker}: {e}") + return None diff --git a/core/position_manager.py b/core/position_manager.py new file mode 100644 index 0000000..56b8892 --- /dev/null +++ b/core/position_manager.py @@ -0,0 +1,264 @@ +"""포지션 + 미체결 매수 관리 모듈. + +포지션 활성화, 트레일링 스탑/손절/타임아웃 체크, +미체결 매수 체결 확인, 예산 계산 등을 담당한다. +""" +from __future__ import annotations + +import logging +import time +from datetime import datetime +from typing import Optional + +import oracledb +import os + +log = logging.getLogger(__name__) + + +# ── DB 연결 (position_sync) ────────────────────────────────────────────────── +_db_conn: Optional[oracledb.Connection] = None + + +def _get_db() -> oracledb.Connection: + """Oracle ADB 연결을 반환. 끊어졌으면 재연결.""" + global _db_conn + if _db_conn is None: + kwargs = dict( + user=os.environ["ORACLE_USER"], + password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"], + ) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + _db_conn = oracledb.connect(**kwargs) + return _db_conn + + +def sync_position( + ticker: str, + state: str, + *, + buy_price: Optional[float] = None, + sell_price: Optional[float] = None, + qty: Optional[float] = None, + order_uuid: Optional[str] = None, + invested_krw: Optional[int] = None, +) -> None: + """position_sync 테이블에 포지션 상태를 기록/삭제. + + Args: + ticker: 종목 코드. + state: 'PENDING_BUY' | 'PENDING_SELL' | 'IDLE'. + """ + try: + conn = _get_db() + cur = conn.cursor() + if state == 'IDLE': + cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker]) + else: + now = datetime.now() + cur.execute( + """MERGE INTO position_sync ps + USING (SELECT :1 AS ticker FROM dual) src + ON (ps.ticker = src.ticker) + WHEN MATCHED THEN UPDATE SET + state = :2, buy_price = :3, sell_price = :4, + qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8 + WHEN NOT MATCHED THEN INSERT + (ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at) + VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""", + [ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now, + ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now], + ) + conn.commit() + except oracledb.Error as e: + log.warning(f"[sync_position] {ticker} {state} 실패: {e}") + global _db_conn + _db_conn = None + + +def calc_remaining_budget( + positions: dict, + pending_buys: dict, + max_budget: int, +) -> float: + """남은 투자 가능 금액을 계산. + + Args: + positions: 현재 포지션 dict. + pending_buys: 미체결 매수 dict. + max_budget: 총 예산. + + Returns: + 남은 투자 가능 금액 (원). + """ + invested = sum(p['entry_price'] * p['qty'] for p in positions.values()) + invested += sum(p['price'] * p['qty'] for p in pending_buys.values()) + return max_budget - invested + + +def check_exit_conditions( + pos: dict, + current_price: float, + *, + trail_pct: float = 0.015, + min_profit_pct: float = 0.005, + stop_loss_pct: float = 0.02, + timeout_secs: float = 14400, +) -> Optional[str]: + """포지션 청산 조건을 체크. + + Args: + pos: 포지션 dict (entry_price, entry_ts, running_peak). + current_price: 현재 가격. + + Returns: + 청산 사유 ('stoploss' | 'trail' | 'timeout') 또는 None. + """ + entry = pos['entry_price'] + profit_pct = (current_price - entry) / entry + elapsed = (datetime.now() - pos['entry_ts']).total_seconds() + + # 1. 손절 + if profit_pct <= -stop_loss_pct: + return 'stoploss' + + # 2. 트레일링 스탑 + peak = pos['running_peak'] + if peak > 0: + drop = (peak - current_price) / peak + if profit_pct >= min_profit_pct and drop >= trail_pct: + return 'trail' + + # 3. 타임아웃 + if elapsed >= timeout_secs: + return 'timeout' + + return None + + +def restore_from_upbit( + client, + tickers: list[str], + positions: dict, + pending_buys: dict, + *, + cancel_fn, + fp_fn, + tg_fn, +) -> None: + """Upbit 잔고에서 포지션과 미체결 매수를 복구. + + Args: + client: pyupbit.Upbit 인스턴스. + tickers: 감시 종목 리스트. + positions: 포지션 dict (in-place 수정). + pending_buys: 미체결 매수 dict (in-place 수정). + cancel_fn: 주문 취소 함수. + fp_fn: 가격 포맷 함수. + tg_fn: 텔레그램 알림 함수. + """ + _restore_positions(client, tickers, positions, cancel_fn, fp_fn, tg_fn) + _restore_pending_buys(client, tickers, positions, pending_buys, fp_fn) + _sync_restored(positions, pending_buys) + + +def _restore_positions( + client, tickers: list[str], positions: dict, + cancel_fn, fp_fn, tg_fn, +) -> None: + """잔고에서 보유 포지션을 복구.""" + balances = client.get_balances() + log.info(f"[복구] 잔고 조회: {len(balances)}건") + + for b in balances: + currency = b.get('currency', '') + bal = float(b.get('balance', 0)) + locked = float(b.get('locked', 0)) + avg = float(b.get('avg_buy_price', 0)) + total = bal + locked + if currency == 'KRW' or total <= 0 or avg <= 0: + continue + ticker = f'KRW-{currency}' + if ticker not in tickers or ticker in positions: + if ticker not in tickers: + log.info(f"[복구] {ticker} TICKERS 외 -> 스킵") + continue + + log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp_fn(avg)}원") + + # 기존 미체결 매도 주문 취소 + try: + old_orders = client.get_order(ticker, state='wait') or [] + for o in (old_orders if isinstance(old_orders, list) else []): + if o.get('side') == 'ask': + cancel_fn(o.get('uuid')) + log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}") + except (ConnectionError, TimeoutError, ValueError) as e: + log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}") + + time.sleep(0.5) + actual_bal = client.get_balance(currency) + if not actual_bal or actual_bal <= 0: + actual_bal = total + log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용") + + positions[ticker] = { + 'entry_price': avg, + 'entry_ts': datetime.now(), + 'running_peak': avg, + 'qty': actual_bal, + } + log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp_fn(avg)}원 트레일링") + tg_fn(f"♻️ 포지션 복구 {ticker}\n매수평균: {fp_fn(avg)}원 수량: {actual_bal:.6f}") + + +def _restore_pending_buys( + client, tickers: list[str], positions: dict, + pending_buys: dict, fp_fn, +) -> None: + """미체결 매수 주문을 복구.""" + for ticker in tickers: + if ticker in positions or ticker in pending_buys: + continue + try: + orders = client.get_order(ticker, state='wait') or [] + for o in (orders if isinstance(orders, list) else []): + if o.get('side') == 'bid': + price = float(o.get('price', 0)) + rem = float(o.get('remaining_volume', 0)) + if price > 0 and rem > 0: + pending_buys[ticker] = { + 'uuid': o.get('uuid'), + 'price': price, + 'qty': rem, + 'ts': datetime.now(), + 'vol_ratio': 0, + } + log.info(f"[복구] {ticker} 미체결 매수 복구: {fp_fn(price)}원 수량:{rem:.6f}") + break + except (ConnectionError, TimeoutError, ValueError): + log.warning(f"[복구] {ticker} 미체결 매수 조회 실패") + + +def _sync_restored(positions: dict, pending_buys: dict) -> None: + """복구된 포지션을 position_sync DB에 반영.""" + restored = len(positions) + len(pending_buys) + if restored: + log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨") + for ticker, pos in positions.items(): + sync_position( + ticker, 'PENDING_SELL', + buy_price=pos['entry_price'], + qty=pos['qty'], + invested_krw=int(pos['qty'] * pos['entry_price']), + ) + for ticker, pb in pending_buys.items(): + sync_position( + ticker, 'PENDING_BUY', + buy_price=pb['price'], + qty=pb['qty'], + order_uuid=pb.get('uuid'), + invested_krw=int(pb['qty'] * pb['price']), + ) diff --git a/core/signal.py b/core/signal.py new file mode 100644 index 0000000..a428804 --- /dev/null +++ b/core/signal.py @@ -0,0 +1,139 @@ +"""시그널 감지 + 지표 계산 모듈. + +20초봉 데이터에서 양봉 + 거래량 + 사전 필터를 적용하여 +매수 시그널 후보를 반환한다. +""" +from __future__ import annotations + +import logging +from typing import Optional + +log = logging.getLogger(__name__) + + +def calc_vr(bar_list: list[dict], idx: int, lookback: int = 61) -> float: + """거래량비(Volume Ratio) 계산. 상위 10% 트리밍. + + Args: + bar_list: 봉 리스트. + idx: 현재 봉 인덱스. + lookback: 기준 봉 수. + + Returns: + 현재 봉 거래량 / trimmed mean 비율. + """ + start = max(0, idx - lookback) + end = max(0, idx - 2) + baseline = sorted(bar_list[i]['volume'] for i in range(start, end)) + if not baseline: + return 0.0 + trim = max(1, len(baseline) // 10) + trimmed = baseline[:len(baseline) - trim] + if not trimmed: + return 0.0 + avg = sum(trimmed) / len(trimmed) + return bar_list[idx]['volume'] / avg if avg > 0 else 0.0 + + +def calc_atr(bar_list: list[dict], lookback: int = 28) -> float: + """ATR(Average True Range) 비율 계산. + + Args: + bar_list: 봉 리스트. + lookback: ATR 계산 봉 수. + + Returns: + ATR / 직전 종가 비율 (0~1 범위). + """ + if len(bar_list) < lookback + 2: + return 0.0 + trs = [] + for i in range(-lookback - 1, -1): + b = bar_list[i] + bp = bar_list[i - 1] + tr = max( + b['high'] - b['low'], + abs(b['high'] - bp['close']), + abs(b['low'] - bp['close']), + ) + trs.append(tr) + prev_close = bar_list[-2]['close'] + return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0 + + +def detect_signal( + ticker: str, + bar_list: list[dict], + *, + vol_min: float = 5.0, + vol_lookback: int = 61, + vol_krw_min: float = 5_000_000, + spread_min: float = 0.3, +) -> Optional[dict]: + """양봉 + 거래량 + 사전 필터 3종을 적용하여 시그널 후보를 반환. + + Args: + ticker: 종목 코드. + bar_list: 봉 리스트 (list로 변환된 deque). + vol_min: 최소 거래량 배수. + vol_lookback: 거래량 평균 기준 봉 수. + vol_krw_min: 최소 거래대금 (원). + spread_min: 횡보 필터 최소 변동폭 (%). + + Returns: + 시그널 dict 또는 None. + """ + n = len(bar_list) + if n < vol_lookback + 5: + return None + + b = bar_list[-1] + if b['close'] <= b['open']: + return None + + vr = calc_vr(bar_list, n - 1, lookback=vol_lookback) + if vr < vol_min: + return None + + bar_krw = b['close'] * b['volume'] + if bar_krw < vol_krw_min: + return None + + # 1) 횡보 필터: 최근 15봉 변동폭 < 0.3% + recent = bar_list[-15:] + period_high = max(x['high'] for x in recent) + period_low = min(x['low'] for x in recent) + if period_low > 0: + spread_pct = (period_high - period_low) / period_low * 100 + if spread_pct < spread_min: + log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% -> 스킵") + return None + + # 2) 고점 필터: 30분 구간 90%+ 위치 & 변동 1%+ + long_bars = bar_list[-90:] + long_high = max(x['high'] for x in long_bars) + long_low = min(x['low'] for x in long_bars) + if long_high > long_low: + pos_in_range = (b['close'] - long_low) / (long_high - long_low) + move_pct = (long_high - long_low) / long_low * 100 + if pos_in_range > 0.9 and move_pct > 1.0: + log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {move_pct:.1f}% -> 스킵") + return None + + # 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉 + prev_greens = 0 + for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1): + if bar_list[k]['close'] > bar_list[k]['open']: + prev_greens += 1 + else: + break + if prev_greens < 2: + log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 -> 스킵") + return None + + return { + 'ticker': ticker, + 'price': b['close'], + 'vol_ratio': vr, + 'bar_list': bar_list, + } diff --git a/daemons/state_sync.py b/daemons/state_sync.py new file mode 100644 index 0000000..9b0ea00 --- /dev/null +++ b/daemons/state_sync.py @@ -0,0 +1,184 @@ +"""10초 주기로 Upbit 잔고/미체결 주문을 조회하여 position_sync 테이블 동기화. + +상태: + PENDING_BUY — 매수 주문 제출됨 (미체결) + HOLDING — 보유 중 (매도 주문 없음) + PENDING_SELL — 매도 주문 제출됨 (미체결) + IDLE — 아무 것도 없음 (행 삭제) + +tick_trader는 이 테이블을 읽어서 positions/pending_buys를 복구한다. + +실행: + .venv/bin/python3 daemons/state_sync.py +로그: + /tmp/state_sync.log +""" +import sys, os, time, logging +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) + +import pyupbit +import oracledb + +TICKERS = [ + 'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE', + 'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP', + 'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL', + 'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G', + 'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN', + 'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB', +] +INTERVAL = 10 # 초 + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s', + handlers=[ + logging.FileHandler('/tmp/state_sync.log'), + logging.StreamHandler(sys.stdout), + ] +) +log = logging.getLogger(__name__) + +upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY']) + + +def get_conn(): + kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], + dsn=os.environ["ORACLE_DSN"]) + if w := os.environ.get("ORACLE_WALLET"): + kwargs["config_dir"] = w + return oracledb.connect(**kwargs) + + +def sync_once(conn): + """Upbit 실제 상태를 조회하여 position_sync 테이블 갱신.""" + cur = conn.cursor() + now = datetime.now() + + # 1. 잔고 조회 → 보유 종목 파악 + balances = upbit.get_balances() or [] + held = {} # ticker → {qty, avg_price} + for b in balances: + currency = b.get('currency', '') + if currency == 'KRW': + continue + ticker = f'KRW-{currency}' + if ticker not in TICKERS: + continue + bal = float(b.get('balance', 0)) + locked = float(b.get('locked', 0)) + total = bal + locked + avg = float(b.get('avg_buy_price', 0)) + if total > 0 and avg > 0: + held[ticker] = {'qty': total, 'avg_price': avg, 'invested': int(total * avg)} + + # 2. 미체결 주문 조회 → 매수/매도 대기 파악 + pending_buys = {} # ticker → {uuid, price, qty} + pending_sells = {} # ticker → {uuid, price, qty} + for ticker in TICKERS: + try: + orders = upbit.get_order(ticker, state='wait') or [] + if not isinstance(orders, list): + continue + for o in orders: + side = o.get('side') + uuid = o.get('uuid') + price = float(o.get('price', 0)) + rem = float(o.get('remaining_volume', 0)) + if price <= 0 or rem <= 0: + continue + if side == 'bid': + pending_buys[ticker] = {'uuid': uuid, 'price': price, 'qty': rem} + elif side == 'ask': + pending_sells[ticker] = {'uuid': uuid, 'price': price, 'qty': rem} + except Exception: + pass + + # 3. 상태 결정 및 DB 반영 + active_tickers = set(held.keys()) | set(pending_buys.keys()) | set(pending_sells.keys()) + + for ticker in active_tickers: + if ticker in pending_buys and ticker not in held: + state = 'PENDING_BUY' + pb = pending_buys[ticker] + buy_price = pb['price'] + sell_price = None + qty = pb['qty'] + order_uuid = pb['uuid'] + invested = int(qty * buy_price) + elif ticker in held and ticker in pending_sells: + state = 'PENDING_SELL' + h = held[ticker] + ps = pending_sells[ticker] + buy_price = h['avg_price'] + sell_price = ps['price'] + qty = h['qty'] + order_uuid = ps['uuid'] + invested = h['invested'] + elif ticker in held: + state = 'HOLDING' + h = held[ticker] + buy_price = h['avg_price'] + sell_price = None + qty = h['qty'] + order_uuid = None + invested = h['invested'] + else: + continue + + cur.execute( + """MERGE INTO position_sync ps + USING (SELECT :1 AS ticker FROM dual) src + ON (ps.ticker = src.ticker) + WHEN MATCHED THEN UPDATE SET + state = :2, buy_price = :3, sell_price = :4, + qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8 + WHEN NOT MATCHED THEN INSERT + (ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at) + VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""", + [ticker, state, buy_price, sell_price, qty, order_uuid, invested, now, + ticker, state, buy_price, sell_price, qty, order_uuid, invested, now] + ) + + # 4. 이제 없는 종목은 삭제 + if active_tickers: + placeholders = ','.join(f"'{t}'" for t in active_tickers) + cur.execute(f"DELETE FROM position_sync WHERE ticker NOT IN ({placeholders})") + else: + cur.execute("DELETE FROM position_sync") + + conn.commit() + + if active_tickers: + summary = ', '.join(f"{t.split('-')[1]}={cur.execute('SELECT state FROM position_sync WHERE ticker=:1',[t]).fetchone()[0]}" for t in sorted(active_tickers)) + log.info(f"[동기화] {summary}") + + +def main(): + log.info(f"=== state_sync 시작 (주기 {INTERVAL}초) ===") + conn = get_conn() + fail_count = 0 + while True: + try: + sync_once(conn) + fail_count = 0 + except Exception as e: + fail_count += 1 + log.error(f"[동기화 오류] {e}", exc_info=(fail_count <= 3)) + try: + conn.close() + except Exception: + pass + try: + conn = get_conn() + except Exception: + pass + time.sleep(INTERVAL) + + +if __name__ == '__main__': + main() diff --git a/daemons/tick_trader.py b/daemons/tick_trader.py index e125cd4..422bf8e 100644 --- a/daemons/tick_trader.py +++ b/daemons/tick_trader.py @@ -1,4 +1,4 @@ -"""WebSocket 기반 20초봉 트레이더. +"""WebSocket 기반 20초봉 트레이더 (Controller). 구조: WebSocket -> trade tick 수신 -> 20초봉 집계 @@ -10,8 +10,13 @@ 로그: /tmp/tick_trader.log """ -import sys, os, time, logging, threading, requests, math -from datetime import datetime, timedelta +import sys +import os +import time +import logging +import threading +import requests +from datetime import datetime from collections import deque, defaultdict from typing import Optional @@ -19,10 +24,18 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dotenv import load_dotenv load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) -from core.llm_advisor import get_exit_price, get_entry_price +from core.llm_advisor import get_entry_price +from core.signal import detect_signal, calc_vr +from core.order import ( + round_price, submit_limit_buy, cancel_order, + check_order_state, sell_market, +) +from core.position_manager import ( + sync_position, calc_remaining_budget, + check_exit_conditions, restore_from_upbit, +) import pyupbit -import oracledb # ── 전략 파라미터 ────────────────────────────────────────────────────────────── TICKERS = [ @@ -30,23 +43,21 @@ TICKERS = [ 'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR', ] -BAR_SEC = 20 # 봉 주기 (초) -VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수 -ATR_LOOKBACK = 28 # ATR 계산 봉 수 -VOL_MIN = 5.0 # 거래량 배수 임계값 -VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터 -BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초) +BAR_SEC = 20 +VOL_LOOKBACK = 61 +VOL_MIN = 5.0 +VOL_KRW_MIN = 5_000_000 +BUY_TIMEOUT = 180 -MAX_POS = int(os.environ.get('MAX_POSITIONS', 5)) -MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000)) -PER_POS = MAX_BUDGET // MAX_POS -FEE = 0.0005 +MAX_POS = int(os.environ.get('MAX_POSITIONS', 5)) +MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000)) +PER_POS = MAX_BUDGET // MAX_POS +FEE = 0.0005 -# 트레일링 스탑 청산 -TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도 -MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5% -STOP_LOSS_PCT = 0.02 # -2% 손절 -TIMEOUT_SECS = 14400 # 4시간 +TRAIL_PCT = 0.015 +MIN_PROFIT_PCT = 0.005 +STOP_LOSS_PCT = 0.02 +TIMEOUT_SECS = 14400 SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true' @@ -59,58 +70,21 @@ TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '') logging.basicConfig( level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', - handlers=[ - logging.FileHandler('/tmp/tick_trader.log'), - ] + handlers=[logging.FileHandler('/tmp/tick_trader.log')], ) log = logging.getLogger(__name__) - -# ── position_sync DB ───────────────────────────────────────────────────────── -_db_conn = None - -def _get_db(): - global _db_conn - if _db_conn is None: - kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], - dsn=os.environ["ORACLE_DSN"]) - if w := os.environ.get("ORACLE_WALLET"): - kwargs["config_dir"] = w - _db_conn = oracledb.connect(**kwargs) - return _db_conn - - -def sync_position(ticker: str, state: str, buy_price=None, sell_price=None, - qty=None, order_uuid=None, invested_krw=None): - """position_sync 테이블에 상태 기록. state_sync 데몬과 tick_trader 양쪽에서 갱신.""" - try: - conn = _get_db() - cur = conn.cursor() - if state == 'IDLE': - cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker]) - else: - now = datetime.now() - cur.execute( - """MERGE INTO position_sync ps - USING (SELECT :1 AS ticker FROM dual) src - ON (ps.ticker = src.ticker) - WHEN MATCHED THEN UPDATE SET - state = :2, buy_price = :3, sell_price = :4, - qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8 - WHEN NOT MATCHED THEN INSERT - (ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at) - VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""", - [ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now, - ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now]) - conn.commit() - except Exception as e: - log.warning(f"[sync_position] {ticker} {state} 실패: {e}") - global _db_conn - _db_conn = None +# ── 상태 ────────────────────────────────────────────────────────────────────── +bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10)) +cur_bar: dict = {} +bar_lock = threading.Lock() +positions: dict = {} +pending_buys: dict = {} +# ── 유틸리티 ────────────────────────────────────────────────────────────────── def fp(price: float) -> str: - """가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시.""" + """가격 포맷. 100원 미만은 소수점 표시.""" if price >= 100: return f"{price:,.0f}" elif price >= 10: @@ -120,6 +94,7 @@ def fp(price: float) -> str: def tg(msg: str) -> None: + """텔레그램 알림 전송.""" if not TG_TOKEN or not TG_CHAT_ID: return try: @@ -128,35 +103,32 @@ def tg(msg: str) -> None: json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'}, timeout=5, ) - except Exception as e: + except (ConnectionError, TimeoutError) as e: log.warning(f'Telegram 전송 실패: {e}') # ── 20초봉 집계 ─────────────────────────────────────────────────────────────── -bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10)) -cur_bar: dict = {} -bar_lock = threading.Lock() - - def _new_bar(price: float, volume: float, ts: datetime) -> dict: + """새 봉 초기화.""" return {'open': price, 'high': price, 'low': price, 'close': price, 'volume': volume, 'ts': ts} def on_tick(ticker: str, price: float, volume: float) -> None: + """WebSocket tick -> 현재 봉에 반영.""" with bar_lock: if ticker not in cur_bar: cur_bar[ticker] = _new_bar(price, volume, datetime.now()) return b = cur_bar[ticker] - b['high'] = max(b['high'], price) - b['low'] = min(b['low'], price) - b['close'] = price + b['high'] = max(b['high'], price) + b['low'] = min(b['low'], price) + b['close'] = price b['volume'] += volume def finalize_bars() -> None: - """BAR_SEC마다 봉 확정 → 시그널 감지 → LLM 매수 판단 → 체결 확인.""" + """BAR_SEC마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리.""" while True: time.sleep(BAR_SEC) now = datetime.now() @@ -168,296 +140,103 @@ def finalize_bars() -> None: continue bars[ticker].append(b) cur_bar[ticker] = _new_bar(b['close'], 0, now) - sig = detect_signal(ticker) + if ticker in positions or ticker in pending_buys: + continue + if len(positions) + len(pending_buys) >= MAX_POS: + continue + sig = detect_signal( + ticker, list(bars[ticker]), + vol_min=VOL_MIN, vol_lookback=VOL_LOOKBACK, + vol_krw_min=VOL_KRW_MIN, + ) if sig: signals.append(sig) - # bar_lock 밖에서 LLM 호출 + 체결 확인 for sig in signals: process_signal(sig) check_pending_buys() check_filled_positions() -# ── 지표 계산 ───────────────────────────────────────────────────────────────── -def calc_vr(bar_list: list, idx: int) -> float: - start = max(0, idx - VOL_LOOKBACK) - end = max(0, idx - 2) - baseline = sorted(bar_list[i]['volume'] for i in range(start, end)) - if not baseline: - return 0.0 - # 상위 10% 스파이크 제거 (trimmed mean) — 볼륨 평균 오염 방지 - trim = max(1, len(baseline) // 10) - trimmed = baseline[:len(baseline) - trim] - if not trimmed: - return 0.0 - avg = sum(trimmed) / len(trimmed) - return bar_list[idx]['volume'] / avg if avg > 0 else 0.0 - - -def calc_atr(bar_list: list) -> float: - if len(bar_list) < ATR_LOOKBACK + 2: - return 0.0 - trs = [] - for i in range(-ATR_LOOKBACK - 1, -1): - b = bar_list[i] - bp = bar_list[i - 1] - tr = max(b['high'] - b['low'], - abs(b['high'] - bp['close']), - abs(b['low'] - bp['close'])) - trs.append(tr) - prev_close = bar_list[-2]['close'] - return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0 - - -# ── 시그널 감지 (완화 — LLM이 최종 판단) ──────────────────────────────────── -def detect_signal(ticker: str) -> Optional[dict]: - """양봉 + 거래량 VOL_MIN 이상이면 시그널 후보 반환. bar_lock 안에서 호출.""" - bar_list = list(bars[ticker]) - n = len(bar_list) - - if n < VOL_LOOKBACK + 5: - return None - if ticker in positions or ticker in pending_buys: - return None - if len(positions) + len(pending_buys) >= MAX_POS: - return None - - b = bar_list[-1] - if b['close'] <= b['open']: - return None - - vr = calc_vr(bar_list, n - 1) - if vr < VOL_MIN: - return None - - # 20초봉 거래대금 하드캡: 소량 조작 방지 - bar_krw = b['close'] * b['volume'] - if bar_krw < VOL_KRW_MIN: - return None - - # ── LLM 호출 절감: skip 패턴 사전 필터 ── - - # 1) 횡보 (최근 15봉 변동폭 < 0.3%) → 매수 매력 없음 - recent = bar_list[-15:] - period_high = max(x['high'] for x in recent) - period_low = min(x['low'] for x in recent) - if period_low > 0: - spread_pct = (period_high - period_low) / period_low * 100 - if spread_pct < 0.3: - log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% → 스킵") - return None - - # 2) 상승 추세 이미 진행 (현재가가 구간 고점 대비 90% 이상 도달) - long_bars = bar_list[-90:] # ~30분 - long_high = max(x['high'] for x in long_bars) - long_low = min(x['low'] for x in long_bars) - if long_high > long_low: - pos_in_range = (b['close'] - long_low) / (long_high - long_low) - if pos_in_range > 0.9 and (long_high - long_low) / long_low * 100 > 1.0: - log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {(long_high-long_low)/long_low*100:.1f}% → 스킵") - return None - - # 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉이어야 진입 - prev_greens = 0 - for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1): - if bar_list[k]['close'] > bar_list[k]['open']: - prev_greens += 1 - else: - break - if prev_greens < 2: - log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 → 스킵") - return None - - return { - 'ticker': ticker, - 'price': b['close'], - 'vol_ratio': vr, - 'bar_list': bar_list, - } - - -# ── 주문 ────────────────────────────────────────────────────────────────────── -def _round_price(price: float) -> float: - """Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지).""" - if price >= 2_000_000: unit = 1000 - elif price >= 1_000_000: unit = 500 - elif price >= 100_000: unit = 100 - elif price >= 10_000: unit = 10 - elif price >= 1_000: unit = 5 - elif price >= 100: unit = 1 - elif price >= 10: unit = 0.1 - else: unit = 0.01 - return math.floor(price / unit) * unit - - -def submit_limit_sell(ticker: str, qty: float, price: float) -> Optional[str]: - """지정가 매도 주문. Returns UUID.""" - price = _round_price(price) - log.debug(f"[매도주문] {ticker} price={price} qty={qty}") - if SIM_MODE: - return f"sim-{ticker}" - try: - order = upbit_client.sell_limit_order(ticker, price, qty) - if not order or 'error' in str(order): - log.error(f"지정가 매도 제출 실패 {ticker}: price={price} qty={qty} → {order}") - return None - return order.get('uuid') - except Exception as e: - log.error(f"지정가 매도 오류 {ticker}: {e}") - return None - - -def cancel_order_safe(uuid: Optional[str]) -> None: - if SIM_MODE or not uuid or uuid.startswith('sim-'): - return - try: - upbit_client.cancel_order(uuid) - except Exception as e: - log.warning(f"주문 취소 실패 {uuid}: {e}") - - -def check_order_state(uuid: str) -> tuple: - """Returns (state, avg_price). state: 'done'|'wait'|'cancel'|None""" - try: - detail = upbit_client.get_order(uuid) - if not detail: - return None, None - state = detail.get('state') - avg_price = float(detail.get('avg_price') or 0) or None - return state, avg_price - except Exception as e: - log.warning(f"주문 조회 실패 {uuid}: {e}") - return None, None - - -def _avg_price_from_order(uuid: str) -> Optional[float]: - try: - detail = upbit_client.get_order(uuid) - if not detail: - return None - trades = detail.get('trades', []) - if trades: - total_funds = sum(float(t['funds']) for t in trades) - total_vol = sum(float(t['volume']) for t in trades) - return total_funds / total_vol if total_vol > 0 else None - avg = detail.get('avg_price') - return float(avg) if avg else None - except Exception as e: - log.warning(f"체결가 조회 실패 {uuid}: {e}") - return None - - -def do_sell_market(ticker: str, qty: float) -> Optional[float]: - """Trail Stop / Timeout용 시장가 매도.""" - if SIM_MODE: - price = pyupbit.get_current_price(ticker) - log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}") - return price - try: - order = upbit_client.sell_market_order(ticker, qty) - if not order or 'error' in str(order): - log.error(f"시장가 매도 실패: {order}") - return None - uuid = order.get('uuid') - time.sleep(1.5) - avg_price = _avg_price_from_order(uuid) if uuid else None - return avg_price or pyupbit.get_current_price(ticker) - except Exception as e: - log.error(f"시장가 매도 오류 {ticker}: {e}") - return None - - -# ── 지정가 매수 (LLM 판단) ─────────────────────────────────────────────────── -pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio} - - +# ── 매수 처리 ───────────────────────────────────────────────────────────────── def process_signal(sig: dict) -> None: - """시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출.""" - ticker = sig['ticker'] - bar_list = sig['bar_list'] + """시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출.""" + ticker = sig['ticker'] cur_price = sig['price'] vol_ratio = sig['vol_ratio'] - # 이미 보유/매수대기 중인 종목 중복 방지 if ticker in positions or ticker in pending_buys: return - - # LLM 호출 전 포지션 수 재확인 (동시 진행 방지) if len(positions) + len(pending_buys) >= MAX_POS: - log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵") return - log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x → LLM 판단 요청") + log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x -> LLM 판단 요청") llm_result = get_entry_price( - ticker=ticker, - signal=sig, - bar_list=bar_list, + ticker=ticker, signal=sig, bar_list=sig['bar_list'], current_price=cur_price, - num_positions=len(positions), - max_positions=MAX_POS, + num_positions=len(positions), max_positions=MAX_POS, ) if llm_result is None or llm_result.get('action') != 'buy': - reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답' - status = llm_result.get('market_status', '') if llm_result else '' - log.info(f"[매수/LLM] {ticker} → 스킵 | {reason}") - tg( - f"⏭️ 매수 스킵 {ticker}\n" - f"현재가: {fp(cur_price)}원 볼륨: {vol_ratio:.1f}x\n" - f"시장: {status}\n" - f"사유: {reason}" - ) + _handle_skip(ticker, cur_price, vol_ratio, llm_result) return - # LLM 호출 후 포지션 수/중복 재확인 if ticker in positions or ticker in pending_buys: return if len(positions) + len(pending_buys) >= MAX_POS: - log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵") + log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵") return - buy_price = _round_price(cur_price) # 현재가로 즉시 매수 - confidence = llm_result.get('confidence', '?') - reason = llm_result.get('reason', '') - status = llm_result.get('market_status', '') + _submit_buy(ticker, cur_price, vol_ratio, llm_result) - # 예산 체크: MAX_BUDGET - 현재 투자금 합계 - invested = sum(p['entry_price'] * p['qty'] for p in positions.values()) - invested += sum(p['price'] * p['qty'] for p in pending_buys.values()) - remaining = MAX_BUDGET - invested + +def _handle_skip( + ticker: str, price: float, vol_ratio: float, + llm_result: Optional[dict], +) -> None: + """LLM skip 결과 로깅 + 텔레그램 알림.""" + reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답' + status = llm_result.get('market_status', '') if llm_result else '' + log.info(f"[매수/LLM] {ticker} -> 스킵 | {reason}") + tg( + f"⏭️ 매수 스킵 {ticker}\n" + f"현재가: {fp(price)}원 볼륨: {vol_ratio:.1f}x\n" + f"시장: {status}\n" + f"사유: {reason}" + ) + + +def _submit_buy( + ticker: str, cur_price: float, vol_ratio: float, + llm_result: dict, +) -> None: + """LLM 승인 후 예산 체크 -> 지정가 매수 제출.""" + buy_price = round_price(cur_price) + confidence = llm_result.get('confidence', '?') + reason = llm_result.get('reason', '') + status = llm_result.get('market_status', '') + + remaining = calc_remaining_budget(positions, pending_buys, MAX_BUDGET) invest_amt = min(PER_POS, remaining) if invest_amt < 5000: - log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f}원 → 스킵") + log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}원 -> 스킵") return qty = invest_amt * (1 - FEE) / buy_price - log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 매수)") + log.info(f"[매수/LLM] {ticker} -> 승인 {fp(buy_price)}원 (현재가 매수)") - if SIM_MODE: - uuid = f"sim-buy-{ticker}" - else: - try: - order = upbit_client.buy_limit_order(ticker, buy_price, qty) - if not order or 'error' in str(order): - log.error(f"지정가 매수 제출 실패: {order}") - return - uuid = order.get('uuid') - except Exception as e: - log.error(f"지정가 매수 오류 {ticker}: {e}") - return + uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE) + if uuid is None: + return pending_buys[ticker] = { - 'uuid': uuid, - 'price': buy_price, - 'qty': qty, - 'ts': datetime.now(), - 'vol_ratio': vol_ratio, + 'uuid': uuid, 'price': buy_price, 'qty': qty, + 'ts': datetime.now(), 'vol_ratio': vol_ratio, } - sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty, - order_uuid=uuid, invested_krw=int(qty * buy_price)) - log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}") invested = int(qty * buy_price) + sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty, + order_uuid=uuid, invested_krw=invested) + log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}") tg( f"📥 지정가 매수 {ticker}\n" f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n" @@ -468,16 +247,16 @@ def process_signal(sig: dict) -> None: ) +# ── 체결 확인 ───────────────────────────────────────────────────────────────── def check_pending_buys() -> None: - """지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃/한도초과 시 취소.""" + """미체결 매수 주문 체결 확인. 타임아웃/한도 초과 시 취소.""" for ticker in list(pending_buys.keys()): - pb = pending_buys[ticker] + pb = pending_buys[ticker] elapsed = (datetime.now() - pb['ts']).total_seconds() - # 포지션 한도 초과 시 미체결 주문 즉시 취소 if len(positions) >= MAX_POS: - cancel_order_safe(pb['uuid']) - log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소") + cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE) + log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 -> 취소") sync_position(ticker, 'IDLE') del pending_buys[ticker] continue @@ -490,30 +269,31 @@ def check_pending_buys() -> None: del pending_buys[ticker] continue else: - state, avg_price = check_order_state(pb['uuid']) + state, avg_price = check_order_state(upbit_client, pb['uuid']) if state == 'done': actual_price = avg_price or pb['price'] - actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty'] + actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty'] _activate_position(ticker, actual_price, actual_qty, pb['vol_ratio']) del pending_buys[ticker] continue - # 타임아웃 if elapsed >= BUY_TIMEOUT: - cancel_order_safe(pb['uuid']) - log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 → 취소") + cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE) + log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 -> 취소") tg(f"❌ 매수 취소 {ticker}\n{fp(pb['price'])}원 {elapsed:.0f}초 미체결") sync_position(ticker, 'IDLE') del pending_buys[ticker] -def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: float) -> None: - """매수 체결 후 포지션 등록 (트레일링 스탑).""" +def _activate_position( + ticker: str, entry_price: float, qty: float, vol_ratio: float, +) -> None: + """매수 체결 후 포지션 등록.""" positions[ticker] = { - 'entry_price': entry_price, - 'entry_ts': datetime.now(), + 'entry_price': entry_price, + 'entry_ts': datetime.now(), 'running_peak': entry_price, - 'qty': qty, + 'qty': qty, } invested = int(qty * entry_price) sync_position(ticker, 'PENDING_SELL', buy_price=entry_price, @@ -528,15 +308,11 @@ def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: f # ── 포지션 관리 ─────────────────────────────────────────────────────────────── -positions: dict = {} - - - def _record_exit(ticker: str, exit_price: float, tag: str) -> None: - """체결 완료 후 포지션 종료 처리.""" - pos = positions[ticker] - pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100 - krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2 + """포지션 청산 기록 + 텔레그램 알림.""" + pos = positions[ticker] + pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100 + krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2 held = int((datetime.now() - pos['entry_ts']).total_seconds()) reason_tag = { @@ -545,12 +321,12 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None: }.get(tag, tag) icon = "✅" if pnl > 0 else "🔴" - log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유") invested = int(pos['qty'] * pos['entry_price']) + log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유") tg( f"{icon} 청산 {ticker} [{reason_tag}]\n" f"투자: {invested:,}원\n" - f"진입: {fp(pos['entry_price'])}원 → 청산: {fp(exit_price)}원\n" + f"진입: {fp(pos['entry_price'])}원 -> 청산: {fp(exit_price)}원\n" f"PNL: {pnl:+.2f}% ({krw:+,.0f}원) {held}초 보유\n" f"{'[시뮬]' if SIM_MODE else '[실거래]'}" ) @@ -558,76 +334,56 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None: del positions[ticker] +def _try_exit(ticker: str, price: float) -> None: + """청산 조건 체크 후 시장가 매도 실행.""" + pos = positions[ticker] + pos['running_peak'] = max(pos['running_peak'], price) + + tag = check_exit_conditions( + pos, price, + trail_pct=TRAIL_PCT, min_profit_pct=MIN_PROFIT_PCT, + stop_loss_pct=STOP_LOSS_PCT, timeout_secs=TIMEOUT_SECS, + ) + if tag is None: + return + + exit_price = sell_market(upbit_client, ticker, pos['qty'], sim_mode=SIM_MODE) or price + if tag == 'trail': + peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100 + drop = (pos['running_peak'] - price) / pos['running_peak'] * 100 + log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) -> {fp(price)}원 drop {drop:.2f}%") + elif tag == 'stoploss': + profit = (price - pos['entry_price']) / pos['entry_price'] * 100 + log.info(f"[손절] {ticker} {fp(price)}원 (진입 대비 {profit:+.2f}%)") + elif tag == 'timeout': + elapsed = (datetime.now() - pos['entry_ts']).total_seconds() + log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과") + + _record_exit(ticker, exit_price, tag) + + def check_filled_positions() -> None: - """20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃.""" + """20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃.""" for ticker in list(positions.keys()): if ticker not in positions: continue - pos = positions[ticker] bar_list = list(bars.get(ticker, [])) if not bar_list: continue - - current_price = bar_list[-1]['close'] - elapsed = (datetime.now() - pos['entry_ts']).total_seconds() - - # peak 갱신 - pos['running_peak'] = max(pos['running_peak'], current_price) - - profit_pct = (current_price - pos['entry_price']) / pos['entry_price'] - drop_from_peak = (pos['running_peak'] - current_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0 - - # 1. 손절: -2% - if profit_pct <= -STOP_LOSS_PCT: - exit_price = do_sell_market(ticker, pos['qty']) or current_price - log.info(f"[손절] {ticker} {fp(current_price)}원 (진입 대비 {profit_pct*100:+.2f}%)") - _record_exit(ticker, exit_price, 'stoploss') - continue - - # 2. 트레일링 스탑: 수익 +0.5% 이상 AND 고점 대비 -1.5% - if profit_pct >= MIN_PROFIT_PCT and drop_from_peak >= TRAIL_PCT: - exit_price = do_sell_market(ticker, pos['qty']) or current_price - peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100 - log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) → {fp(current_price)}원 drop {drop_from_peak*100:.2f}%") - _record_exit(ticker, exit_price, 'trail') - continue - - # 3. 타임아웃: 4시간 - if elapsed >= TIMEOUT_SECS: - exit_price = do_sell_market(ticker, pos['qty']) or current_price - log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과") - _record_exit(ticker, exit_price, 'timeout') - continue + _try_exit(ticker, bar_list[-1]['close']) def update_positions(current_prices: dict) -> None: - """tick마다 peak 갱신 (실시간 트레일링).""" + """tick마다 실시간 peak 갱신 + 손절/트레일 체크.""" for ticker in list(positions.keys()): if ticker not in current_prices: continue - pos = positions[ticker] - price = current_prices[ticker] - - pos['running_peak'] = max(pos['running_peak'], price) - - # 실시간 손절 체크 - profit_pct = (price - pos['entry_price']) / pos['entry_price'] - if profit_pct <= -STOP_LOSS_PCT: - exit_price = do_sell_market(ticker, pos['qty']) or price - log.info(f"[손절/실시간] {ticker} {fp(price)}원 ({profit_pct*100:+.2f}%)") - _record_exit(ticker, exit_price, 'stoploss') - continue - - # 실시간 트레일링 체크 - drop = (pos['running_peak'] - price) / pos['running_peak'] if pos['running_peak'] > 0 else 0 - if profit_pct >= MIN_PROFIT_PCT and drop >= TRAIL_PCT: - exit_price = do_sell_market(ticker, pos['qty']) or price - log.info(f"[트레일/실시간] {ticker} 고점 {fp(pos['running_peak'])}원 → {fp(price)}원") - _record_exit(ticker, exit_price, 'trail') + _try_exit(ticker, current_prices[ticker]) -# ── 메인 ────────────────────────────────────────────────────────────────────── +# ── 초기화 ──────────────────────────────────────────────────────────────────── def preload_bars() -> None: + """REST API 1분봉으로 bars[] 사전 적재.""" need_min = (VOL_LOOKBACK + 10) // 3 + 1 log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...") loaded = 0 @@ -647,7 +403,7 @@ def preload_bars() -> None: bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts}) loaded += 1 break - except Exception as e: + except (ConnectionError, TimeoutError, ValueError) as e: log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}") time.sleep(1) time.sleep(0.2) @@ -655,91 +411,22 @@ def preload_bars() -> None: def restore_positions() -> None: - """Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응).""" + """Upbit 잔고에서 포지션 + 미체결 매수 복구.""" if SIM_MODE: return try: - balances = upbit_client.get_balances() - log.info(f"[복구] 잔고 조회: {len(balances)}건") - for b in balances: - currency = b.get('currency', '') - bal = float(b.get('balance', 0)) - locked = float(b.get('locked', 0)) - avg = float(b.get('avg_buy_price', 0)) - total = bal + locked - if currency == 'KRW' or total <= 0 or avg <= 0: - continue - ticker = f'KRW-{currency}' - if ticker not in TICKERS: - log.info(f"[복구] {ticker} TICKERS 외 → 스킵") - continue - if ticker in positions: - continue - log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp(avg)}원") - # 기존 미체결 매도 주문 전부 취소 (트레일링으로 관리) - try: - old_orders = upbit_client.get_order(ticker, state='wait') or [] - for o in (old_orders if isinstance(old_orders, list) else []): - if o.get('side') == 'ask': - cancel_order_safe(o.get('uuid')) - log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}") - except Exception as e: - log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}") - # 취소 후 실제 가용 수량 재조회 - time.sleep(0.5) - actual_bal = upbit_client.get_balance(currency) - if not actual_bal or actual_bal <= 0: - actual_bal = total - log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용") - positions[ticker] = { - 'entry_price': avg, - 'entry_ts': datetime.now(), - 'running_peak': avg, - 'qty': actual_bal, - } - log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원 트레일링") - tg(f"♻️ 포지션 복구 {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}") - - # 미체결 매수 주문 복구 → pending_buys - for ticker in TICKERS: - if ticker in positions or ticker in pending_buys: - continue - try: - orders = upbit_client.get_order(ticker, state='wait') or [] - for o in (orders if isinstance(orders, list) else []): - if o.get('side') == 'bid': - price = float(o.get('price', 0)) - rem = float(o.get('remaining_volume', 0)) - if price > 0 and rem > 0: - pending_buys[ticker] = { - 'uuid': o.get('uuid'), - 'price': price, - 'qty': rem, - 'ts': datetime.now(), - 'vol_ratio': 0, - } - log.info(f"[복구] {ticker} 미체결 매수 복구: {fp(price)}원 수량:{rem:.6f}") - break - except Exception: - pass - - restored = len(positions) + len(pending_buys) - if restored: - log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨") - # 복구 결과를 position_sync에 반영 - for ticker, pos in positions.items(): - sync_position(ticker, 'PENDING_SELL', buy_price=pos['entry_price'], - qty=pos['qty'], - invested_krw=int(pos['qty'] * pos['entry_price'])) - for ticker, pb in pending_buys.items(): - sync_position(ticker, 'PENDING_BUY', buy_price=pb['price'], - qty=pb['qty'], order_uuid=pb.get('uuid'), - invested_krw=int(pb['qty'] * pb['price'])) - except Exception as e: + restore_from_upbit( + upbit_client, TICKERS, positions, pending_buys, + cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE), + fp_fn=fp, tg_fn=tg, + ) + except (ConnectionError, TimeoutError, ValueError) as e: log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True) -def main(): +# ── 메인 ────────────────────────────────────────────────────────────────────── +def main() -> None: + """tick_trader 메인 루프.""" mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션" log.info(f"=== tick_trader 시작 ({mode}) ===") log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원") @@ -770,7 +457,7 @@ def main(): continue ticker = data.get('code') - price = data.get('trade_price') + price = data.get('trade_price') volume = data.get('trade_volume') if not ticker or price is None or volume is None: @@ -785,7 +472,7 @@ def main(): warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5) if positions: pos_lines = ' '.join( - f"{t.split('-')[1]} {p['entry_price']:,.0f}→{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)" + f"{t.split('-')[1]} {p['entry_price']:,.0f}->{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)" for t, p in positions.items() ) log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}") diff --git a/ecosystem.config.js b/ecosystem.config.js index 52810f2..71340d5 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -33,6 +33,17 @@ module.exports = { autorestart: true, watch: false, }, + { + name: "state-sync", + script: "daemons/state_sync.py", + interpreter: ".venv/bin/python3", + cwd: "/Users/joungmin/workspaces/upbit-trader", + out_file: "logs/state-sync.log", + error_file: "logs/state-sync-error.log", + log_date_format: "YYYY-MM-DD HH:mm:ss", + autorestart: true, + watch: false, + }, { name: "context-collector", script: "daemons/context_collector.py", diff --git a/pyproject.toml b/pyproject.toml index 20af01c..3138915 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,4 +5,6 @@ requires-python = ">=3.9" dependencies = [ "pyupbit>=0.3.0", "python-dotenv>=1.0", + "anthropic>=0.40", + "oracledb>=2.0", ]