feat: 트레일링 스탑 전환 + 사전 필터 강화 + 예산 증액
- cascade/LLM 매도 제거 -> 트레일링 스탑 (고점 -1.5%, 손절 -2%, 타임아웃 4h) - 사전 필터 3종 추가: 횡보/고점/연속양봉(>=2) -> LLM 호출 57% 절감 - 현재가 매수 (LLM 가격 제안 제거) - 종목 30개 -> 10개, BTC 제외 - 예산: 100K/3pos -> 1M/5pos (종목당 200K) - VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초 - LLM 프롬프트: 연패 무시, get_trade_history 제거 - 3월 백테스트: 승률 52.1%, PNL +17,868원 - STRATEGY.md 전면 재작성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,9 @@
|
||||
"""WebSocket 기반 20초봉 트레이더.
|
||||
|
||||
구조:
|
||||
WebSocket → trade tick 수신 → 20초봉 집계
|
||||
→ 시그널(양봉+VOL≥4x) → LLM 매수 판단 → 지정가 매수
|
||||
→ LLM primary 매도 + cascade fallback 청산
|
||||
|
||||
cascade (초 기준):
|
||||
① 0~ 40초: +2.0% 지정가
|
||||
② 40~ 100초: +1.0% 지정가
|
||||
③ 100~ 300초: +0.5% 지정가
|
||||
④ 300~3500초: +0.1% 지정가
|
||||
⑤ 3500초~: Trail Stop 0.8% 시장가
|
||||
WebSocket -> trade tick 수신 -> 20초봉 집계
|
||||
-> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수
|
||||
-> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
|
||||
|
||||
실행:
|
||||
.venv/bin/python3 daemons/tick_trader.py
|
||||
@@ -29,38 +22,31 @@ load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file_
|
||||
from core.llm_advisor import get_exit_price, get_entry_price
|
||||
|
||||
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',
|
||||
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
|
||||
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
|
||||
]
|
||||
|
||||
BAR_SEC = 20 # 봉 주기 (초)
|
||||
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
||||
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
||||
VOL_MIN = 6.0 # 거래량 배수 임계값
|
||||
BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초)
|
||||
VOL_MIN = 5.0 # 거래량 배수 임계값
|
||||
VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터
|
||||
BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초)
|
||||
|
||||
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
|
||||
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
|
||||
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
|
||||
|
||||
# cascade 청산 (초 기준) — 지정가 매도
|
||||
CASCADE_STAGES = [
|
||||
(0, 40, 0.020, '①'), # 2봉
|
||||
(40, 100, 0.010, '②'), # 3봉
|
||||
(100, 300, 0.005, '③'), # 10봉
|
||||
(300, 3500, 0.001, '④'), # 160봉
|
||||
]
|
||||
TRAIL_STOP_R = 0.008
|
||||
TIMEOUT_SECS = 14400 # 4시간
|
||||
LLM_INTERVAL = 60 # LLM 호출 간격 (초)
|
||||
LLM_MIN_ELAPSED = 60 # 진입 후 최소 N초 이후부터 LLM 활성
|
||||
# 트레일링 스탑 청산
|
||||
TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도
|
||||
MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5%
|
||||
STOP_LOSS_PCT = 0.02 # -2% 손절
|
||||
TIMEOUT_SECS = 14400 # 4시간
|
||||
|
||||
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
||||
|
||||
@@ -80,6 +66,49 @@ logging.basicConfig(
|
||||
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
|
||||
|
||||
|
||||
def fp(price: float) -> str:
|
||||
"""가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시."""
|
||||
if price >= 100:
|
||||
@@ -153,10 +182,15 @@ def finalize_bars() -> None:
|
||||
def calc_vr(bar_list: list, idx: int) -> float:
|
||||
start = max(0, idx - VOL_LOOKBACK)
|
||||
end = max(0, idx - 2)
|
||||
baseline = [bar_list[i]['volume'] for i in range(start, end)]
|
||||
baseline = sorted(bar_list[i]['volume'] for i in range(start, end))
|
||||
if not baseline:
|
||||
return 0.0
|
||||
avg = sum(baseline) / len(baseline)
|
||||
# 상위 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
|
||||
|
||||
|
||||
@@ -196,6 +230,44 @@ def detect_signal(ticker: str) -> Optional[dict]:
|
||||
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'],
|
||||
@@ -209,8 +281,7 @@ 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 >= 500_000: unit = 100
|
||||
elif price >= 100_000: unit = 50
|
||||
elif price >= 100_000: unit = 100
|
||||
elif price >= 10_000: unit = 10
|
||||
elif price >= 1_000: unit = 5
|
||||
elif price >= 100: unit = 1
|
||||
@@ -222,12 +293,13 @@ def _round_price(price: float) -> float:
|
||||
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"지정가 매도 제출 실패: {order}")
|
||||
log.error(f"지정가 매도 제출 실패 {ticker}: price={price} qty={qty} → {order}")
|
||||
return None
|
||||
return order.get('uuid')
|
||||
except Exception as e:
|
||||
@@ -306,6 +378,10 @@ def process_signal(sig: dict) -> None:
|
||||
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} 포지션 한도 도달 → 스킵")
|
||||
@@ -334,18 +410,29 @@ def process_signal(sig: dict) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# LLM 호출 후 포지션 수 재확인
|
||||
# LLM 호출 후 포지션 수/중복 재확인
|
||||
if ticker in positions or ticker in pending_buys:
|
||||
return
|
||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||
log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵")
|
||||
return
|
||||
|
||||
buy_price = _round_price(llm_result['price'])
|
||||
buy_price = _round_price(cur_price) # 현재가로 즉시 매수
|
||||
confidence = llm_result.get('confidence', '?')
|
||||
reason = llm_result.get('reason', '')
|
||||
status = llm_result.get('market_status', '')
|
||||
qty = PER_POS * (1 - FEE) / buy_price
|
||||
diff_pct = (buy_price - cur_price) / cur_price * 100
|
||||
log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 {fp(cur_price)}원, 차이 {diff_pct:+.2f}%)")
|
||||
|
||||
# 예산 체크: 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
|
||||
invest_amt = min(PER_POS, remaining)
|
||||
if invest_amt < 5000:
|
||||
log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f}원 → 스킵")
|
||||
return
|
||||
|
||||
qty = invest_amt * (1 - FEE) / buy_price
|
||||
log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 매수)")
|
||||
|
||||
if SIM_MODE:
|
||||
uuid = f"sim-buy-{ticker}"
|
||||
@@ -367,10 +454,13 @@ def process_signal(sig: dict) -> None:
|
||||
'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)
|
||||
tg(
|
||||
f"📥 <b>지정가 매수</b> {ticker}\n"
|
||||
f"지정가: {fp(buy_price)}원 (현재가 대비 {diff_pct:+.2f}%)\n"
|
||||
f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n"
|
||||
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
|
||||
f"확신: {confidence} 시장: {status}\n"
|
||||
f"LLM: {reason}\n"
|
||||
@@ -388,6 +478,7 @@ def check_pending_buys() -> None:
|
||||
if len(positions) >= MAX_POS:
|
||||
cancel_order_safe(pb['uuid'])
|
||||
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소")
|
||||
sync_position(ticker, 'IDLE')
|
||||
del pending_buys[ticker]
|
||||
continue
|
||||
|
||||
@@ -412,31 +503,26 @@ def check_pending_buys() -> None:
|
||||
cancel_order_safe(pb['uuid'])
|
||||
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:
|
||||
"""매수 체결 후 포지션 등록 + cascade 매도 설정."""
|
||||
_, _, lr, tag = CASCADE_STAGES[0]
|
||||
target = entry_price * (1 + lr)
|
||||
sell_uuid = submit_limit_sell(ticker, qty, target)
|
||||
|
||||
"""매수 체결 후 포지션 등록 (트레일링 스탑)."""
|
||||
positions[ticker] = {
|
||||
'entry_price': entry_price,
|
||||
'entry_ts': datetime.now(),
|
||||
'running_peak': entry_price,
|
||||
'qty': qty,
|
||||
'stage': 0,
|
||||
'sell_uuid': sell_uuid,
|
||||
'sell_price': target,
|
||||
'llm_last_ts': None,
|
||||
'llm_active': False,
|
||||
}
|
||||
log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 지정가 {tag} {fp(target)}원")
|
||||
invested = int(qty * entry_price)
|
||||
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
|
||||
qty=qty, invested_krw=invested)
|
||||
log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 트레일 -{TRAIL_PCT*100:.1f}%")
|
||||
tg(
|
||||
f"🟢 <b>매수 체결</b> {ticker}\n"
|
||||
f"체결가: {fp(entry_price)}원 수량: {qty:.6f}\n"
|
||||
f"지정가 매도: {tag} {fp(target)}원 (+{lr*100:.1f}%)\n"
|
||||
f"체결가: {fp(entry_price)}원 투자: {invested:,}원\n"
|
||||
f"트레일: 고점 대비 -{TRAIL_PCT*100:.1f}% / 손절: -{STOP_LOSS_PCT*100:.1f}%\n"
|
||||
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||
)
|
||||
|
||||
@@ -445,25 +531,6 @@ def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: f
|
||||
positions: dict = {}
|
||||
|
||||
|
||||
def _advance_stage(ticker: str) -> None:
|
||||
"""다음 cascade 단계로 전환. 기존 지정가 취소 후 재주문."""
|
||||
pos = positions[ticker]
|
||||
cancel_order_safe(pos.get('sell_uuid'))
|
||||
next_stage = pos['stage'] + 1
|
||||
pos['stage'] = next_stage
|
||||
|
||||
if next_stage < len(CASCADE_STAGES):
|
||||
_, _, lr, tag = CASCADE_STAGES[next_stage]
|
||||
target = pos['entry_price'] * (1 + lr)
|
||||
uuid = submit_limit_sell(ticker, pos['qty'], target)
|
||||
pos['sell_uuid'] = uuid
|
||||
pos['sell_price'] = target
|
||||
log.info(f"[단계전환] {ticker} → {tag} 목표가 {fp(target)}원")
|
||||
else:
|
||||
pos['sell_uuid'] = None
|
||||
pos['sell_price'] = None
|
||||
log.info(f"[단계전환] {ticker} → ⑤ Trail Stop")
|
||||
|
||||
|
||||
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
||||
"""체결 완료 후 포지션 종료 처리."""
|
||||
@@ -473,145 +540,90 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
||||
held = int((datetime.now() - pos['entry_ts']).total_seconds())
|
||||
|
||||
reason_tag = {
|
||||
'①': '① +2.0% 익절', '②': '② +1.0% 익절',
|
||||
'③': '③ +0.5% 익절', '④': '④ +0.1% 본전',
|
||||
'trail': '⑤ 트레일스탑', 'timeout': '⑤ 타임아웃',
|
||||
'trail': '트레일스탑', 'timeout': '타임아웃',
|
||||
'stoploss': '손절', 'llm': 'LLM 매도',
|
||||
}.get(tag, tag)
|
||||
|
||||
llm_flag = 'LLM' if pos.get('llm_active') else 'cascade'
|
||||
icon = "✅" if pnl > 0 else "🔴"
|
||||
log.info(f"[청산/{tag}/{llm_flag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
||||
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
||||
invested = int(pos['qty'] * pos['entry_price'])
|
||||
tg(
|
||||
f"{icon} <b>청산</b> {ticker} [{reason_tag}] ({llm_flag})\n"
|
||||
f"진입: {fp(pos['entry_price'])}원\n"
|
||||
f"청산: {fp(exit_price)}원\n"
|
||||
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
|
||||
f"투자: {invested:,}원\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 '[실거래]'}"
|
||||
)
|
||||
sync_position(ticker, 'IDLE')
|
||||
del positions[ticker]
|
||||
|
||||
|
||||
def _should_call_llm(pos: dict, elapsed: float) -> bool:
|
||||
"""LLM 호출 조건: 진입 후 LLM_MIN_ELAPSED 초 경과 + LLM_INTERVAL 간격."""
|
||||
if elapsed < LLM_MIN_ELAPSED:
|
||||
return False
|
||||
last = pos.get('llm_last_ts')
|
||||
if last is None:
|
||||
return True
|
||||
return (datetime.now() - last).total_seconds() >= LLM_INTERVAL
|
||||
|
||||
|
||||
def check_filled_positions() -> None:
|
||||
"""20초마다 지정가 체결 확인.
|
||||
|
||||
흐름:
|
||||
1. 체결 완료 확인
|
||||
2. LLM 어드바이저 호출 (1분 주기) → 목표가 반환 시 주문 교체
|
||||
3. LLM hold/오류 시 cascade fallback (단계 시간 초과 → 다음 단계)
|
||||
"""
|
||||
"""20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃."""
|
||||
for ticker in list(positions.keys()):
|
||||
if ticker not in positions:
|
||||
continue
|
||||
pos = positions[ticker]
|
||||
uuid = pos.get('sell_uuid')
|
||||
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
||||
|
||||
if uuid is None:
|
||||
# Trail Stop 구간 — update_positions(tick)에서 처리
|
||||
bar_list = list(bars.get(ticker, []))
|
||||
if not bar_list:
|
||||
continue
|
||||
|
||||
stage = pos['stage']
|
||||
_, end, _, tag = CASCADE_STAGES[stage]
|
||||
bar_list = list(bars.get(ticker, []))
|
||||
current_price = bar_list[-1]['close']
|
||||
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
||||
|
||||
if SIM_MODE:
|
||||
# SIM: 최근 봉 고가가 목표가 이상이면 체결
|
||||
if bar_list and bar_list[-1]['high'] >= pos['sell_price']:
|
||||
_record_exit(ticker, pos['sell_price'], tag)
|
||||
continue
|
||||
else:
|
||||
# 실거래: API로 체결 확인
|
||||
state, avg_price = check_order_state(uuid)
|
||||
if state == 'done':
|
||||
_record_exit(ticker, avg_price or pos['sell_price'], tag)
|
||||
continue
|
||||
if state in ('cancel', None):
|
||||
_advance_stage(ticker)
|
||||
continue
|
||||
# peak 갱신
|
||||
pos['running_peak'] = max(pos['running_peak'], current_price)
|
||||
|
||||
# ── LLM 어드바이저 (primary) ──────────────────────────────────────
|
||||
if _should_call_llm(pos, elapsed):
|
||||
pos['llm_last_ts'] = datetime.now()
|
||||
current_price = bar_list[-1]['close'] if bar_list else pos['sell_price']
|
||||
llm_sell = get_exit_price(ticker, pos, bar_list, 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
|
||||
|
||||
if llm_sell is not None and llm_sell.get('action') == 'sell':
|
||||
new_price = llm_sell['price']
|
||||
confidence = llm_sell.get('confidence', '?')
|
||||
reason = llm_sell.get('reason', '')
|
||||
status = llm_sell.get('market_status', '')
|
||||
watch = llm_sell.get('watch_needed', False)
|
||||
pnl_pct = (new_price - pos['entry_price']) / pos['entry_price'] * 100
|
||||
# 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
|
||||
|
||||
cancel_order_safe(uuid)
|
||||
new_uuid = submit_limit_sell(ticker, pos['qty'], new_price)
|
||||
pos['sell_uuid'] = new_uuid
|
||||
pos['sell_price'] = new_price
|
||||
pos['llm_active'] = True
|
||||
log.info(f"[매도/LLM] {ticker} 지정가 {fp(new_price)}원 설정")
|
||||
tg(
|
||||
f"🤖 <b>LLM 매도 설정</b> {ticker}\n"
|
||||
f"지정가: {fp(new_price)}원 (진입 대비 {pnl_pct:+.2f}%)\n"
|
||||
f"확신: {confidence} 시장: {status} 관망: {'Y' if watch else 'N'}\n"
|
||||
f"LLM: {reason}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
reason = llm_sell.get('reason', 'hold') if llm_sell else '오류/무응답'
|
||||
watch = llm_sell.get('watch_needed', False) if llm_sell else False
|
||||
pos['llm_active'] = False
|
||||
log.info(f"[매도/LLM→fallback] {ticker} {reason} → cascade 대기")
|
||||
# 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
|
||||
|
||||
# ── Cascade fallback: LLM 실패 시에만 단계 전환 ──────────────────
|
||||
if not pos.get('llm_active') and elapsed >= end:
|
||||
log.info(f"[매도/cascade] {ticker} {elapsed:.0f}초 경과 → 다음 단계")
|
||||
_advance_stage(ticker)
|
||||
# 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
|
||||
|
||||
|
||||
def update_positions(current_prices: dict) -> None:
|
||||
"""tick마다 Trail Stop / Timeout 체크 — ③ 종료(300s) 이후에만 동작."""
|
||||
stage3_end = CASCADE_STAGES[2][1] # 300초
|
||||
|
||||
"""tick마다 peak 갱신 (실시간 트레일링)."""
|
||||
for ticker in list(positions.keys()):
|
||||
if ticker not in current_prices:
|
||||
continue
|
||||
pos = positions[ticker]
|
||||
price = current_prices[ticker]
|
||||
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
||||
pos = positions[ticker]
|
||||
price = current_prices[ticker]
|
||||
|
||||
# ③ 이전: peak 추적 안 함, Trail Stop 비활성
|
||||
if elapsed < stage3_end:
|
||||
continue
|
||||
pos['running_peak'] = max(pos['running_peak'], price)
|
||||
|
||||
# ③ 종료 직후 첫 틱: peak을 현재가로 초기화 (진입가 기준 제거)
|
||||
if not pos.get('trail_peak_set'):
|
||||
pos['running_peak'] = price
|
||||
pos['trail_peak_set'] = True
|
||||
else:
|
||||
pos['running_peak'] = max(pos['running_peak'], price)
|
||||
|
||||
# 지정가 주문 중이면 Trail Stop 비활성
|
||||
if pos.get('sell_uuid') is not None:
|
||||
continue
|
||||
|
||||
drop = (pos['running_peak'] - price) / pos['running_peak']
|
||||
|
||||
if drop >= TRAIL_STOP_R:
|
||||
# 실시간 손절 체크
|
||||
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')
|
||||
elif elapsed >= TIMEOUT_SECS and price <= pos['entry_price']:
|
||||
exit_price = do_sell_market(ticker, pos['qty']) or price
|
||||
_record_exit(ticker, exit_price, 'timeout')
|
||||
|
||||
|
||||
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
||||
@@ -643,73 +655,101 @@ def preload_bars() -> None:
|
||||
|
||||
|
||||
def restore_positions() -> None:
|
||||
"""Upbit 잔고에서 보유 종목을 positions에 복구 (재시작 대응)."""
|
||||
"""Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응)."""
|
||||
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)) + float(b.get('locked', 0))
|
||||
bal = float(b.get('balance', 0))
|
||||
locked = float(b.get('locked', 0))
|
||||
avg = float(b.get('avg_buy_price', 0))
|
||||
if currency == 'KRW' or bal <= 0 or avg <= 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:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
|
||||
# 취소 후 실제 가용 수량 재조회
|
||||
time.sleep(0.5)
|
||||
actual_bal = upbit_client.get_balance(currency) or bal
|
||||
_, _, lr, stag = CASCADE_STAGES[0]
|
||||
target = avg * (1 + lr)
|
||||
sell_uuid = submit_limit_sell(ticker, actual_bal, target)
|
||||
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() - timedelta(seconds=LLM_MIN_ELAPSED), # LLM 즉시 활성
|
||||
'entry_ts': datetime.now(),
|
||||
'running_peak': avg,
|
||||
'qty': actual_bal,
|
||||
'stage': 0,
|
||||
'sell_uuid': sell_uuid,
|
||||
'sell_price': target,
|
||||
'llm_last_ts': None,
|
||||
'llm_active': False,
|
||||
}
|
||||
log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원")
|
||||
log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원 트레일링")
|
||||
tg(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}")
|
||||
if positions:
|
||||
log.info(f"[복구] 총 {len(positions)}개 포지션 복구됨")
|
||||
|
||||
# 미체결 매수 주문 복구 → 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:
|
||||
log.warning(f"[복구] 잔고 조회 실패: {e}")
|
||||
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
|
||||
|
||||
|
||||
def main():
|
||||
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:,}원")
|
||||
stage_nums = ['①','②','③','④','⑤','⑥']
|
||||
stage_desc = ' → '.join(
|
||||
f"{stage_nums[i]} {s[1]}초 +{s[2]*100:.1f}%" for i, s in enumerate(CASCADE_STAGES)
|
||||
)
|
||||
log.info(f"청산: {stage_desc} → {stage_nums[len(CASCADE_STAGES)]} Trail -{TRAIL_STOP_R*100:.1f}% (지정가→시장가)")
|
||||
log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h")
|
||||
tg(
|
||||
f"🚀 <b>tick_trader 시작</b> ({mode})\n"
|
||||
f"봉주기 20초 | VOL ≥ {VOL_MIN}x | 최대 {MAX_POS}포지션\n"
|
||||
f"① 40초 +2.0% 지정가\n"
|
||||
f"② 100초 +1.0% 지정가\n"
|
||||
f"③ 700초 +0.5% 지정가\n"
|
||||
f"④ 3100초 +0.1% 지정가\n"
|
||||
f"⑤ Trail -{TRAIL_STOP_R*100:.1f}% 시장가"
|
||||
f"예산: {MAX_BUDGET:,}원 | 최대 {MAX_POS}포지션 | 종목당 {PER_POS:,}원\n"
|
||||
f"VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M | 연속양봉 >= 2\n"
|
||||
f"트레일: 고점 -{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%)\n"
|
||||
f"손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_SECS//3600}h"
|
||||
)
|
||||
|
||||
preload_bars()
|
||||
@@ -745,7 +785,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} [{CASCADE_STAGES[p['stage']][3] if p['stage'] < len(CASCADE_STAGES) else '⑤'}]"
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user