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:
joungmin
2026-03-06 20:46:47 +09:00
parent 976c53ed66
commit 6e0c4508fa
69 changed files with 5018 additions and 495 deletions

264
core/position_manager.py Normal file
View 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']),
)