- 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>
407 lines
16 KiB
Python
407 lines
16 KiB
Python
import os
|
|
import json
|
|
import numpy as np
|
|
from modules.services.ollama import OllamaManager
|
|
from modules.analysis.technical import TechnicalAnalyzer
|
|
from modules.analysis.deep_learning import ModelRegistry
|
|
|
|
# [최적화] 워커 프로세스별 전역 변수 (Ollama 캐싱)
|
|
_ollama_manager = None
|
|
|
|
|
|
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,
|
|
max_per_stock=3000000):
|
|
"""
|
|
[v2.0] 변동성 기반 포지션 사이징 (Modified Kelly Criterion)
|
|
|
|
핵심 원칙:
|
|
1. 변동성이 높으면 → 적은 수량 (리스크 관리)
|
|
2. 확신도(score)가 높으면 → 많은 수량 (기회 포착)
|
|
3. AI 신뢰도가 높으면 → 가산 비중
|
|
4. 절대 한 종목에 전체 자산의 15% 이상 투자하지 않음
|
|
|
|
Returns:
|
|
int: 매수 수량 (0이면 매수 안 함)
|
|
"""
|
|
if current_price <= 0 or total_capital <= 0:
|
|
return 0
|
|
|
|
# 1. 기본 투자금 (전체 자산의 10%)
|
|
base_invest = total_capital * 0.10
|
|
|
|
# 2. 변동성 조절 계수 (변동성 높을수록 투자금 감소)
|
|
if volatility <= 1.0:
|
|
vol_factor = 1.2
|
|
elif volatility <= 2.0:
|
|
vol_factor = 1.0
|
|
elif volatility <= 3.0:
|
|
vol_factor = 0.7
|
|
elif volatility <= 5.0:
|
|
vol_factor = 0.45
|
|
else:
|
|
vol_factor = 0.3
|
|
|
|
# 3. 확신도 조절 계수
|
|
if score >= 0.85:
|
|
conf_factor = 2.0
|
|
elif score >= 0.75:
|
|
conf_factor = 1.5
|
|
elif score >= 0.65:
|
|
conf_factor = 1.0
|
|
else:
|
|
conf_factor = 0.5
|
|
|
|
# 4. AI 신뢰도 가산
|
|
ai_bonus = 1.0
|
|
if ai_confidence >= 0.85:
|
|
ai_bonus = 1.3
|
|
elif ai_confidence >= 0.7:
|
|
ai_bonus = 1.1
|
|
|
|
# 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)
|
|
invest_amount = min(invest_amount, total_capital)
|
|
|
|
qty = int(invest_amount / current_price)
|
|
return max(0, qty)
|
|
|
|
|
|
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
|
macro_status=None, holding_info=None):
|
|
"""
|
|
[v3.0] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
|
|
|
|
[v3.0 개선사항]
|
|
1. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
|
|
2. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
|
|
3. 강화된 LLM 프롬프트 (거시경제 상태, 볼린저밴드, 거래량 급증, 보유 수익률)
|
|
"""
|
|
try:
|
|
# 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=volume_history)
|
|
|
|
# ===== 2. ATR 기반 동적 손절/익절 (실제 고가/저가 사용) =====
|
|
sl_tp = TechnicalAnalyzer.calculate_dynamic_sl_tp(
|
|
prices, high_prices=high_prices, low_prices=low_prices)
|
|
|
|
# ===== 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
|
|
|
|
# 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
|
|
ai_loss = 1.0
|
|
|
|
if pred_result:
|
|
ai_confidence = pred_result.get('confidence', 0.5)
|
|
ai_loss = pred_result.get('loss', 1.0)
|
|
|
|
change_magnitude = min(abs(pred_result['change_rate']), 5.0) / 5.0
|
|
|
|
if pred_result['trend'] == 'UP':
|
|
lstm_score = 0.5 + (change_magnitude * ai_confidence * 0.4)
|
|
else:
|
|
lstm_score = 0.5 - (change_magnitude * ai_confidence * 0.4)
|
|
|
|
lstm_score = max(0.0, min(1.0, lstm_score))
|
|
|
|
# ===== 5. 수급 분석 (외인/기관) =====
|
|
investor_score = 0.0
|
|
frgn_net_buy = 0
|
|
orgn_net_buy = 0
|
|
consecutive_frgn_buy = 0
|
|
consecutive_orgn_buy = 0
|
|
|
|
if investor_trend:
|
|
for day in investor_trend:
|
|
frgn_net_buy += day['foreigner']
|
|
orgn_net_buy += day['institutional']
|
|
if day['foreigner'] > 0:
|
|
consecutive_frgn_buy += 1
|
|
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
|
|
|
|
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!")
|
|
|
|
# ===== 6. AI 뉴스 분석 (강화된 프롬프트) =====
|
|
if pred_result:
|
|
pred_price = pred_result.get('predicted', 0)
|
|
pred_change = pred_result.get('change_rate', 0)
|
|
else:
|
|
pred_price = current_price
|
|
pred_change = 0.0
|
|
|
|
news_summary = "; ".join(
|
|
[n.get('title', '') for n in (news_items or [])[:3] if n.get('title')]
|
|
) or "뉴스 없음"
|
|
|
|
# 거시경제 상태
|
|
macro_state = macro_status.get('status', 'SAFE') if macro_status else 'SAFE'
|
|
|
|
# 거래량 급증 여부
|
|
vol_surge = "급증(x{:.1f})".format(vol_ratio) if vol_ratio >= 2.0 else "정상"
|
|
|
|
# 보유종목 수익률
|
|
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}%"
|
|
|
|
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))
|
|
ai_reason = data.get("reason", "")
|
|
except Exception:
|
|
print(f" ⚠️ AI response parse failed, using neutral (0.5)")
|
|
|
|
# ===== 7. 통합 점수 (동적 가중치 v2.0) =====
|
|
adx_val = ma_info.get('adx', 20)
|
|
|
|
if ai_confidence >= 0.85 and adx_val >= 25:
|
|
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:
|
|
w_tech, w_news, w_ai = 0.35, 0.30, 0.35
|
|
|
|
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
|
|
|
|
total_score += min(investor_score, 0.15)
|
|
total_score = min(total_score, 1.0)
|
|
|
|
# ===== 8. 시장 상황별 동적 임계값 =====
|
|
buy_threshold = 0.60
|
|
sell_threshold = 0.30
|
|
|
|
if macro_status:
|
|
if macro_state == 'DANGER':
|
|
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
|
|
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
|
|
|
|
# ===== 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:
|
|
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}%)"
|
|
|
|
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}%)"
|
|
|
|
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:
|
|
decision = "SELL"
|
|
decision_reason = (f"Trailing Stop ({drop_from_peak:.1f}% from peak, "
|
|
f"threshold: -{sl_tp['trailing_stop_pct']:.1f}%)")
|
|
|
|
if decision == "HOLD" and total_score <= sell_threshold:
|
|
decision = "SELL"
|
|
decision_reason = f"Analysis Signal (Score: {total_score:.2f} <= {sell_threshold:.2f})"
|
|
|
|
if decision == "HOLD" and adx_val >= 30:
|
|
mtf_align = ma_info.get('mtf_alignment', '')
|
|
if mtf_align == 'STRONG_BEAR' and holding_yield < 0:
|
|
decision = "SELL"
|
|
decision_reason = f"Strong Bear Trend Reversal (MTF: {mtf_align})"
|
|
|
|
# --- 매수 판단 ---
|
|
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)"
|
|
elif lstm_score >= 0.80 and ai_confidence >= 0.85 and adx_val >= 25:
|
|
strong_signal = True
|
|
strong_reason = f"High Confidence AI + Strong Trend (ADX={adx_val:.0f})"
|
|
elif investor_score >= 0.10 and tech_score >= 0.60 and total_score >= 0.60:
|
|
strong_signal = True
|
|
strong_reason = "Institutional Buying + Good Fundamentals"
|
|
elif ma_info.get('mtf_alignment') == 'STRONG_BULL' and tech_score >= 0.60:
|
|
strong_signal = True
|
|
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!")
|
|
elif total_score >= buy_threshold:
|
|
decision = "BUY"
|
|
decision_reason = f"Score {total_score:.2f} >= threshold {buy_threshold:.2f}"
|
|
|
|
# ===== 10. 포지션 사이징 =====
|
|
suggested_qty = 0
|
|
if decision == "BUY":
|
|
suggested_qty = calculate_position_size(
|
|
total_capital=10000000,
|
|
current_price=current_price,
|
|
volatility=volatility,
|
|
score=total_score,
|
|
ai_confidence=ai_confidence
|
|
)
|
|
if suggested_qty == 0:
|
|
decision = "HOLD"
|
|
decision_reason = "Position size too small"
|
|
|
|
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} "
|
|
f"LSTM={lstm_score:.2f} Inv={investor_score:.2f} → "
|
|
f"Total={total_score:.2f} [{decision}]"
|
|
f"{f' ({decision_reason})' if decision_reason else ''}")
|
|
|
|
return {
|
|
"ticker": ticker,
|
|
"score": total_score,
|
|
"tech": tech_score,
|
|
"sentiment": sentiment_score,
|
|
"lstm_score": lstm_score,
|
|
"investor_score": investor_score,
|
|
"volatility": volatility,
|
|
"volume_ratio": vol_ratio,
|
|
"prediction": pred_result,
|
|
"decision": decision,
|
|
"decision_reason": decision_reason,
|
|
"current_price": current_price,
|
|
"ma_info": ma_info,
|
|
"sl_tp": sl_tp,
|
|
"suggested_qty": suggested_qty,
|
|
"ai_confidence": ai_confidence,
|
|
"ai_reason": ai_reason
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"❌ [Worker Error] Failed to analyze {ticker}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return {
|
|
"ticker": ticker,
|
|
"score": 0.0,
|
|
"decision": "HOLD",
|
|
"decision_reason": f"Error: {str(e)}",
|
|
"current_price": 0,
|
|
"sl_tp": {'stop_loss_pct': -5.0, 'take_profit_pct': 8.0, 'trailing_stop_pct': 3.0},
|
|
"suggested_qty": 0,
|
|
"error": str(e)
|
|
}
|