refactor(phase2): Add Scanner module and strategy README
This commit is contained in:
288
backend/app/strategy/README.md
Normal file
288
backend/app/strategy/README.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# Strategy Module
|
||||||
|
|
||||||
|
매매 전략을 관리하고 실행하는 핵심 모듈
|
||||||
|
|
||||||
|
## 모듈 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
strategy/
|
||||||
|
├── __init__.py # 모듈 내보내기
|
||||||
|
├── base_strategy.py # BaseStrategy 추상 클래스
|
||||||
|
├── signal.py # Signal 데이터 클래스
|
||||||
|
├── strategy_manager.py # 전략 관리자 (등록/실행)
|
||||||
|
├── indicators.py # 보조지표 (RSI, BB, MACD 등)
|
||||||
|
├── scanner.py # 종목 스캔
|
||||||
|
├── strategies/ # 구체적 전략 구현
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── day_trading.py # 데이트레이딩 전략
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사용 방법
|
||||||
|
|
||||||
|
### 1. 전략 구현
|
||||||
|
|
||||||
|
`BaseStrategy`를 상속받아 `analyze()` 메서드 구현:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.strategy import BaseStrategy, Signal, SignalType
|
||||||
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
class MyStrategy(BaseStrategy):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name="MyStrategy", config={"param1": 10})
|
||||||
|
|
||||||
|
async def analyze(self, stock_code, market_data, portfolio_data=None):
|
||||||
|
current_price = market_data.get("current_price")
|
||||||
|
|
||||||
|
# 매수 조건 체크
|
||||||
|
if self._should_buy(market_data):
|
||||||
|
return Signal(
|
||||||
|
stock_code=stock_code,
|
||||||
|
signal_type=SignalType.BUY,
|
||||||
|
strategy_name=self.name,
|
||||||
|
timestamp=datetime.now(pytz.timezone("Asia/Seoul")),
|
||||||
|
price=current_price,
|
||||||
|
confidence=0.8,
|
||||||
|
reason="매수 조건 충족"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _should_buy(self, market_data):
|
||||||
|
# 실제 로직 구현
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 전략 등록 및 실행
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.strategy import strategy_manager
|
||||||
|
from app.strategy.strategies import DayTradingStrategy
|
||||||
|
|
||||||
|
# 전략 인스턴스 생성
|
||||||
|
day_trading = DayTradingStrategy(
|
||||||
|
name="DayTrading_ORB",
|
||||||
|
config={
|
||||||
|
"max_positions": 3,
|
||||||
|
"stop_loss_pct": 0.025
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 전략 등록 (priority 낮을수록 먼저 실행)
|
||||||
|
strategy_manager.register_strategy(day_trading, priority=10)
|
||||||
|
|
||||||
|
# 단일 전략 실행
|
||||||
|
signal = await strategy_manager.run_strategy(
|
||||||
|
"DayTrading_ORB",
|
||||||
|
stock_code="005930",
|
||||||
|
market_data={
|
||||||
|
"current_price": 75000,
|
||||||
|
"minute_chart": [...],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if signal:
|
||||||
|
print(f"신호 발생: {signal}")
|
||||||
|
|
||||||
|
# 모든 전략 동시 실행
|
||||||
|
signals = await strategy_manager.run_all_strategies(
|
||||||
|
stock_code="005930",
|
||||||
|
market_data={...},
|
||||||
|
parallel=True # 병렬 실행
|
||||||
|
)
|
||||||
|
|
||||||
|
# 신호 충돌 해결
|
||||||
|
final_signal = strategy_manager.resolve_conflicts(
|
||||||
|
signals,
|
||||||
|
method="highest_confidence" # or "priority", "majority_vote"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Signal 데이터 구조
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.strategy import Signal, SignalType
|
||||||
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
signal = Signal(
|
||||||
|
stock_code="005930",
|
||||||
|
signal_type=SignalType.BUY,
|
||||||
|
strategy_name="MyStrategy",
|
||||||
|
timestamp=datetime.now(pytz.timezone("Asia/Seoul")),
|
||||||
|
|
||||||
|
# 주문 정보
|
||||||
|
quantity=10,
|
||||||
|
price=75000, # None = 시장가
|
||||||
|
confidence=0.85,
|
||||||
|
|
||||||
|
# 손익 관리
|
||||||
|
stop_loss_price=73000,
|
||||||
|
take_profit_price=78000,
|
||||||
|
trailing_stop_pct=0.02, # 2% 트레일링
|
||||||
|
|
||||||
|
# 메타데이터
|
||||||
|
reason="RSI 과매도 반등",
|
||||||
|
metadata={"rsi": 25, "bb_lower": 74000}
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON 변환
|
||||||
|
data = signal.to_dict()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 보조지표 사용
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.strategy.indicators import rsi, bollinger_bands, sma, macd, atr
|
||||||
|
|
||||||
|
# 종가 리스트
|
||||||
|
closes = [75000, 75500, 76000, 74500, 75000, ...]
|
||||||
|
|
||||||
|
# RSI
|
||||||
|
rsi_value = rsi(closes, period=14)
|
||||||
|
if rsi_value and rsi_value < 30:
|
||||||
|
print("과매도 구간")
|
||||||
|
|
||||||
|
# 볼린저 밴드
|
||||||
|
bb = bollinger_bands(closes, period=20, std_mult=2.0)
|
||||||
|
if bb:
|
||||||
|
upper, middle, lower = bb
|
||||||
|
if closes[-1] < lower:
|
||||||
|
print("하단 돌파")
|
||||||
|
|
||||||
|
# SMA
|
||||||
|
sma_20 = sma(closes, 20)
|
||||||
|
sma_60 = sma(closes, 60)
|
||||||
|
if sma_20 and sma_60 and sma_20 > sma_60:
|
||||||
|
print("골든 크로스 발생")
|
||||||
|
|
||||||
|
# ATR
|
||||||
|
highs = [76000, 76500, ...]
|
||||||
|
lows = [74500, 75000, ...]
|
||||||
|
atr_value = atr(highs, lows, closes, period=14)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 종목 스캔
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.strategy.scanner import Scanner, filter_price_range, filter_volume_min
|
||||||
|
|
||||||
|
# 스캀너 생성
|
||||||
|
scanner = Scanner()
|
||||||
|
|
||||||
|
# 필터 추가
|
||||||
|
scanner.add_filter(filter_price_range(10000, 100000)) # 1만~10만원
|
||||||
|
scanner.add_filter(filter_volume_min(100000)) # 최소 거래량 10만주
|
||||||
|
|
||||||
|
# 커스텀 필터
|
||||||
|
def custom_filter(data):
|
||||||
|
# 등락률 5% 이상
|
||||||
|
return data.get("change_rate", 0) >= 5.0
|
||||||
|
|
||||||
|
scanner.add_filter(custom_filter)
|
||||||
|
|
||||||
|
# 스캔 실행
|
||||||
|
kospi_codes = ["005930", "000660", "035420", ...] # KOSPI 종목 리스트
|
||||||
|
matched = await scanner.scan(kospi_codes, max_concurrent=10)
|
||||||
|
|
||||||
|
for stock in matched:
|
||||||
|
print(f"{stock['stock_name']} ({stock['stock_code']}): "
|
||||||
|
f"{stock['current_price']}원, +{stock['change_rate']}%")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
### BaseStrategy
|
||||||
|
- ✅ 전략 추상화 (템플릿 메서드 패턴)
|
||||||
|
- ✅ `analyze()` 구현 강제
|
||||||
|
- ✅ `on_signal_executed()` 콜백
|
||||||
|
- ✅ `on_error()` 에러 핸들링
|
||||||
|
- ✅ 설정 관리 (config)
|
||||||
|
- ✅ 활성화/비활성화
|
||||||
|
|
||||||
|
### StrategyManager
|
||||||
|
- ✅ 전략 등록/삭제
|
||||||
|
- ✅ 우선순위 관리
|
||||||
|
- ✅ 단일/복수 전략 실행
|
||||||
|
- ✅ 병렬 실행 지원
|
||||||
|
- ✅ 신호 충돌 해결
|
||||||
|
- `highest_confidence`: 가장 높은 신뢰도
|
||||||
|
- `priority`: 전략 우선순위
|
||||||
|
- `majority_vote`: 다수결
|
||||||
|
- ✅ 신호 이력 관리
|
||||||
|
- ✅ 통계 정보
|
||||||
|
|
||||||
|
### Signal
|
||||||
|
- ✅ 표준화된 신호 데이터 구조
|
||||||
|
- ✅ 6가지 신호 타입 (BUY/SELL/HOLD/CLOSE/STOP_LOSS/TAKE_PROFIT)
|
||||||
|
- ✅ 손익 관리 필드
|
||||||
|
- ✅ 신뢰도 평가
|
||||||
|
- ✅ JSON 직렬화
|
||||||
|
|
||||||
|
### Indicators
|
||||||
|
- ✅ SMA (Simple Moving Average)
|
||||||
|
- ✅ EMA (Exponential Moving Average)
|
||||||
|
- ✅ RSI (Relative Strength Index)
|
||||||
|
- ✅ Bollinger Bands
|
||||||
|
- ✅ MACD
|
||||||
|
- ✅ ATR (Average True Range)
|
||||||
|
- ✅ VWAP (Volume Weighted Average Price)
|
||||||
|
|
||||||
|
### Scanner
|
||||||
|
- ✅ 조건 기반 종목 필터링
|
||||||
|
- ✅ 동시 다중 종목 스캔
|
||||||
|
- ✅ 사전 정의 필터
|
||||||
|
- 가격 범위
|
||||||
|
- 거래량 최소값
|
||||||
|
- 등락률 범위
|
||||||
|
- 시가총액 최소값
|
||||||
|
- ✅ 커스텀 필터 추가 가능
|
||||||
|
|
||||||
|
## 기존 코드 마이그레이션
|
||||||
|
|
||||||
|
기존 `services/` 폴더의 전략 파일들을 새 구조로 변환:
|
||||||
|
|
||||||
|
| 기존 파일 | 새 위치 |
|
||||||
|
|----------|----------|
|
||||||
|
| `day_trading_strategy.py` | `strategy/strategies/day_trading.py` |
|
||||||
|
| `swing_trading_strategy.py` | `strategy/strategies/swing_trading.py` |
|
||||||
|
| `momentum_strategy.py` | `strategy/strategies/momentum.py` |
|
||||||
|
|
||||||
|
변환 패턴:
|
||||||
|
```python
|
||||||
|
# 기존
|
||||||
|
def check_signal(stock_code, market_data):
|
||||||
|
if condition:
|
||||||
|
return DayTradeSignal(...)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 새 구조
|
||||||
|
class DayTradingStrategy(BaseStrategy):
|
||||||
|
async def analyze(self, stock_code, market_data, portfolio_data=None):
|
||||||
|
if condition:
|
||||||
|
return Signal(...)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2 완료 체크리스트
|
||||||
|
|
||||||
|
- [x] `strategy/base_strategy.py` 생성
|
||||||
|
- [x] `strategy/signal.py` 생성
|
||||||
|
- [x] `strategy/strategy_manager.py` 생성
|
||||||
|
- [x] `strategy/indicators.py` 생성
|
||||||
|
- [x] `strategy/scanner.py` 생성
|
||||||
|
- [x] `strategy/strategies/day_trading.py` 예시 생성
|
||||||
|
- [x] README 문서 작성
|
||||||
|
|
||||||
|
## 다음 단계 (Phase 3)
|
||||||
|
|
||||||
|
- [ ] WebSocket 모듈 분리 (`realtime/`)
|
||||||
|
- [ ] 실시간 시세 수신 구조 개선
|
||||||
|
- [ ] 장 스케줄 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2026-03-06
|
||||||
|
**작성자**: 서영우
|
||||||
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