Files
upbit-trader/archive/tests/sim_limit_exit.py
joungmin 6e0c4508fa refactor: MVC 구조 분리 + 미사용 파일 archive 정리
- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리:
  - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal)
  - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회)
  - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건
- type hints, Google docstring, 구체적 예외 타입 적용
- 50줄 초과 함수 분리 (process_signal, restore_positions)
- 미사용 파일 58개 archive/ 폴더로 이동
- README.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:46:47 +09:00

266 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""진입 즉시 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()