LSTM v3 멀티피처, KIS OHLCV 배치, 동적 전략 강화

- deep_learning.py: INPUT_SIZE=7 (close/open/high/low/volume/rsi/macd),
  feature_scaler/target_scaler 분리, ModelRegistry LRU 종목별 격리 (v3 체크포인트)
- kis.py: get_daily_ohlcv() OHLCV 전체 반환, KISAsyncClient 비동기 배치 조회 추가,
  order() 지정가/조건부 주문 지원
- strategy/process.py: ATR/ADX 기반 동적 손절익절, 트레일링 스탑, 포지션 사이징 강화
- config.py: OLLAMA_NUM_THREAD=8 (9800X3D 최적화), LSTM_COOLDOWN/FAST_EPOCHS 환경변수화
- macro.py: 거시경제 지표 계산 개선
- ollama.py: VRAM 여유량 기반 선택적 언로드
- monitor.py: CPU 서킷 브레이커 연속 횟수 조건 추가
- ipc.py: IPC_STALENESS 600초로 확대
- news.py: 비동기 뉴스 수집 개선
- telegram.py, runner.py: 안정성 개선

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:08:33 +09:00
parent 37f6d87bec
commit 4e77a1acf1
11 changed files with 939 additions and 268 deletions

View File

@@ -3,20 +3,25 @@ import json
import numpy as np
from modules.services.ollama import OllamaManager
from modules.analysis.technical import TechnicalAnalyzer
from modules.analysis.deep_learning import PricePredictor
from modules.analysis.deep_learning import ModelRegistry
# [최적화] 워커 프로세스별 전역 변수 (LSTM 모델 캐싱)
_lstm_predictor = None
# [최적화] 워커 프로세스별 전역 변수 (Ollama 캐싱)
_ollama_manager = None
def get_predictor():
"""워커 프로세스 내에서 PricePredictor 인스턴스를 싱글톤으로 관리"""
global _lstm_predictor
if _lstm_predictor is None:
print(f"[Worker {os.getpid()}] Initializing LSTM Predictor...")
_lstm_predictor = PricePredictor()
print(f"[Worker {os.getpid()}] LSTM Device: {_lstm_predictor.device}"
f" | AMP: {_lstm_predictor.use_amp}")
return _lstm_predictor
def get_predictor(ticker=None):
"""워커 프로세스 내에서 ModelRegistry로 종목별 PricePredictor 관리"""
registry = ModelRegistry.get_instance()
return registry.get_predictor(ticker or "default")
def get_ollama():
"""워커 프로세스 내에서 OllamaManager 인스턴스를 싱글톤으로 관리
- 종목마다 새 인스턴스를 만들면 Ollama에 동시 요청이 폭주해 데드락 발생"""
global _ollama_manager
if _ollama_manager is None:
_ollama_manager = OllamaManager()
return _ollama_manager
def calculate_position_size(total_capital, current_price, volatility, score, ai_confidence,
@@ -40,9 +45,8 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
base_invest = total_capital * 0.10
# 2. 변동성 조절 계수 (변동성 높을수록 투자금 감소)
# 변동성 1% → 1.0배, 2% → 0.75배, 3% → 0.5배, 5%+ → 0.3배
if volatility <= 1.0:
vol_factor = 1.2 # 안정적 종목은 약간 증가
vol_factor = 1.2
elif volatility <= 2.0:
vol_factor = 1.0
elif volatility <= 3.0:
@@ -50,10 +54,9 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
elif volatility <= 5.0:
vol_factor = 0.45
else:
vol_factor = 0.3 # 고변동 종목
vol_factor = 0.3
# 3. 확신도 조절 계수 (score가 높을수록 투자금 증가)
# score 0.6 → 0.5배, 0.7 → 1.0배, 0.8 → 1.5배, 0.9+ → 2.0배
# 3. 확신도 조절 계수
if score >= 0.85:
conf_factor = 2.0
elif score >= 0.75:
@@ -73,45 +76,85 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
# 5. 최종 투자금 계산
invest_amount = base_invest * vol_factor * conf_factor * ai_bonus
# 상한 제한
invest_amount = min(invest_amount, max_per_stock)
invest_amount = min(invest_amount, total_capital * 0.15) # 최대 15%
invest_amount = min(invest_amount, total_capital) # 잔고 초과 방지
invest_amount = min(invest_amount, total_capital * 0.15)
invest_amount = min(invest_amount, total_capital)
# 수량 계산
qty = int(invest_amount / current_price)
return max(0, qty)
def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
macro_status=None, holding_info=None):
"""
[v2.0] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
[v3.0] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
[v2.0 개선사항]
1. ATR 기반 동적 손절/익절 + 트레일링 스탑
2. 포지션 사이징 (변동성 + 확신도 기반)
3. 시장상황별 동적 매수/매도 임계값
4. 보유종목에 대한 분석 기반 매도 판단
5. ADX/OBV/MTF 통합 기술적 분석
6. 강화된 AI 프롬프트 (종목 고유 뉴스 분석)
[v3.0 개선사항]
1. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
2. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
3. 강화된 LLM 프롬프트 (거시경제 상태, 볼린저밴드, 거래량 급증, 보유 수익률)
"""
try:
print(f"⚙️ [Bot Process] Analyzing {ticker} ({len(prices)} candles)...")
# OHLCV 데이터 분리 (하위호환: list 형태도 허용)
if isinstance(ohlcv_data, dict):
prices = ohlcv_data.get('close', [])
high_prices = ohlcv_data.get('high') or None
low_prices = ohlcv_data.get('low') or None
volume_history = ohlcv_data.get('volume') or None
open_prices = ohlcv_data.get('open') or None
else:
# 하위 호환: 기존 close 리스트
prices = ohlcv_data if isinstance(ohlcv_data, list) else []
high_prices = None
low_prices = None
volume_history = None
open_prices = None
# volume이 모두 0이거나 비어있으면 None 처리
if volume_history and all(v == 0 for v in volume_history):
volume_history = None
print(f"⚙️ [Bot Process] Analyzing {ticker} ({len(prices)} candles, "
f"OHLCV={'yes' if high_prices else 'close-only'}, "
f"Vol={'yes' if volume_history else 'no'})...")
# ===== 1. 기술적 지표 계산 =====
current_price = prices[-1] if prices else 0
tech_score, rsi, volatility, vol_ratio, ma_info = TechnicalAnalyzer.get_technical_score(
current_price, prices, volume_history=None)
current_price, prices, volume_history=volume_history)
# ===== 2. ATR 기반 동적 손절/익절 =====
sl_tp = TechnicalAnalyzer.calculate_dynamic_sl_tp(prices)
# ===== 2. ATR 기반 동적 손절/익절 (실제 고가/저가 사용) =====
sl_tp = TechnicalAnalyzer.calculate_dynamic_sl_tp(
prices, high_prices=high_prices, low_prices=low_prices)
# ===== 3. LSTM 주가 예측 =====
lstm_predictor = get_predictor()
# ===== 3. 볼린저밴드 위치 계산 =====
bb_upper, bb_mid, bb_lower = TechnicalAnalyzer.calculate_bollinger_bands(prices)
if bb_upper > bb_lower:
bb_pos = (current_price - bb_lower) / (bb_upper - bb_lower) # 0=하단, 1=상단
if bb_pos <= 0.2:
bb_zone = "하단(과매도)"
elif bb_pos >= 0.8:
bb_zone = "상단(과매수)"
else:
bb_zone = f"중간({bb_pos:.0%})"
else:
bb_pos = 0.5
bb_zone = "중간"
# ===== 4. LSTM 주가 예측 (ModelRegistry 사용) =====
lstm_predictor = get_predictor(ticker)
if lstm_predictor:
lstm_predictor.training_status['current_ticker'] = ticker
pred_result = lstm_predictor.train_and_predict(prices, ticker=ticker)
# LSTM에 전달할 OHLCV 딕셔너리 구성
lstm_ohlcv = {
'close': prices,
'open': open_prices or prices,
'high': high_prices or prices,
'low': low_prices or prices,
'volume': volume_history or []
}
pred_result = lstm_predictor.train_and_predict(lstm_ohlcv, ticker=ticker)
lstm_score = 0.5
ai_confidence = 0.5
@@ -130,7 +173,7 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
lstm_score = max(0.0, min(1.0, lstm_score))
# ===== 4. 수급 분석 (외인/기관) =====
# ===== 5. 수급 분석 (외인/기관) =====
investor_score = 0.0
frgn_net_buy = 0
orgn_net_buy = 0
@@ -146,26 +189,23 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
if day['institutional'] > 0:
consecutive_orgn_buy += 1
# 외인 수급 점수 (강화)
if frgn_net_buy > 0:
investor_score += 0.03
if consecutive_frgn_buy >= 3:
investor_score += 0.04
if consecutive_frgn_buy >= 5:
investor_score += 0.03 # 5일 연속 매수 = 추가 보너스
investor_score += 0.03
# 기관 수급 점수 (신규)
if orgn_net_buy > 0:
investor_score += 0.02
if consecutive_orgn_buy >= 3:
investor_score += 0.03
# 외인+기관 동시 순매수 = 강력 신호
if frgn_net_buy > 0 and orgn_net_buy > 0:
investor_score += 0.03
print(f" 💰 [Investor] Both Foreign & Institutional Buying!")
# ===== 5. AI 뉴스 분석 (강화된 프롬프트) =====
# ===== 6. AI 뉴스 분석 (강화된 프롬프트) =====
if pred_result:
pred_price = pred_result.get('predicted', 0)
pred_change = pred_result.get('change_rate', 0)
@@ -173,65 +213,59 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
pred_price = current_price
pred_change = 0.0
ollama = OllamaManager()
prompt = f"""
[System Instruction]
1. Role: You are a legendary quant trader with 30 years of experience in Korean stock market.
2. You MUST analyze the data objectively and respond with a JSON object.
news_summary = "; ".join(
[n.get('title', '') for n in (news_items or [])[:3] if n.get('title')]
) or "뉴스 없음"
[Market Data for Stock {ticker}]
- Current Price: {current_price:,.0f} KRW
- Technical Score: {tech_score:.3f} (RSI: {rsi:.1f})
- Moving Average: {ma_info['trend']} (Price is {ma_info['position']})
- ADX Trend Strength: {ma_info.get('adx', 20):.1f} ({ma_info.get('adx_trend', 'N/A')})
- Multi-Timeframe: {ma_info.get('mtf_alignment', 'N/A')}
- AI Prediction: {pred_price:.0f} KRW ({pred_change:+.2f}%)
- AI Confidence: {ai_confidence:.2f} (Training Loss: {ai_loss:.4f})
- Volatility: {volatility:.2f}%
- Volume Ratio: {vol_ratio:.1f}x
- ATR Stop Loss: {sl_tp['stop_loss_pct']:.1f}% / Take Profit: {sl_tp['take_profit_pct']:.1f}%
- Investor Trend (5 Days): Foreigner Net Buy {frgn_net_buy}, Institutional Net Buy {orgn_net_buy}
# 거시경제 상태
macro_state = macro_status.get('status', 'SAFE') if macro_status else 'SAFE'
[Decision Framework]
- Strong BUY signals: Foreigners+Institutions buying, Golden Cross, ADX>25 with bullish trend, AI high confidence UP
- Moderate BUY: RSI<40 with bullish reversal, Price near Bollinger Lower Band
- SELL signals: RSI>70, Dead Cross, ADX>25 with bearish trend, Foreigners selling
- AVOID/HOLD: ADX<20 (sideways), Mixed signals, Low confidence
# 거래량 급증 여부
vol_surge = "급증(x{:.1f})".format(vol_ratio) if vol_ratio >= 2.0 else "정상"
[News Data]
{json.dumps(news_items[:5] if news_items else [], ensure_ascii=False)}
# 보유종목 수익률
holding_yield_str = ""
if holding_info and holding_info.get('qty', 0) > 0:
yld = holding_info.get('yield', 0.0)
holding_yield_str = f" | 보유수익률={yld:+.1f}%"
[Response Format - JSON Only]
{{"sentiment_score": 0.0 to 1.0, "reason": "Brief analysis reason"}}
"""
ollama = get_ollama()
prompt = (
f"Korean stock analyst. JSON only: {{\"sentiment_score\":0.0-1.0,\"reason\":\"1 sentence\"}}\n"
f"Stock {ticker}{current_price:,.0f}{holding_yield_str}\n"
f"Market={macro_state} | "
f"Tech={tech_score:.2f} RSI={rsi:.1f} MA={ma_info['trend']} ADX={ma_info.get('adx',20):.0f} "
f"MTF={ma_info.get('mtf_alignment','N/A')}\n"
f"BB={bb_zone} | AI={pred_change:+.2f}% conf={ai_confidence:.0%} | "
f"Vol={volatility:.1f}% VolRatio={vol_surge}\n"
f"Flow: Frgn={frgn_net_buy:+,}({consecutive_frgn_buy}d) "
f"Inst={orgn_net_buy:+,}({consecutive_orgn_buy}d)\n"
f"News: {news_summary}"
)
ai_resp = ollama.request_inference(prompt)
sentiment_score = 0.5
ai_reason = ""
try:
data = json.loads(ai_resp)
sentiment_score = float(data.get("sentiment_score", 0.5))
sentiment_score = max(0.0, min(1.0, sentiment_score)) # 범위 강제
sentiment_score = max(0.0, min(1.0, sentiment_score))
ai_reason = data.get("reason", "")
except Exception:
print(f" ⚠️ AI response parse failed, using neutral (0.5)")
# ===== 6. 통합 점수 (동적 가중치 v2.0) =====
# ADX가 높으면 (추세가 강하면) LSTM과 기술적 분석 비중 증가
# ===== 7. 통합 점수 (동적 가중치 v2.0) =====
adx_val = ma_info.get('adx', 20)
if ai_confidence >= 0.85 and adx_val >= 25:
# 강한 추세 + 높은 AI 신뢰도: AI 최우선
w_tech, w_news, w_ai = 0.15, 0.15, 0.70
print(f" 🤖 [Ultra High Confidence + Strong Trend] AI Weight 70%")
elif ai_confidence >= 0.85:
w_tech, w_news, w_ai = 0.20, 0.20, 0.60
print(f" 🤖 [High Confidence] AI Weight 60%")
elif adx_val >= 30:
# 매우 강한 추세: 기술적 분석 우선
w_tech, w_news, w_ai = 0.50, 0.20, 0.30
print(f" 📊 [Very Strong Trend ADX={adx_val:.0f}] Tech Weight 50%")
elif adx_val < 20:
# 비추세/횡보: 뉴스와 수급 중시
w_tech, w_news, w_ai = 0.30, 0.40, 0.30
print(f" 📰 [Sideways ADX={adx_val:.0f}] News Weight 40%")
else:
@@ -239,63 +273,53 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
# 수급 가산점 (최대 +0.15)
total_score += min(investor_score, 0.15)
total_score = min(total_score, 1.0)
# ===== 7. 시장 상황별 동적 임계값 =====
# ===== 8. 시장 상황별 동적 임계값 =====
buy_threshold = 0.60
sell_threshold = 0.30
if macro_status:
macro_state = macro_status.get('status', 'SAFE')
if macro_state == 'DANGER':
buy_threshold = 999.0 # 매수 완전 차단
sell_threshold = 0.45 # 매도 기준 상향 (빨리 탈출)
buy_threshold = 999.0
sell_threshold = 0.45
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold raised to 0.45")
elif macro_state == 'CAUTION':
buy_threshold = 0.72 # 매수 기준 대폭 상향 (보수적)
sell_threshold = 0.38 # 매도 기준도 상향
buy_threshold = 0.72
sell_threshold = 0.38
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
# ===== 8. 매매 결정 =====
# ===== 9. 매매 결정 =====
decision = "HOLD"
decision_reason = ""
# --- 보유 종목 분석 기반 매도 (신규) ---
if holding_info:
holding_yield = holding_info.get('yield', 0.0)
holding_qty = holding_info.get('qty', 0)
peak_price = holding_info.get('peak_price', current_price)
if holding_qty > 0:
# A. 동적 손절 (ATR 기반)
if holding_yield <= sl_tp['stop_loss_pct']:
decision = "SELL"
decision_reason = f"Dynamic Stop Loss ({holding_yield:.1f}% <= {sl_tp['stop_loss_pct']:.1f}%)"
# B. 동적 익절 (ATR 기반)
elif holding_yield >= sl_tp['take_profit_pct']:
decision = "SELL"
decision_reason = f"Dynamic Take Profit ({holding_yield:.1f}% >= {sl_tp['take_profit_pct']:.1f}%)"
# C. 트레일링 스탑 (최고가 대비 하락)
elif peak_price > 0:
drop_from_peak = ((current_price - peak_price) / peak_price) * 100
if drop_from_peak <= -sl_tp['trailing_stop_pct'] and holding_yield > 2.0:
# 수익 상태에서만 트레일링 스탑 작동 (2% 이상 수익 확보)
decision = "SELL"
decision_reason = (f"Trailing Stop ({drop_from_peak:.1f}% from peak, "
f"threshold: -{sl_tp['trailing_stop_pct']:.1f}%)")
# D. 분석 기반 매도 (점수가 매도 임계값 이하)
if decision == "HOLD" and total_score <= sell_threshold:
decision = "SELL"
decision_reason = f"Analysis Signal (Score: {total_score:.2f} <= {sell_threshold:.2f})"
# E. 추세 반전 매도 (ADX 강한 하락추세)
if decision == "HOLD" and adx_val >= 30:
plus_di = ma_info.get('adx', 0) # 참고용
mtf_align = ma_info.get('mtf_alignment', '')
if mtf_align == 'STRONG_BEAR' and holding_yield < 0:
decision = "SELL"
@@ -303,11 +327,9 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
# --- 매수 판단 ---
if decision == "HOLD":
# 강한 단일 신호 매수 (기준 강화)
strong_signal = False
strong_reason = ""
# [강화] 복합 조건 매수 (단일 지표가 아닌 복합 조건)
if tech_score >= 0.75 and lstm_score >= 0.6 and sentiment_score >= 0.6:
strong_signal = True
strong_reason = "Triple Confirmation (Tech+AI+News)"
@@ -322,7 +344,6 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
strong_reason = f"Strong Multi-Timeframe Bullish + Tech {tech_score:.2f}"
if strong_signal and total_score >= buy_threshold - 0.05:
# 강한 신호는 임계값 약간 완화 허용
decision = "BUY"
decision_reason = strong_reason
print(f" 🎯 [{strong_reason}] → BUY!")
@@ -330,10 +351,9 @@ def analyze_stock_process(ticker, prices, news_items, investor_trend=None,
decision = "BUY"
decision_reason = f"Score {total_score:.2f} >= threshold {buy_threshold:.2f}"
# ===== 9. 포지션 사이징 =====
# ===== 10. 포지션 사이징 =====
suggested_qty = 0
if decision == "BUY":
# 기본 자산 1000만원으로 가정 (실제 run_cycle에서 덮어씀)
suggested_qty = calculate_position_size(
total_capital=10000000,
current_price=current_price,