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>
This commit is contained in:
264
core/position_manager.py
Normal file
264
core/position_manager.py
Normal 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']),
|
||||
)
|
||||
Reference in New Issue
Block a user