커밋 1 — 실매 가격 TTL 구멍 (본체)

왜: 체결이 없어도 마지막가는 유지인데, 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 (일회성)
This commit is contained in:
Your Name
2026-08-18 00:13:17 +09:00
parent 36a3e2b4a1
commit 6d2a706a48
1416 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
import asyncio
import json
import logging
from typing import Any, Callable, Dict, List, Optional, Union
import websockets
from websockets.exceptions import ConnectionClosed, WebSocketException
from .config import get_ws_url, WS_TIMEOUT
logger = logging.getLogger(__name__)
class WebSocketError(Exception):
"""Custom exception for WebSocket errors"""
def __init__(self, message: str, error_data: dict = None):
self.message = message
self.error_data = error_data or {}
super().__init__(message)
class RealTimeData:
"""실시간 데이터를 담는 클래스"""
def __init__(self, data: Dict[str, Any]):
self.raw_data = data
self.trnm = data.get('trnm')
self.return_code = data.get('return_code')
self.return_msg = data.get('return_msg')
self.data = data.get('data', [])
class WebSocketClient:
"""키움증권 실시간 웹소켓 클라이언트"""
def __init__(
self,
access_token: str,
ws_url: Optional[str] = None,
auto_reconnect: bool = True,
reconnect_interval: int = 5,
ping_interval: int = 30
):
"""
웹소켓 클라이언트 초기화
Args:
access_token: 액세스 토큰
ws_url: 웹소켓 URL (None이면 설정에서 자동 선택)
auto_reconnect: 자동 재연결 여부
reconnect_interval: 재연결 간격 (초)
ping_interval: PING 간격 (초)
"""
self.access_token = access_token
self.ws_url = ws_url or get_ws_url()
self.auto_reconnect = auto_reconnect
self.reconnect_interval = reconnect_interval
self.ping_interval = ping_interval
self.websocket: Optional[websockets.WebSocketServerProtocol] = None
self.connected = False
self.keep_running = True
self.is_logged_in = False
# 콜백 함수들
self.on_connect: Optional[Callable] = None
self.on_disconnect: Optional[Callable] = None
self.on_login: Optional[Callable] = None
self.on_data: Optional[Callable[[RealTimeData], None]] = None
self.on_error: Optional[Callable[[Exception], None]] = None
# 태스크들
self._receive_task: Optional[asyncio.Task] = None
self._ping_task: Optional[asyncio.Task] = None
async def connect(self) -> None:
"""웹소켓 서버에 연결"""
try:
logger.info(f"웹소켓 서버에 연결 중: {self.ws_url}")
self.websocket = await websockets.connect(
self.ws_url,
ping_interval=self.ping_interval,
ping_timeout=WS_TIMEOUT
)
self.connected = True
logger.info("웹소켓 서버 연결 성공")
if self.on_connect:
await self.on_connect()
except Exception as e:
logger.error(f"웹소켓 연결 실패: {e}")
self.connected = False
if self.on_error:
await self.on_error(e)
raise WebSocketError(f"연결 실패: {e}")
async def login(self) -> None:
"""웹소켓 서버에 로그인"""
if not self.connected:
await self.connect()
login_data = {
'trnm': 'LOGIN',
'token': self.access_token
}
await self.send(login_data)
logger.info("로그인 요청 전송")
async def send(self, message: Union[Dict[str, Any], str]) -> None:
"""메시지 전송"""
if not self.connected:
if self.auto_reconnect:
await self.connect()
else:
raise WebSocketError("웹소켓이 연결되지 않았습니다")
try:
if isinstance(message, dict):
message_str = json.dumps(message)
else:
message_str = message
await self.websocket.send(message_str)
logger.debug(f"메시지 전송: {message_str}")
except Exception as e:
logger.error(f"메시지 전송 실패: {e}")
if self.on_error:
await self.on_error(e)
raise WebSocketError(f"메시지 전송 실패: {e}")
async def register_realtime(
self,
group_no: str = "1",
type_list: List[str] = None,
item_list: List[str] = None,
refresh: str = "1"
) -> None:
"""
실시간 데이터 등록
Args:
group_no: 그룹 번호
type_list: 실시간 항목 리스트 (예: ['04', '0A', '0B'])
item_list: 종목코드 리스트 (빈 리스트면 전체)
refresh: 기존등록유지여부 (0: 기존유지안함, 1: 기존유지)
"""
if type_list is None:
type_list = ['04'] # 기본값: 잔고
if item_list is None:
item_list = [''] # 빈 문자열은 전체 종목
register_data = {
'trnm': 'REG',
'grp_no': group_no,
'refresh': refresh,
'data': [{
'item': item_list,
'type': type_list
}]
}
await self.send(register_data)
logger.info(f"실시간 데이터 등록: {type_list}")
async def unregister_realtime(self, group_no: str = "1") -> None:
"""실시간 데이터 해지"""
unregister_data = {
'trnm': 'REMOVE',
'grp_no': group_no
}
await self.send(unregister_data)
logger.info("실시간 데이터 해지")
async def _handle_message(self, message: str) -> None:
"""메시지 처리"""
try:
data = json.loads(message)
realtime_data = RealTimeData(data)
trnm = realtime_data.trnm
if trnm == 'LOGIN':
if realtime_data.return_code == 0:
self.is_logged_in = True
logger.info("로그인 성공")
if self.on_login:
await self.on_login()
else:
error_msg = realtime_data.return_msg or "로그인 실패"
logger.error(f"로그인 실패: {error_msg}")
if self.on_error:
await self.on_error(WebSocketError(error_msg))
elif trnm == 'PING':
# PING에 PONG으로 응답
await self.send(data)
logger.debug("PING-PONG 응답")
elif trnm == 'REAL':
# 실시간 데이터 수신
logger.debug(f"실시간 데이터 수신: {data}")
if self.on_data:
await self.on_data(realtime_data)
else:
# 기타 응답
logger.debug(f"기타 응답 수신: {data}")
if self.on_data:
await self.on_data(realtime_data)
except json.JSONDecodeError as e:
logger.error(f"JSON 파싱 오류: {e}")
if self.on_error:
await self.on_error(e)
except Exception as e:
logger.error(f"메시지 처리 오류: {e}")
if self.on_error:
await self.on_error(e)
async def _receive_messages(self) -> None:
"""메시지 수신 루프"""
while self.keep_running:
try:
if not self.connected or not self.websocket:
break
message = await self.websocket.recv()
await self._handle_message(message)
except ConnectionClosed:
logger.warning("웹소켓 연결이 종료되었습니다")
self.connected = False
self.is_logged_in = False
if self.on_disconnect:
await self.on_disconnect()
if self.auto_reconnect and self.keep_running:
logger.info(f"{self.reconnect_interval}초 후 재연결을 시도합니다")
await asyncio.sleep(self.reconnect_interval)
try:
await self.connect()
await self.login()
except Exception as e:
logger.error(f"재연결 실패: {e}")
else:
break
except Exception as e:
logger.error(f"메시지 수신 오류: {e}")
if self.on_error:
await self.on_error(e)
async def _ping_loop(self) -> None:
"""PING 루프 (연결 유지)"""
while self.keep_running and self.connected:
try:
await asyncio.sleep(self.ping_interval)
if self.connected and self.websocket:
await self.websocket.ping()
logger.debug("PING 전송")
except Exception as e:
logger.error(f"PING 오류: {e}")
async def start(self) -> None:
"""웹소켓 클라이언트 시작"""
try:
await self.connect()
await self.login()
# 수신 및 PING 태스크 시작
self._receive_task = asyncio.create_task(self._receive_messages())
self._ping_task = asyncio.create_task(self._ping_loop())
logger.info("웹소켓 클라이언트 시작됨")
except Exception as e:
logger.error(f"웹소켓 클라이언트 시작 실패: {e}")
raise
async def stop(self) -> None:
"""웹소켓 클라이언트 중지"""
logger.info("웹소켓 클라이언트 중지 중...")
self.keep_running = False
# 태스크들 취소
if self._receive_task:
self._receive_task.cancel()
if self._ping_task:
self._ping_task.cancel()
# 웹소켓 연결 종료
if self.websocket:
await self.websocket.close()
self.connected = False
self.is_logged_in = False
logger.info("웹소켓 클라이언트 중지됨")
async def run_forever(self) -> None:
"""무한 루프로 실행"""
try:
await self.start()
# 태스크들이 완료될 때까지 대기
if self._receive_task:
await self._receive_task
except asyncio.CancelledError:
logger.info("웹소켓 클라이언트가 취소되었습니다")
except Exception as e:
logger.error(f"웹소켓 클라이언트 실행 오류: {e}")
finally:
await self.stop()
def run_sync(self) -> None:
"""동기적으로 실행 (새로운 이벤트 루프에서)"""
try:
asyncio.run(self.run_forever())
except KeyboardInterrupt:
logger.info("사용자에 의해 중단되었습니다")
except Exception as e:
logger.error(f"동기 실행 오류: {e}")
raise