왜: 체결이 없어도 마지막가는 유지인데, 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 (일회성)
283 lines
11 KiB
Python
283 lines
11 KiB
Python
import asyncio
|
|
import logging
|
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
from datetime import datetime
|
|
|
|
from .websocket import WebSocketClient, RealTimeData, WebSocketError
|
|
from .websocket_constants import get_field_name, get_type_name, REALTIME_TYPES
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class RealTimeDataProcessor:
|
|
"""실시간 데이터 처리기"""
|
|
|
|
def __init__(self):
|
|
self.data_handlers: Dict[str, Callable] = {}
|
|
self.balance_data: Dict[str, Dict] = {} # 계좌별 잔고 데이터
|
|
self.stock_data: Dict[str, Dict] = {} # 종목별 시세 데이터
|
|
|
|
def register_handler(self, type_code: str, handler: Callable):
|
|
"""특정 타입의 데이터 핸들러 등록"""
|
|
self.data_handlers[type_code] = handler
|
|
|
|
def process_data(self, realtime_data: RealTimeData) -> Dict[str, Any]:
|
|
"""실시간 데이터 처리"""
|
|
if realtime_data.trnm != 'REAL':
|
|
return {}
|
|
|
|
processed_data = {}
|
|
|
|
for item_data in realtime_data.data:
|
|
type_code = item_data.get('type', '')
|
|
item_code = item_data.get('item', '')
|
|
values = item_data.get('values', {})
|
|
|
|
# 데이터 타입별 처리
|
|
if type_code == '04': # 잔고
|
|
processed = self._process_balance_data(item_code, values)
|
|
self.balance_data[item_code] = processed
|
|
processed_data[item_code] = processed
|
|
|
|
elif type_code in ['0A', '0B', '0C']: # 주식 관련
|
|
processed = self._process_stock_data(type_code, item_code, values)
|
|
self.stock_data[item_code] = processed
|
|
processed_data[item_code] = processed
|
|
|
|
# 등록된 핸들러 호출
|
|
if type_code in self.data_handlers:
|
|
try:
|
|
self.data_handlers[type_code](processed_data)
|
|
except Exception as e:
|
|
logger.error(f"데이터 핸들러 오류 ({type_code}): {e}")
|
|
|
|
return processed_data
|
|
|
|
def _process_balance_data(self, item_code: str, values: Dict) -> Dict[str, Any]:
|
|
"""잔고 데이터 처리"""
|
|
processed = {
|
|
'종목코드': item_code,
|
|
'처리시간': datetime.now().isoformat(),
|
|
'데이터타입': '잔고'
|
|
}
|
|
|
|
# 필드 매핑 적용
|
|
for field_code, value in values.items():
|
|
field_name = get_field_name('04', field_code)
|
|
processed[field_name] = value
|
|
|
|
return processed
|
|
|
|
def _process_stock_data(self, type_code: str, item_code: str, values: Dict) -> Dict[str, Any]:
|
|
"""주식 데이터 처리"""
|
|
processed = {
|
|
'종목코드': item_code,
|
|
'처리시간': datetime.now().isoformat(),
|
|
'데이터타입': get_type_name(type_code)
|
|
}
|
|
|
|
# 필드 매핑 적용
|
|
for field_code, value in values.items():
|
|
field_name = get_field_name(type_code, field_code)
|
|
processed[field_name] = value
|
|
|
|
return processed
|
|
|
|
def get_balance_data(self, item_code: str = None) -> Dict:
|
|
"""잔고 데이터 조회"""
|
|
if item_code:
|
|
return self.balance_data.get(item_code, {})
|
|
return self.balance_data
|
|
|
|
def get_stock_data(self, item_code: str = None) -> Dict:
|
|
"""주식 데이터 조회"""
|
|
if item_code:
|
|
return self.stock_data.get(item_code, {})
|
|
return self.stock_data
|
|
|
|
class SimpleWebSocketClient:
|
|
"""간단한 웹소켓 클라이언트 (사용하기 쉬운 인터페이스)"""
|
|
|
|
def __init__(
|
|
self,
|
|
access_token: str,
|
|
ws_url: Optional[str] = None,
|
|
auto_reconnect: bool = True
|
|
):
|
|
self.client = WebSocketClient(
|
|
access_token=access_token,
|
|
ws_url=ws_url,
|
|
auto_reconnect=auto_reconnect
|
|
)
|
|
self.processor = RealTimeDataProcessor()
|
|
self._setup_default_handlers()
|
|
|
|
def _setup_default_handlers(self):
|
|
"""기본 핸들러 설정"""
|
|
self.client.on_data = self._on_data_received
|
|
self.client.on_connect = self._on_connected
|
|
self.client.on_login = self._on_logged_in
|
|
self.client.on_error = self._on_error
|
|
|
|
async def _on_data_received(self, realtime_data: RealTimeData):
|
|
"""데이터 수신 시 호출"""
|
|
processed_data = self.processor.process_data(realtime_data)
|
|
if processed_data:
|
|
logger.info(f"실시간 데이터 처리 완료: {len(processed_data)}개 항목")
|
|
|
|
async def _on_connected(self):
|
|
"""연결 성공 시 호출"""
|
|
logger.info("웹소켓 서버에 연결되었습니다")
|
|
|
|
async def _on_logged_in(self):
|
|
"""로그인 성공 시 호출"""
|
|
logger.info("웹소켓 서버 로그인 성공")
|
|
|
|
async def _on_error(self, error: Exception):
|
|
"""오류 발생 시 호출"""
|
|
logger.error(f"웹소켓 오류: {error}")
|
|
|
|
def register_data_handler(self, type_code: str, handler: Callable):
|
|
"""데이터 핸들러 등록"""
|
|
self.processor.register_handler(type_code, handler)
|
|
|
|
async def start(self, type_list: List[str] = None, item_list: List[str] = None):
|
|
"""웹소켓 클라이언트 시작"""
|
|
if type_list is None:
|
|
type_list = ['04'] # 기본값: 잔고
|
|
|
|
await self.client.start()
|
|
await self.client.register_realtime(type_list=type_list, item_list=item_list)
|
|
|
|
async def stop(self):
|
|
"""웹소켓 클라이언트 중지"""
|
|
await self.client.stop()
|
|
|
|
def run_sync(self, type_list: List[str] = None, item_list: List[str] = None):
|
|
"""동기적으로 실행"""
|
|
async def run():
|
|
await self.start(type_list, item_list)
|
|
await self.client.run_forever()
|
|
|
|
try:
|
|
asyncio.run(run())
|
|
except KeyboardInterrupt:
|
|
logger.info("사용자에 의해 중단되었습니다")
|
|
asyncio.run(self.stop())
|
|
|
|
def get_balance_data(self, item_code: str = None) -> Dict:
|
|
"""잔고 데이터 조회"""
|
|
return self.processor.get_balance_data(item_code)
|
|
|
|
def get_stock_data(self, item_code: str = None) -> Dict:
|
|
"""주식 데이터 조회"""
|
|
return self.processor.get_stock_data(item_code)
|
|
|
|
class WebSocketManager:
|
|
"""웹소켓 클라이언트 관리자 (여러 클라이언트 관리)"""
|
|
|
|
def __init__(self):
|
|
self.clients: Dict[str, WebSocketClient] = {}
|
|
self.tasks: Dict[str, asyncio.Task] = {}
|
|
|
|
async def add_client(
|
|
self,
|
|
client_id: str,
|
|
access_token: str,
|
|
ws_url: Optional[str] = None,
|
|
type_list: List[str] = None,
|
|
item_list: List[str] = None
|
|
) -> WebSocketClient:
|
|
"""클라이언트 추가"""
|
|
if client_id in self.clients:
|
|
raise ValueError(f"클라이언트 ID '{client_id}'가 이미 존재합니다")
|
|
|
|
client = WebSocketClient(access_token=access_token, ws_url=ws_url)
|
|
self.clients[client_id] = client
|
|
|
|
# 클라이언트 시작
|
|
await client.start()
|
|
if type_list:
|
|
await client.register_realtime(type_list=type_list, item_list=item_list)
|
|
|
|
# 태스크 생성
|
|
task = asyncio.create_task(client.run_forever())
|
|
self.tasks[client_id] = task
|
|
|
|
return client
|
|
|
|
async def remove_client(self, client_id: str):
|
|
"""클라이언트 제거"""
|
|
if client_id not in self.clients:
|
|
return
|
|
|
|
# 태스크 취소
|
|
if client_id in self.tasks:
|
|
self.tasks[client_id].cancel()
|
|
del self.tasks[client_id]
|
|
|
|
# 클라이언트 중지
|
|
await self.clients[client_id].stop()
|
|
del self.clients[client_id]
|
|
|
|
async def stop_all(self):
|
|
"""모든 클라이언트 중지"""
|
|
for client_id in list(self.clients.keys()):
|
|
await self.remove_client(client_id)
|
|
|
|
def get_client(self, client_id: str) -> Optional[WebSocketClient]:
|
|
"""클라이언트 조회"""
|
|
return self.clients.get(client_id)
|
|
|
|
def list_clients(self) -> List[str]:
|
|
"""클라이언트 목록 조회"""
|
|
return list(self.clients.keys())
|
|
|
|
# 유틸리티 함수들
|
|
def create_simple_client(
|
|
access_token: str,
|
|
ws_url: Optional[str] = None,
|
|
type_list: List[str] = None,
|
|
item_list: List[str] = None
|
|
) -> SimpleWebSocketClient:
|
|
"""간단한 웹소켓 클라이언트 생성"""
|
|
client = SimpleWebSocketClient(access_token=access_token, ws_url=ws_url)
|
|
|
|
if type_list:
|
|
for type_code in type_list:
|
|
if type_code not in REALTIME_TYPES:
|
|
logger.warning(f"알 수 없는 실시간 타입: {type_code}")
|
|
|
|
return client
|
|
|
|
def format_balance_data(balance_data: Dict) -> str:
|
|
"""잔고 데이터를 보기 좋게 포맷팅"""
|
|
if not balance_data:
|
|
return "잔고 데이터가 없습니다"
|
|
|
|
lines = []
|
|
for item_code, data in balance_data.items():
|
|
lines.append(f"종목: {data.get('종목명', item_code)}")
|
|
lines.append(f" 현재가: {data.get('현재가', 'N/A')}")
|
|
lines.append(f" 보유수량: {data.get('보유수량', 'N/A')}")
|
|
lines.append(f" 매입단가: {data.get('매입단가', 'N/A')}")
|
|
lines.append(f" 손익률: {data.get('손익률', 'N/A')}%")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def format_stock_data(stock_data: Dict) -> str:
|
|
"""주식 데이터를 보기 좋게 포맷팅"""
|
|
if not stock_data:
|
|
return "주식 데이터가 없습니다"
|
|
|
|
lines = []
|
|
for item_code, data in stock_data.items():
|
|
lines.append(f"종목: {data.get('종목명', item_code)}")
|
|
lines.append(f" 현재가: {data.get('현재가', 'N/A')}")
|
|
lines.append(f" 등락율: {data.get('등락율', 'N/A')}%")
|
|
lines.append(f" 거래량: {data.get('거래량', 'N/A')}")
|
|
lines.append(f" 매도호가: {data.get('매도호가', 'N/A')}")
|
|
lines.append(f" 매수호가: {data.get('매수호가', 'N/A')}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines) |