Compare commits

...

1 Commits

Author SHA1 Message Date
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
69 changed files with 5018 additions and 495 deletions

100
README.md Normal file
View File

@@ -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`

View File

@@ -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()

View File

@@ -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()

View File

@@ -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}분 소요)")

314
archive/tests/sim_3bar.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

244
archive/tests/sim_tp_sl.py Normal file
View File

@@ -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()

310
archive/tests/sweep_1min.py Normal file
View File

@@ -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}%)")

357
archive/tests/sweep_nbar.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

178
core/order.py Normal file
View File

@@ -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

264
core/position_manager.py Normal file
View File

@@ -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"♻️ <b>포지션 복구</b> {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']),
)

139
core/signal.py Normal file
View File

@@ -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,
}

184
daemons/state_sync.py Normal file
View File

@@ -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()

View File

@@ -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"⏭️ <b>매수 스킵</b> {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"⏭️ <b>매수 스킵</b> {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"📥 <b>지정가 매수</b> {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"❌ <b>매수 취소</b> {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} <b>청산</b> {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: <b>{pnl:+.2f}%</b> ({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"♻️ <b>포지션 복구</b> {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}")

View File

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

View File

@@ -5,4 +5,6 @@ requires-python = ">=3.9"
dependencies = [
"pyupbit>=0.3.0",
"python-dotenv>=1.0",
"anthropic>=0.40",
"oracledb>=2.0",
]