refactor(phase2): Add Scanner module and strategy README
This commit is contained in:
119
backend/app/strategy/scanner.py
Normal file
119
backend/app/strategy/scanner.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Stock Scanner
|
||||
|
||||
시장에서 특정 조건에 맞는 종목을 스캔하는 모듈
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from app.trading import market_data_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Scanner:
|
||||
"""
|
||||
종목 스캔 엔진
|
||||
|
||||
특정 조건을 만족하는 종목을 찾는다.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.filters: List[Callable] = []
|
||||
|
||||
def add_filter(self, filter_func: Callable[[Dict[str, Any]], bool]):
|
||||
"""
|
||||
필터 추가
|
||||
|
||||
Args:
|
||||
filter_func: market_data를 받아 True/False 반환하는 함수
|
||||
"""
|
||||
self.filters.append(filter_func)
|
||||
|
||||
async def scan(
|
||||
self,
|
||||
stock_codes: List[str],
|
||||
max_concurrent: int = 5
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
종목 목록을 스캔하여 조건에 맞는 종목 반환
|
||||
|
||||
Args:
|
||||
stock_codes: 스캔할 종목코드 목록
|
||||
max_concurrent: 동시 처리 수
|
||||
|
||||
Returns:
|
||||
조건에 맞는 종목 데이터 목록
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def scan_one(stock_code: str) -> Optional[Dict[str, Any]]:
|
||||
async with semaphore:
|
||||
try:
|
||||
# 시세 데이터 수집
|
||||
price_data = await market_data_provider.get_current_price(stock_code)
|
||||
|
||||
if not price_data or price_data.get("rt_cd") != "0":
|
||||
return None
|
||||
|
||||
output = price_data.get("output", {})
|
||||
market_data = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output.get("hts_kor_isnm", ""),
|
||||
"current_price": float(output.get("stck_prpr", 0)),
|
||||
"change_rate": float(output.get("prdy_ctrt", 0)),
|
||||
"volume": int(output.get("acml_vol", 0)),
|
||||
"market_cap": int(output.get("hts_avls", 0)) * 100000000, # 억 → 원
|
||||
}
|
||||
|
||||
# 필터 적용
|
||||
for filter_func in self.filters:
|
||||
if not filter_func(market_data):
|
||||
return None
|
||||
|
||||
return market_data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 스캔 실패 {stock_code}: {e}")
|
||||
return None
|
||||
|
||||
tasks = [scan_one(code) for code in stock_codes]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
matched = [r for r in results if r is not None]
|
||||
logger.info(f"🔍 스캔 완료: {len(matched)}/{len(stock_codes)} 종목 매칭")
|
||||
|
||||
return matched
|
||||
|
||||
|
||||
# 사전 정의 필터
|
||||
|
||||
def filter_price_range(min_price: float, max_price: float) -> Callable:
|
||||
"""가격 범위 필터"""
|
||||
def _filter(data: Dict[str, Any]) -> bool:
|
||||
price = data.get("current_price", 0)
|
||||
return min_price <= price <= max_price
|
||||
return _filter
|
||||
|
||||
|
||||
def filter_volume_min(min_volume: int) -> Callable:
|
||||
"""최소 거래량 필터"""
|
||||
def _filter(data: Dict[str, Any]) -> bool:
|
||||
return data.get("volume", 0) >= min_volume
|
||||
return _filter
|
||||
|
||||
|
||||
def filter_change_rate(min_rate: float, max_rate: float) -> Callable:
|
||||
"""등락률 범위 필터"""
|
||||
def _filter(data: Dict[str, Any]) -> bool:
|
||||
rate = data.get("change_rate", 0)
|
||||
return min_rate <= rate <= max_rate
|
||||
return _filter
|
||||
|
||||
|
||||
def filter_market_cap_min(min_cap: int) -> Callable:
|
||||
"""최소 시가총액 필터 (원)"""
|
||||
def _filter(data: Dict[str, Any]) -> bool:
|
||||
return data.get("market_cap", 0) >= min_cap
|
||||
return _filter
|
||||
Reference in New Issue
Block a user