"""포지션 + 미체결 매수 관리 모듈. 포지션 활성화, 트레일링 스탑/손절/타임아웃 체크, 미체결 매수 체결 확인, 예산 계산 등을 담당한다. """ from __future__ import annotations import logging import time from datetime import datetime from typing import Optional import oracledb import os log = logging.getLogger(__name__) # ── DB 연결 (position_sync) ────────────────────────────────────────────────── _db_conn: Optional[oracledb.Connection] = None def _get_db() -> oracledb.Connection: """Oracle ADB 연결을 반환. 끊어졌으면 재연결.""" global _db_conn if _db_conn is None: kwargs = dict( user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], dsn=os.environ["ORACLE_DSN"], ) if w := os.environ.get("ORACLE_WALLET"): kwargs["config_dir"] = w _db_conn = oracledb.connect(**kwargs) return _db_conn def sync_position( ticker: str, state: str, *, buy_price: Optional[float] = None, sell_price: Optional[float] = None, qty: Optional[float] = None, order_uuid: Optional[str] = None, invested_krw: Optional[int] = None, ) -> None: """position_sync 테이블에 포지션 상태를 기록/삭제. Args: ticker: 종목 코드. state: 'PENDING_BUY' | 'PENDING_SELL' | 'IDLE'. """ try: conn = _get_db() cur = conn.cursor() if state == 'IDLE': cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker]) else: now = datetime.now() cur.execute( """MERGE INTO position_sync ps USING (SELECT :1 AS ticker FROM dual) src ON (ps.ticker = src.ticker) WHEN MATCHED THEN UPDATE SET state = :2, buy_price = :3, sell_price = :4, qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8 WHEN NOT MATCHED THEN INSERT (ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at) VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""", [ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now, ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now], ) conn.commit() except oracledb.Error as e: log.warning(f"[sync_position] {ticker} {state} 실패: {e}") global _db_conn _db_conn = None def calc_remaining_budget( positions: dict, pending_buys: dict, max_budget: int, ) -> float: """남은 투자 가능 금액을 계산. Args: positions: 현재 포지션 dict. pending_buys: 미체결 매수 dict. max_budget: 총 예산. Returns: 남은 투자 가능 금액 (원). """ invested = sum(p['entry_price'] * p['qty'] for p in positions.values()) invested += sum(p['price'] * p['qty'] for p in pending_buys.values()) return max_budget - invested def check_exit_conditions( pos: dict, current_price: float, *, trail_pct: float = 0.015, min_profit_pct: float = 0.005, stop_loss_pct: float = 0.02, timeout_secs: float = 14400, ) -> Optional[str]: """포지션 청산 조건을 체크. Args: pos: 포지션 dict (entry_price, entry_ts, running_peak). current_price: 현재 가격. Returns: 청산 사유 ('stoploss' | 'trail' | 'timeout') 또는 None. """ entry = pos['entry_price'] profit_pct = (current_price - entry) / entry elapsed = (datetime.now() - pos['entry_ts']).total_seconds() # 1. 손절 if profit_pct <= -stop_loss_pct: return 'stoploss' # 2. 트레일링 스탑 peak = pos['running_peak'] if peak > 0: drop = (peak - current_price) / peak if profit_pct >= min_profit_pct and drop >= trail_pct: return 'trail' # 3. 타임아웃 if elapsed >= timeout_secs: return 'timeout' return None def restore_from_upbit( client, tickers: list[str], positions: dict, pending_buys: dict, *, cancel_fn, fp_fn, tg_fn, ) -> None: """Upbit 잔고에서 포지션과 미체결 매수를 복구. Args: client: pyupbit.Upbit 인스턴스. tickers: 감시 종목 리스트. positions: 포지션 dict (in-place 수정). pending_buys: 미체결 매수 dict (in-place 수정). cancel_fn: 주문 취소 함수. fp_fn: 가격 포맷 함수. tg_fn: 텔레그램 알림 함수. """ _restore_positions(client, tickers, positions, cancel_fn, fp_fn, tg_fn) _restore_pending_buys(client, tickers, positions, pending_buys, fp_fn) _sync_restored(positions, pending_buys) def _restore_positions( client, tickers: list[str], positions: dict, cancel_fn, fp_fn, tg_fn, ) -> None: """잔고에서 보유 포지션을 복구.""" balances = client.get_balances() log.info(f"[복구] 잔고 조회: {len(balances)}건") for b in balances: currency = b.get('currency', '') bal = float(b.get('balance', 0)) locked = float(b.get('locked', 0)) avg = float(b.get('avg_buy_price', 0)) total = bal + locked if currency == 'KRW' or total <= 0 or avg <= 0: continue ticker = f'KRW-{currency}' if ticker not in tickers or ticker in positions: if ticker not in tickers: log.info(f"[복구] {ticker} TICKERS 외 -> 스킵") continue log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp_fn(avg)}원") # 기존 미체결 매도 주문 취소 try: old_orders = client.get_order(ticker, state='wait') or [] for o in (old_orders if isinstance(old_orders, list) else []): if o.get('side') == 'ask': cancel_fn(o.get('uuid')) log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}") except (ConnectionError, TimeoutError, ValueError) as e: log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}") time.sleep(0.5) actual_bal = client.get_balance(currency) if not actual_bal or actual_bal <= 0: actual_bal = total log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용") positions[ticker] = { 'entry_price': avg, 'entry_ts': datetime.now(), 'running_peak': avg, 'qty': actual_bal, } log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp_fn(avg)}원 트레일링") tg_fn(f"♻️ 포지션 복구 {ticker}\n매수평균: {fp_fn(avg)}원 수량: {actual_bal:.6f}") def _restore_pending_buys( client, tickers: list[str], positions: dict, pending_buys: dict, fp_fn, ) -> None: """미체결 매수 주문을 복구.""" for ticker in tickers: if ticker in positions or ticker in pending_buys: continue try: orders = client.get_order(ticker, state='wait') or [] for o in (orders if isinstance(orders, list) else []): if o.get('side') == 'bid': price = float(o.get('price', 0)) rem = float(o.get('remaining_volume', 0)) if price > 0 and rem > 0: pending_buys[ticker] = { 'uuid': o.get('uuid'), 'price': price, 'qty': rem, 'ts': datetime.now(), 'vol_ratio': 0, } log.info(f"[복구] {ticker} 미체결 매수 복구: {fp_fn(price)}원 수량:{rem:.6f}") break except (ConnectionError, TimeoutError, ValueError): log.warning(f"[복구] {ticker} 미체결 매수 조회 실패") def _sync_restored(positions: dict, pending_buys: dict) -> None: """복구된 포지션을 position_sync DB에 반영.""" restored = len(positions) + len(pending_buys) if restored: log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨") for ticker, pos in positions.items(): sync_position( ticker, 'PENDING_SELL', buy_price=pos['entry_price'], qty=pos['qty'], invested_krw=int(pos['qty'] * pos['entry_price']), ) for ticker, pb in pending_buys.items(): sync_position( ticker, 'PENDING_BUY', buy_price=pb['price'], qty=pb['qty'], order_uuid=pb.get('uuid'), invested_krw=int(pb['qty'] * pb['price']), )