왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병. 넣을 파일 신규: kis_trader/engine/live_sell_price.py kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price) 전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0) database.py (키 주석 + legacy/ sys.path) kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고) EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후) 문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구) 메시지 초안 fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM) 횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다. 호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백. 영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님) 커밋 2 — 루트 정리 (remove/ vs legacy/) 왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐. 넣을 파일 이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등) 이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2) 신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py docs/like_mcp.md/code_architecture.md 수동 노트 메시지 초안 chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/ remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은 ensure_legacy_root로 legacy/만 본다. 빼기: scratch/set_ws_price_max_age_zero.py (일회성)
260 lines
10 KiB
Python
260 lines
10 KiB
Python
"""
|
|
스마트 주문 집행 모듈 (TWAP - Time Weighted Average Price)
|
|
- 1초 간격 기계적 매수 방지
|
|
- 랜덤 딜레이로 시장 충격 최소화
|
|
- 분할 매수 진행 상태 저장 (봇 재시작 시에도 안전)
|
|
"""
|
|
import time
|
|
import random
|
|
import json
|
|
import os
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, Callable, Optional
|
|
|
|
logger = logging.getLogger("SmartExecutor")
|
|
|
|
|
|
class SmartOrderExecutor:
|
|
"""
|
|
[스텔스 주문 집행기]
|
|
- TWAP(시간 가중 평균) 방식으로 긴 시간 동안 랜덤하게 물량 분할 집행
|
|
- 호가 잡아먹힘 방지 및 슬리피지 최소화
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
state_file: str = "smart_orders.json",
|
|
min_split_amount: int = 500000, # 1회 최소 주문 금액 (50만원)
|
|
max_split_amount: int = 2000000, # 1회 최대 주문 금액 (200만원)
|
|
min_delay_seconds: int = 30, # 최소 대기 시간 (초)
|
|
max_delay_seconds: int = 180, # 최대 대기 시간 (초)
|
|
default_duration_minutes: int = 30 # 기본 분할 기간 (분)
|
|
):
|
|
"""
|
|
Args:
|
|
state_file: 진행 상태 저장 파일
|
|
min_split_amount: 1회 최소 주문 금액
|
|
max_split_amount: 1회 최대 주문 금액
|
|
min_delay_seconds: 분할 매수 간 최소 딜레이
|
|
max_delay_seconds: 분할 매수 간 최대 딜레이
|
|
default_duration_minutes: 기본 분할 기간
|
|
"""
|
|
self.state_file = state_file
|
|
self.min_split = min_split_amount
|
|
self.max_split = max_split_amount
|
|
self.min_delay = min_delay_seconds
|
|
self.max_delay = max_delay_seconds
|
|
self.duration = default_duration_minutes
|
|
|
|
self.active_orders: Dict = self._load_state()
|
|
|
|
logger.info(
|
|
f"🎯 SmartExecutor 초기화: "
|
|
f"분할{self.min_split//10000}~{self.max_split//10000}만원 | "
|
|
f"딜레이{min_delay_seconds}~{max_delay_seconds}초"
|
|
)
|
|
|
|
def _load_state(self) -> Dict:
|
|
"""저장된 진행 상태 로드"""
|
|
if os.path.exists(self.state_file):
|
|
try:
|
|
with open(self.state_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
logger.info(f"📂 진행 중인 스마트 주문 로드: {len(data)}개")
|
|
return data
|
|
except Exception as e:
|
|
logger.error(f"❌ 상태 파일 로드 실패: {e}")
|
|
return {}
|
|
return {}
|
|
|
|
def _save_state(self):
|
|
"""현재 상태 저장 (Atomic Write)"""
|
|
try:
|
|
# 임시 파일에 먼저 쓰고 rename (원자성 보장)
|
|
temp_file = f"{self.state_file}.tmp"
|
|
with open(temp_file, 'w', encoding='utf-8') as f:
|
|
json.dump(self.active_orders, f, indent=2, ensure_ascii=False)
|
|
|
|
# Windows 호환성을 위해 기존 파일 삭제 후 rename
|
|
if os.path.exists(self.state_file):
|
|
os.remove(self.state_file)
|
|
os.rename(temp_file, self.state_file)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ 상태 저장 실패: {e}")
|
|
|
|
def add_order(
|
|
self,
|
|
stock_code: str,
|
|
stock_name: str,
|
|
total_amount: int,
|
|
duration_minutes: Optional[int] = None
|
|
):
|
|
"""
|
|
스마트 주문 등록 (분할 매수 시작)
|
|
|
|
Args:
|
|
stock_code: 종목코드
|
|
stock_name: 종목명
|
|
total_amount: 총 매수 금액
|
|
duration_minutes: 분할 기간 (분), None이면 기본값 사용
|
|
"""
|
|
if stock_code in self.active_orders:
|
|
logger.warning(f"⚠️ [{stock_name}] 이미 진행 중인 스마트 주문 존재")
|
|
return False
|
|
|
|
duration = duration_minutes or self.duration
|
|
|
|
# 분할 횟수 계산 (총 금액을 min~max 사이로 나눔)
|
|
avg_split = (self.min_split + self.max_split) / 2
|
|
split_count = max(2, int(total_amount / avg_split))
|
|
amount_per_trade = int(total_amount / split_count)
|
|
|
|
# 첫 실행 시간 설정 (즉시 ~ 1분 후)
|
|
next_run = datetime.now() + timedelta(seconds=random.randint(1, 60))
|
|
end_time = datetime.now() + timedelta(minutes=duration)
|
|
|
|
self.active_orders[stock_code] = {
|
|
'name': stock_name,
|
|
'total_amount': total_amount,
|
|
'remaining_amount': total_amount,
|
|
'split_count': split_count,
|
|
'amount_per_trade': amount_per_trade,
|
|
'executed_count': 0,
|
|
'start_time': datetime.now().isoformat(),
|
|
'end_time': end_time.isoformat(),
|
|
'next_run_time': next_run.isoformat()
|
|
}
|
|
|
|
self._save_state()
|
|
|
|
logger.info(
|
|
f"📝 [{stock_name}] 스마트 주문 등록: "
|
|
f"{total_amount:,}원 -> {split_count}회 분할 ({duration}분)"
|
|
)
|
|
return True
|
|
|
|
def process_orders(
|
|
self,
|
|
current_prices: Dict[str, float],
|
|
buy_callback: Callable[[str, str, int, float], bool]
|
|
) -> int:
|
|
"""
|
|
스마트 주문 처리 (메인 루프에서 주기적으로 호출)
|
|
|
|
Args:
|
|
current_prices: {종목코드: 현재가} 딕셔너리
|
|
buy_callback: 실제 매수 실행 함수 (code, name, amount, price) -> success
|
|
|
|
Returns:
|
|
이번 사이클에서 실행한 주문 수
|
|
"""
|
|
executed_count = 0
|
|
completed_codes = []
|
|
now = datetime.now()
|
|
|
|
for code, order in list(self.active_orders.items()):
|
|
try:
|
|
# 1. 실행 시간 체크
|
|
next_run = datetime.fromisoformat(order['next_run_time'])
|
|
if now < next_run:
|
|
continue # 아직 실행 시간 안됨
|
|
|
|
# 2. 종료 시간 체크
|
|
end_time = datetime.fromisoformat(order['end_time'])
|
|
if now > end_time:
|
|
logger.warning(
|
|
f"⏰ [{order['name']}] 시간 초과 (잔액: {order['remaining_amount']:,}원) "
|
|
f"-> 남은 금액은 다음 기회에..."
|
|
)
|
|
completed_codes.append(code)
|
|
continue
|
|
|
|
# 3. 현재가 확인
|
|
current_price = current_prices.get(code)
|
|
if not current_price or current_price <= 0:
|
|
logger.debug(f"⚠️ [{order['name']}] 현재가 없음 -> 스킵")
|
|
continue
|
|
|
|
# 4. 이번 실행 금액 결정
|
|
remaining = order['remaining_amount']
|
|
buy_amount = min(order['amount_per_trade'], remaining)
|
|
|
|
# 5. 콜백으로 실제 매수 시도
|
|
success = buy_callback(code, order['name'], buy_amount, current_price)
|
|
|
|
if success:
|
|
# 6. 성공 시 상태 업데이트
|
|
order['remaining_amount'] -= buy_amount
|
|
order['executed_count'] += 1
|
|
executed_count += 1
|
|
|
|
logger.info(
|
|
f"✅ [{order['name']}] 분할 매수 {order['executed_count']}회차: "
|
|
f"{buy_amount:,}원 @ {current_price:,} "
|
|
f"(잔액: {order['remaining_amount']:,}원)"
|
|
)
|
|
|
|
# 7. 완료 체크
|
|
if order['remaining_amount'] < 10000: # 1만원 미만 잔돈은 무시
|
|
logger.info(f"🎉 [{order['name']}] 스마트 주문 완료!")
|
|
completed_codes.append(code)
|
|
else:
|
|
# 8. 다음 실행 시간 랜덤 설정
|
|
delay = random.randint(self.min_delay, self.max_delay)
|
|
order['next_run_time'] = (now + timedelta(seconds=delay)).isoformat()
|
|
logger.info(
|
|
f"⏳ [{order['name']}] 다음 매수는 {delay}초 후 "
|
|
f"({order['split_count'] - order['executed_count']}회 남음)"
|
|
)
|
|
else:
|
|
# 매수 실패 시 1분 뒤 재시도
|
|
order['next_run_time'] = (now + timedelta(seconds=60)).isoformat()
|
|
logger.warning(f"⚠️ [{order['name']}] 매수 실패 -> 1분 후 재시도")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ [{code}] 스마트 주문 처리 에러: {e}")
|
|
continue
|
|
|
|
# 완료된 주문 삭제
|
|
for code in completed_codes:
|
|
del self.active_orders[code]
|
|
|
|
# 상태 저장 (변경사항 있을 때만)
|
|
if executed_count > 0 or completed_codes:
|
|
self._save_state()
|
|
|
|
return executed_count
|
|
|
|
def cancel_order(self, stock_code: str) -> bool:
|
|
"""스마트 주문 취소"""
|
|
if stock_code in self.active_orders:
|
|
order = self.active_orders.pop(stock_code)
|
|
self._save_state()
|
|
logger.info(f"🚫 [{order['name']}] 스마트 주문 취소")
|
|
return True
|
|
return False
|
|
|
|
def get_status(self, stock_code: str = None) -> Dict:
|
|
"""
|
|
진행 상태 조회
|
|
|
|
Args:
|
|
stock_code: 특정 종목 (None이면 전체)
|
|
|
|
Returns:
|
|
주문 상태 딕셔너리
|
|
"""
|
|
if stock_code:
|
|
return self.active_orders.get(stock_code, {})
|
|
return self.active_orders.copy()
|
|
|
|
def has_active_order(self, stock_code: str) -> bool:
|
|
"""해당 종목의 진행 중인 스마트 주문이 있는지 확인"""
|
|
return stock_code in self.active_orders
|
|
|
|
def get_active_count(self) -> int:
|
|
"""진행 중인 스마트 주문 개수"""
|
|
return len(self.active_orders)
|