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:
joungmin
2026-03-06 20:33:15 +09:00
parent 872163a3d1
commit 976c53ed66
4 changed files with 930 additions and 485 deletions

View File

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