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",
]