- 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>
265 lines
8.9 KiB
Python
265 lines
8.9 KiB
Python
"""포지션 + 미체결 매수 관리 모듈.
|
|
|
|
포지션 활성화, 트레일링 스탑/손절/타임아웃 체크,
|
|
미체결 매수 체결 확인, 예산 계산 등을 담당한다.
|
|
"""
|
|
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']),
|
|
)
|